Skip to content

Prepare for updating accounts in database #526

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 15 commits into from
Feb 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions lib/model/compose.dart
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ String quoteAndReplyPlaceholder(PerAccountStore store, {
final sender = store.users[message.senderId];
assert(sender != null);
final url = narrowLink(store,
SendableNarrow.ofMessage(message, selfUserId: store.account.userId),
SendableNarrow.ofMessage(message, selfUserId: store.selfUserId),
nearMessageId: message.id);
// See note in [quoteAndReply] about asking `mention` to omit the |<id> part.
return '${mention(sender!, silent: true)} ${inlineLink('said', url)}: ' // TODO(i18n) ?
Expand All @@ -164,7 +164,7 @@ String quoteAndReply(PerAccountStore store, {
final sender = store.users[message.senderId];
assert(sender != null);
final url = narrowLink(store,
SendableNarrow.ofMessage(message, selfUserId: store.account.userId),
SendableNarrow.ofMessage(message, selfUserId: store.selfUserId),
nearMessageId: message.id);
// Could ask `mention` to omit the |<id> part unless the mention is ambiguous…
// but that would mean a linear scan through all users, and the extra noise
Expand Down
15 changes: 15 additions & 0 deletions lib/model/database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,25 @@ import 'package:path_provider/path_provider.dart';

part 'database.g.dart';

/// The table of [Account] records in the app's database.
class Accounts extends Table {
/// The ID of this account in the app's local database.
///
/// This uniquely identifies the account within this install of the app,
/// and never changes for a given account. It has no meaning to the server,
/// though, or anywhere else outside this install of the app.
Column<int> get id => integer().autoIncrement()();

/// The URL of the Zulip realm this account is on.
///
/// This corresponds to [GetServerSettingsResult.realmUrl].
/// It never changes for a given account.
Column<String> get realmUrl => text().map(const UriConverter())();

/// The Zulip user ID of this account.
///
/// This is the identifier the server uses for the account.
/// It never changes for a given account.
Column<int> get userId => integer()();

Column<String> get email => text()();
Expand Down
15 changes: 15 additions & 0 deletions lib/model/database.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 5 additions & 15 deletions lib/model/internal_link.dart
Original file line number Diff line number Diff line change
Expand Up @@ -95,23 +95,13 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) {
fragment.write('/near/$nearMessageId');
}

return store.account.realmUrl.replace(fragment: fragment.toString());
}

/// Create a new `Uri` object in relation to a given realmUrl.
///
/// Returns `null` if `urlString` could not be parsed as a `Uri`.
Uri? tryResolveOnRealmUrl(String urlString, Uri realmUrl) {
try {
return realmUrl.resolve(urlString);
} on FormatException {
return null;
}
return store.realmUrl.replace(fragment: fragment.toString());
}

/// A [Narrow] from a given URL, on `store`'s realm.
///
/// `url` must already be passed through [tryResolveOnRealmUrl].
/// `url` must already be a result from [PerAccountStore.tryResolveUrl]
/// on `store`.
///
/// Returns `null` if any of the operator/operand pairs are invalid.
///
Expand All @@ -124,7 +114,7 @@ Uri? tryResolveOnRealmUrl(String urlString, Uri realmUrl) {
/// #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) {
if (!_isInternalLink(url, store.account.realmUrl)) return null;
if (!_isInternalLink(url, store.realmUrl)) return null;

final (category, segments) = _getCategoryAndSegmentsFromFragment(url.fragment);
switch (category) {
Expand Down Expand Up @@ -197,7 +187,7 @@ Narrow? _interpretNarrowSegments(List<String> segments, PerAccountStore store) {

if (dmElement != null) {
if (streamElement != null || topicElement != null) return null;
return DmNarrow.withUsers(dmElement.operand, selfUserId: store.account.userId);
return DmNarrow.withUsers(dmElement.operand, selfUserId: store.selfUserId);
} else if (streamElement != null) {
final streamId = streamElement.operand;
if (topicElement != null) {
Expand Down
109 changes: 78 additions & 31 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -111,22 +111,19 @@ abstract class GlobalStore extends ChangeNotifier {
}

// It's up to us. Start loading.
final account = getAccount(accountId);
assert(account != null, 'Account not found on global store');
future = loadPerAccount(account!);
future = loadPerAccount(accountId);
_perAccountStoresLoading[accountId] = future;
store = await future;
_setPerAccount(accountId, store);
_perAccountStoresLoading.remove(accountId);
return store;
}

Future<void> _reloadPerAccount(Account account) async {
assert(identical(_accounts[account.id], account));
assert(_perAccountStores.containsKey(account.id));
assert(!_perAccountStoresLoading.containsKey(account.id));
final store = await loadPerAccount(account);
_setPerAccount(account.id, store);
Future<void> _reloadPerAccount(int accountId) async {
assert(_perAccountStores.containsKey(accountId));
assert(!_perAccountStoresLoading.containsKey(accountId));
final store = await loadPerAccount(accountId);
_setPerAccount(accountId, store);
}

void _setPerAccount(int accountId, PerAccountStore store) {
Expand All @@ -141,7 +138,7 @@ abstract class GlobalStore extends ChangeNotifier {
/// This method should be called only by the implementation of [perAccount].
/// Other callers interested in per-account data should use [perAccount]
/// and/or [perAccountSync].
Future<PerAccountStore> loadPerAccount(Account account);
Future<PerAccountStore> loadPerAccount(int accountId);

// Just the Iterables, not the actual Map, to avoid clients mutating the map.
// Mutations should go through the setters/mutators below.
Expand Down Expand Up @@ -191,76 +188,106 @@ class PerAccountStore extends ChangeNotifier with StreamStore {
/// but it may have already been used for other requests.
factory PerAccountStore.fromInitialSnapshot({
required GlobalStore globalStore,
required Account account,
required int accountId,
ApiConnection? connection,
required InitialSnapshot initialSnapshot,
}) {
final account = globalStore.getAccount(accountId)!;
connection ??= globalStore.apiConnectionFromAccount(account);
final streams = StreamStoreImpl(initialSnapshot: initialSnapshot);
return PerAccountStore._(
globalStore: globalStore,
account: account,
connection: connection,
realmUrl: account.realmUrl,
zulipVersion: initialSnapshot.zulipVersion,
maxFileUploadSizeMib: initialSnapshot.maxFileUploadSizeMib,
realmDefaultExternalAccounts: initialSnapshot.realmDefaultExternalAccounts,
realmEmoji: initialSnapshot.realmEmoji,
customProfileFields: _sortCustomProfileFields(initialSnapshot.customProfileFields),
accountId: accountId,
selfUserId: account.userId,
userSettings: initialSnapshot.userSettings,
unreads: Unreads(
initial: initialSnapshot.unreadMsgs,
selfUserId: account.userId,
streamStore: streams,
),
users: Map.fromEntries(
initialSnapshot.realmUsers
.followedBy(initialSnapshot.realmNonActiveUsers)
.followedBy(initialSnapshot.crossRealmBots)
.map((user) => MapEntry(user.userId, user))),
streams: streams,
unreads: Unreads(
initial: initialSnapshot.unreadMsgs,
selfUserId: account.userId,
streamStore: streams,
),
recentDmConversationsView: RecentDmConversationsView(
initial: initialSnapshot.recentPrivateConversations, selfUserId: account.userId),
);
}

PerAccountStore._({
required GlobalStore globalStore,
required this.account,
required this.connection,
required this.realmUrl,
required this.zulipVersion,
required this.maxFileUploadSizeMib,
required this.realmDefaultExternalAccounts,
required this.realmEmoji,
required this.customProfileFields,
required this.accountId,
required this.selfUserId,
required this.userSettings,
required this.unreads,
required this.users,
required streams,
required this.unreads,
required this.recentDmConversationsView,
}) : _globalStore = globalStore,
}) : assert(selfUserId == globalStore.getAccount(accountId)!.userId),
assert(realmUrl == globalStore.getAccount(accountId)!.realmUrl),
assert(realmUrl == connection.realmUrl),
_globalStore = globalStore,
_streams = streams;

final GlobalStore _globalStore;
////////////////////////////////////////////////////////////////
// Data.

final Account account;
final ApiConnection connection; // TODO(#135): update zulipFeatureLevel with events
////////////////////////////////
// Where data comes from in the first place.

// TODO(#135): Keep all this data updated by handling Zulip events from the server.
final GlobalStore _globalStore;
final ApiConnection connection; // TODO(#135): update zulipFeatureLevel with events

////////////////////////////////
// Data attached to the realm or the server.

/// Always equal to `account.realmUrl` and `connection.realmUrl`.
final Uri realmUrl;

/// Resolve [reference] as a URL relative to [realmUrl].
///
/// This returns null if [reference] fails to parse as a URL.
Uri? tryResolveUrl(String reference) => _tryResolveUrl(realmUrl, reference);

final String zulipVersion; // TODO get from account; update there on initial snapshot
final int maxFileUploadSizeMib; // No event for this.
final Map<String, RealmDefaultExternalAccount> realmDefaultExternalAccounts;
Map<String, RealmEmojiItem> realmEmoji;
List<CustomProfileField> customProfileFields;

////////////////////////////////
// Data attached to the self-account on the realm.

final int accountId;
Account get account => _globalStore.getAccount(accountId)!;

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

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

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

final Map<int, User> users;

////////////////////////////////
// Streams, topics, and stuff about them.

@override
Expand All @@ -278,7 +305,10 @@ class PerAccountStore extends ChangeNotifier with StreamStore {
@visibleForTesting
StreamStoreImpl get debugStreamStore => _streams;

// TODO lots more data. When adding, be sure to update handleEvent too.
////////////////////////////////
// Messages, and summaries of messages.

final Unreads unreads;

final RecentDmConversationsView recentDmConversationsView;

Expand All @@ -294,8 +324,14 @@ class PerAccountStore extends ChangeNotifier with StreamStore {
assert(removed);
}

////////////////////////////////
// Other digests of data.

final AutocompleteViewManager autocompleteViewManager = AutocompleteViewManager();

// End of data.
////////////////////////////////////////////////////////////////

/// 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
Expand Down Expand Up @@ -464,6 +500,16 @@ class PerAccountStore extends ChangeNotifier with StreamStore {
}

const _apiSendMessage = sendMessage; // Bit ugly; for alternatives, see: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20PerAccountStore.20methods/near/1545809
const _tryResolveUrl = tryResolveUrl;

/// Like [Uri.resolve], but on failure return null instead of throwing.
Uri? tryResolveUrl(Uri baseUrl, String reference) {
try {
return baseUrl.resolve(reference);
} on FormatException {
return null;
}
}

/// A [GlobalStore] that uses a live server and live, persistent local database.
///
Expand Down Expand Up @@ -517,8 +563,8 @@ class LiveGlobalStore extends GlobalStore {
final AppDatabase _db;

@override
Future<PerAccountStore> loadPerAccount(Account account) async {
final updateMachine = await UpdateMachine.load(this, account);
Future<PerAccountStore> loadPerAccount(int accountId) async {
final updateMachine = await UpdateMachine.load(this, accountId);
return updateMachine.store;
}

Expand Down Expand Up @@ -554,7 +600,8 @@ class UpdateMachine {
/// Load the user's data from the server, and start an event queue going.
///
/// In the future this might load an old snapshot from local storage first.
static Future<UpdateMachine> load(GlobalStore globalStore, Account account) async {
static Future<UpdateMachine> load(GlobalStore globalStore, int accountId) async {
final account = globalStore.getAccount(accountId)!;
// TODO test UpdateMachine.load, now that it uses [GlobalStore.apiConnection]
final connection = globalStore.apiConnectionFromAccount(account);

Expand All @@ -566,7 +613,7 @@ class UpdateMachine {

final store = PerAccountStore.fromInitialSnapshot(
globalStore: globalStore,
account: account,
accountId: accountId,
connection: connection,
initialSnapshot: initialSnapshot,
);
Expand Down Expand Up @@ -624,7 +671,7 @@ class UpdateMachine {
switch (e) {
case ZulipApiException(code: 'BAD_EVENT_QUEUE_ID'):
assert(debugLog('Lost event queue for $store. Replacing…'));
await store._globalStore._reloadPerAccount(store.account);
await store._globalStore._reloadPerAccount(store.accountId);
dispose();
debugLog('… Event queue replaced.');
return;
Expand Down
3 changes: 1 addition & 2 deletions lib/widgets/action_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,11 @@ void showMessageActionSheet({required BuildContext context, required Message mes
// any message list, so that's fine.
final isComposeBoxOffered = MessageListPage.composeBoxControllerOf(context) != null;

final selfUserId = store.account.userId;
final hasThumbsUpReactionVote = message.reactions
?.aggregated.any((reactionWithVotes) =>
reactionWithVotes.reactionType == ReactionType.unicodeEmoji
&& reactionWithVotes.emojiCode == '1f44d'
&& reactionWithVotes.userIds.contains(selfUserId))
&& reactionWithVotes.userIds.contains(store.selfUserId))
?? false;

showDraggableScrollableModalBottomSheet(
Expand Down
2 changes: 1 addition & 1 deletion lib/widgets/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ class HomePage extends StatelessWidget {
const SizedBox(height: 12),
Text.rich(TextSpan(
text: 'Connected to: ',
children: [bold(store.account.realmUrl.toString())])),
children: [bold(store.realmUrl.toString())])),
Text.rich(TextSpan(
text: 'Zulip server version: ',
children: [bold(store.zulipVersion)])),
Expand Down
Loading