Skip to content

Add UserStore; pull out getUser, userDisplayName, others #1327

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Feb 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fbee491
test [nfc]: Add the few missing awaits on handleEvent calls
gnprice Feb 15, 2025
d5caa78
user: Split a UserStore out from PerAccountStore
gnprice Feb 5, 2025
af10aea
user [nfc]: Refer to "user store" rather than "store.users" in text
gnprice Feb 15, 2025
7168c48
user [nfc]: Document users map, especially its incompleteness
gnprice Feb 5, 2025
a7ecf5b
user [nfc]: Factor out a userDisplayName
gnprice Feb 5, 2025
cffe2e4
inbox [nfc]: Inline and unhoist a self-user lookup
gnprice Feb 5, 2025
c077a45
recent dms [nfc]: Unhoist a self-user lookup
gnprice Feb 5, 2025
581e377
compose [nfc]: Unhoist a self-user lookup
gnprice Feb 5, 2025
ea46825
store [nfc]: Add a zulipFeatureLevel getter
gnprice Feb 5, 2025
1fd0c3f
user [nfc]: Move selfUserId to UserStore
gnprice Feb 5, 2025
626d473
user [nfc]: Add a selfUser getter
gnprice Feb 5, 2025
de98200
compose [nfc]: Take UserStore in userMention, rather than bare Map
gnprice Feb 5, 2025
8b172ba
user [nfc]: Introduce senderDisplayName
gnprice Feb 5, 2025
4adf1fc
user [nfc]: Note unknown-user crashes where senderDisplayName can help
gnprice Feb 5, 2025
b61f135
user [nfc]: Note places lacking live-update where senderDisplayName h…
gnprice Feb 5, 2025
6bbe209
autocomplete [nfc]: Make explicit why two user lookups have null-asse…
gnprice Feb 5, 2025
268a462
user [nfc]: Factor out a getUser method
gnprice Feb 5, 2025
dadf9de
user [nfc]: Factor out an allUsers iterable
gnprice Feb 5, 2025
30c64a0
user [nfc]: Make the actual users Map private
gnprice Feb 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions lib/model/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,7 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
required PerAccountStore store,
required Narrow narrow,
}) {
return store.users.values.toList()
return store.allUsers.toList()
..sort(_comparator(store: store, narrow: narrow));
}

Expand Down Expand Up @@ -622,13 +622,13 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
if (tryOption(WildcardMentionOption.all)) break all;
if (tryOption(WildcardMentionOption.everyone)) break all;
if (isComposingChannelMessage) {
final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9)
final isChannelWildcardAvailable = store.zulipFeatureLevel >= 247; // TODO(server-9)
if (isChannelWildcardAvailable && tryOption(WildcardMentionOption.channel)) break all;
if (tryOption(WildcardMentionOption.stream)) break all;
}
}

final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 224; // TODO(server-8)
final isTopicWildcardAvailable = store.zulipFeatureLevel >= 224; // TODO(server-8)
if (isComposingChannelMessage && isTopicWildcardAvailable) {
tryOption(WildcardMentionOption.topic);
}
Expand Down
20 changes: 11 additions & 9 deletions lib/model/compose.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import '../generated/l10n/zulip_localizations.dart';
import 'internal_link.dart';
import 'narrow.dart';
import 'store.dart';
import 'user.dart';

/// The available user wildcard mention options,
/// known to the server as [canonicalString].
Expand Down Expand Up @@ -127,11 +128,12 @@ String wrapWithBacktickFence({required String content, String? infoString}) {
/// An @-mention of an individual user, like @**Chris Bobbe|13313**.
///
/// To omit the user ID part ("|13313") whenever the name part is unambiguous,
/// pass a Map of all users we know about. This means accepting a linear scan
/// pass the full UserStore. This means accepting a linear scan
/// through all users; avoid it in performance-sensitive codepaths.
String userMention(User user, {bool silent = false, Map<int, User>? users}) {
String userMention(User user, {bool silent = false, UserStore? users}) {
bool includeUserId = users == null
|| users.values.where((u) => u.fullName == user.fullName).take(2).length == 2;
|| users.allUsers.where((u) => u.fullName == user.fullName)
.take(2).length == 2;

return '@${silent ? '_' : ''}**${user.fullName}${includeUserId ? '|${user.userId}' : ''}**';
}
Expand All @@ -140,8 +142,8 @@ String userMention(User user, {bool silent = false, Map<int, User>? users}) {
String wildcardMention(WildcardMentionOption wildcardOption, {
required PerAccountStore store,
}) {
final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9)
final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 224; // TODO(server-8)
final isChannelWildcardAvailable = store.zulipFeatureLevel >= 247; // TODO(server-9)
final isTopicWildcardAvailable = store.zulipFeatureLevel >= 224; // TODO(server-8)

String name = wildcardOption.canonicalString;
switch (wildcardOption) {
Expand Down Expand Up @@ -188,8 +190,8 @@ String quoteAndReplyPlaceholder(
PerAccountStore store, {
required Message message,
}) {
final sender = store.users[message.senderId];
assert(sender != null);
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);
Expand All @@ -210,8 +212,8 @@ String quoteAndReply(PerAccountStore store, {
required Message message,
required String rawContent,
}) {
final sender = store.users[message.senderId];
assert(sender != null);
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);
Expand Down
2 changes: 1 addition & 1 deletion lib/model/internal_link.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ String? decodeHashComponent(String str) {
Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) {
// TODO(server-7)
final apiNarrow = resolveApiNarrowForServer(
narrow.apiEncode(), store.connection.zulipFeatureLevel!);
narrow.apiEncode(), store.zulipFeatureLevel);
final fragment = StringBuffer('narrow');
for (ApiNarrowElement element in apiNarrow) {
fragment.write('/');
Expand Down
69 changes: 25 additions & 44 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import 'recent_senders.dart';
import 'channel.dart';
import 'typing_status.dart';
import 'unreads.dart';
import 'user.dart';

export 'package:drift/drift.dart' show Value;
export 'database.dart' show Account, AccountsCompanion, AccountAlreadyExistsException;
Expand Down Expand Up @@ -266,7 +267,7 @@ class AccountNotFoundException implements Exception {}
/// This class does not attempt to poll an event queue
/// to keep the data up to date. For that behavior, see
/// [UpdateMachine].
class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, MessageStore {
class PerAccountStore extends ChangeNotifier with EmojiStore, UserStore, ChannelStore, MessageStore {
/// Construct a store for the user's data, starting from the given snapshot.
///
/// The global store must already have been updated with
Expand Down Expand Up @@ -307,7 +308,6 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
emoji: EmojiStoreImpl(
realmUrl: realmUrl, allRealmEmoji: initialSnapshot.realmEmoji),
accountId: accountId,
selfUserId: account.userId,
userSettings: initialSnapshot.userSettings,
typingNotifier: TypingNotifier(
connection: connection,
Expand All @@ -316,11 +316,9 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
typingStartedWaitPeriod: Duration(
milliseconds: initialSnapshot.serverTypingStartedWaitPeriodMilliseconds),
),
users: Map.fromEntries(
initialSnapshot.realmUsers
.followedBy(initialSnapshot.realmNonActiveUsers)
.followedBy(initialSnapshot.crossRealmBots)
.map((user) => MapEntry(user.userId, user))),
users: UserStoreImpl(
selfUserId: account.userId,
initialSnapshot: initialSnapshot),
typingStatus: TypingStatus(
selfUserId: account.userId,
typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds),
Expand Down Expand Up @@ -351,22 +349,21 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
required this.emailAddressVisibility,
required EmojiStoreImpl emoji,
required this.accountId,
required this.selfUserId,
required this.userSettings,
required this.typingNotifier,
required this.users,
required UserStoreImpl users,
required this.typingStatus,
required ChannelStoreImpl channels,
required MessageStoreImpl messages,
required this.unreads,
required this.recentDmConversationsView,
required this.recentSenders,
}) : assert(selfUserId == globalStore.getAccount(accountId)!.userId),
Copy link
Member

Choose a reason for hiding this comment

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

I can see that this assertion was already ineffective — data stores like TypingStatus, Unreads, and RecentDmConversationsView could all take different selfUserIds without detecting the discrepancy.

In contrast, the three surviving assertions check that realmUrl is consistent across the data stores. While we can do that for selfUserId too, I'm not sure if that's useful. A bug can slip through if we add a new realmUrl without adding a new assertion below, and the same issue applies to selfUserId assertions if we are to maintain them.

Perhaps a better way would be sharing the reference to realmUrl and selfUserId, instead of passing them multiple times, but the way PerAccountStore.fromInitialSnapshot is set up already makes it easy to check for mistakes, making this less of an issue.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, this check is basically already internal to the implementation of the PerAccountStore constructors, so I think it's not essential to keep.

Copy link
Member Author

Choose a reason for hiding this comment

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

Perhaps a better way would be sharing the reference to realmUrl and selfUserId, instead of passing them multiple times,

This is also something I think we'll want to do — what I'm imagining is making a type named perhaps BasePerAccountStore, with those and perhaps connection and/or account (which subsumes them), and then these other sub-stores can say on BasePerAccountStore to get access to those getters.

(Naturally that's for a future PR, though.)

assert(realmUrl == globalStore.getAccount(accountId)!.realmUrl),
}) : assert(realmUrl == globalStore.getAccount(accountId)!.realmUrl),
assert(realmUrl == connection.realmUrl),
assert(emoji.realmUrl == realmUrl),
_globalStore = globalStore,
_emoji = emoji,
_users = users,
_channels = channels,
_messages = messages;

Expand Down Expand Up @@ -407,6 +404,10 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
/// This returns null if [reference] fails to parse as a URL.
Uri? tryResolveUrl(String reference) => _tryResolveUrl(realmUrl, reference);

/// Always equal to `connection.zulipFeatureLevel`
/// and `account.zulipFeatureLevel`.
int get zulipFeatureLevel => connection.zulipFeatureLevel!;
Copy link
Member

Choose a reason for hiding this comment

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

Really convenient!


String get zulipVersion => account.zulipVersion;
final RealmWildcardMentionPolicy realmWildcardMentionPolicy; // TODO(#668): update this realm setting
final bool realmMandatoryTopics; // TODO(#668): update this realm setting
Expand Down Expand Up @@ -455,17 +456,23 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
/// Will throw if called after [dispose] has been called.
Account get account => _globalStore.getAccount(accountId)!;

/// Always equal to `account.userId`.
final int selfUserId;

final UserSettings? userSettings; // TODO(server-5)

final TypingNotifier typingNotifier;

////////////////////////////////
// Users and data about them.

final Map<int, User> users;
@override
int get selfUserId => _users.selfUserId;

@override
User? getUser(int userId) => _users.getUser(userId);

@override
Iterable<User> get allUsers => _users.allUsers;

final UserStoreImpl _users;

final TypingStatus typingStatus;

Expand Down Expand Up @@ -634,44 +641,18 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess

case RealmUserAddEvent():
assert(debugLog("server event: realm_user/add"));
users[event.person.userId] = event.person;
_users.handleRealmUserEvent(event);
notifyListeners();

case RealmUserRemoveEvent():
assert(debugLog("server event: realm_user/remove"));
users.remove(event.userId);
_users.handleRealmUserEvent(event);
autocompleteViewManager.handleRealmUserRemoveEvent(event);
notifyListeners();

case RealmUserUpdateEvent():
assert(debugLog("server event: realm_user/update"));
final user = users[event.userId];
if (user == null) {
return; // TODO log
}
if (event.fullName != null) user.fullName = event.fullName!;
if (event.avatarUrl != null) user.avatarUrl = event.avatarUrl!;
if (event.avatarVersion != null) user.avatarVersion = event.avatarVersion!;
if (event.timezone != null) user.timezone = event.timezone!;
if (event.botOwnerId != null) user.botOwnerId = event.botOwnerId!;
if (event.role != null) user.role = event.role!;
if (event.isBillingAdmin != null) user.isBillingAdmin = event.isBillingAdmin!;
if (event.deliveryEmail != null) user.deliveryEmail = event.deliveryEmail!.value;
if (event.newEmail != null) user.email = event.newEmail!;
if (event.isActive != null) user.isActive = event.isActive!;
if (event.customProfileField != null) {
final profileData = (user.profileData ??= {});
final update = event.customProfileField!;
if (update.value != null) {
profileData[update.id] = ProfileFieldUserData(value: update.value!, renderedValue: update.renderedValue);
} else {
profileData.remove(update.id);
}
if (profileData.isEmpty) {
// null is equivalent to `{}` for efficiency; see [User._readProfileData].
user.profileData = null;
}
}
_users.handleRealmUserEvent(event);
autocompleteViewManager.handleRealmUserUpdateEvent(event);
notifyListeners();

Expand Down
142 changes: 142 additions & 0 deletions lib/model/user.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import '../api/model/events.dart';
import '../api/model/initial_snapshot.dart';
import '../api/model/model.dart';
import 'localizations.dart';

/// The portion of [PerAccountStore] describing the users in the realm.
mixin UserStore {
/// The user ID of the "self-user",
/// i.e. the account the person using this app is logged into.
///
/// This always equals the [Account.userId] on [PerAccountStore.account].
///
/// For the corresponding [User] object, see [selfUser].
int get selfUserId;

/// The user with the given ID, if that user is known.
///
/// There may be other users that are perfectly real but are
/// not known to the app, for multiple reasons:
///
/// * The self-user may not have permission to see all the users in the
/// realm, for example because the self-user is a guest.
///
/// * Because of the fetch/event race, any data that the client fetched
/// outside of the event system may reflect an earlier or later time
/// than this data, which is maintained by the event system.
/// This includes messages fetched for a message list, and notifications.
/// Those may therefore refer to users for which we have yet to see the
/// [RealmUserAddEvent], or have already handled a [RealmUserRemoveEvent].
///
/// Code that looks up a user here should therefore always handle
/// the possibility that the user is not found (except
/// where there is a specific reason to know the user should be found).
/// Consider using [userDisplayName].
User? getUser(int userId);

/// All known users in the realm.
///
/// This may have a large number of elements, like tens of thousands.
/// Consider [getUser] or other alternatives to iterating through this.
///
/// There may be perfectly real users which are not known
/// and so are not found here. For details, see [getUser].
Iterable<User> get allUsers;

/// The [User] object for the "self-user",
/// i.e. the account the person using this app is logged into.
///
/// When only the user ID is needed, see [selfUserId].
User get selfUser => getUser(selfUserId)!;

/// 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)".
///
/// When a [Message] is available which the user sent,
/// use [senderDisplayName] instead for a better-informed fallback.
String userDisplayName(int userId) {
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 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;
}
}

/// The implementation of [UserStore] that does the work.
///
/// 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 with UserStore {
UserStoreImpl({
required this.selfUserId,
required InitialSnapshot initialSnapshot,
}) : _users = Map.fromEntries(
initialSnapshot.realmUsers
.followedBy(initialSnapshot.realmNonActiveUsers)
.followedBy(initialSnapshot.crossRealmBots)
.map((user) => MapEntry(user.userId, user)));

@override
final int selfUserId;

final Map<int, User> _users;

@override
User? getUser(int userId) => _users[userId];

@override
Iterable<User> get allUsers => _users.values;

void handleRealmUserEvent(RealmUserEvent event) {
switch (event) {
case RealmUserAddEvent():
_users[event.person.userId] = event.person;

case RealmUserRemoveEvent():
_users.remove(event.userId);

case RealmUserUpdateEvent():
final user = _users[event.userId];
if (user == null) {
return; // TODO log
}
if (event.fullName != null) user.fullName = event.fullName!;
if (event.avatarUrl != null) user.avatarUrl = event.avatarUrl!;
if (event.avatarVersion != null) user.avatarVersion = event.avatarVersion!;
if (event.timezone != null) user.timezone = event.timezone!;
if (event.botOwnerId != null) user.botOwnerId = event.botOwnerId!;
if (event.role != null) user.role = event.role!;
if (event.isBillingAdmin != null) user.isBillingAdmin = event.isBillingAdmin!;
if (event.deliveryEmail != null) user.deliveryEmail = event.deliveryEmail!.value;
if (event.newEmail != null) user.email = event.newEmail!;
if (event.isActive != null) user.isActive = event.isActive!;
if (event.customProfileField != null) {
final profileData = (user.profileData ??= {});
final update = event.customProfileField!;
if (update.value != null) {
profileData[update.id] = ProfileFieldUserData(value: update.value!, renderedValue: update.renderedValue);
} else {
profileData.remove(update.id);
}
if (profileData.isEmpty) {
// null is equivalent to `{}` for efficiency; see [User._readProfileData].
user.profileData = null;
}
}
}
}
}
Loading