Parcourir la source

feat(mobile): unify partner assets on timeline (#4974)

* feat(mobile): unify partner assets on timeline

* skip non-owned assets in bulk actions

* add message when trying to delete partner assets
Fynn Petersen-Frey il y a 1 an
Parent
commit
9fa9ad05b1

+ 4 - 0
mobile/assets/i18n/en-US.json

@@ -175,6 +175,10 @@
   "home_page_favorite_err_local": "Can not favorite local assets yet, skipping",
   "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
   "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping",
+  "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping",
+  "home_page_album_err_partner": "Can not add partner assets to an album yet, skipping",
+  "home_page_archive_err_partner": "Can not archive partner assets, skipping",
+  "home_page_delete_err_partner": "Can not delete partner assets, skipping",
   "image_viewer_page_state_provider_download_error": "Download Error",
   "image_viewer_page_state_provider_download_success": "Download Success",
   "image_viewer_page_state_provider_share_error": "Share Error",

+ 4 - 11
mobile/lib/modules/archive/providers/archive_asset_provider.dart

@@ -1,15 +1,14 @@
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
-import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
-import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/db.provider.dart';
 import 'package:immich_mobile/shared/providers/user.provider.dart';
+import 'package:immich_mobile/utils/renderlist_generator.dart';
 import 'package:isar/isar.dart';
 
-final archiveProvider = StreamProvider<RenderList>((ref) async* {
+final archiveProvider = StreamProvider<RenderList>((ref) {
   final user = ref.watch(currentUserProvider);
-  if (user == null) return;
+  if (user == null) return const Stream.empty();
   final query = ref
       .watch(dbProvider)
       .assets
@@ -19,11 +18,5 @@ final archiveProvider = StreamProvider<RenderList>((ref) async* {
       .isArchivedEqualTo(true)
       .isTrashedEqualTo(false)
       .sortByFileCreatedAt();
-  final settings = ref.watch(appSettingsServiceProvider);
-  final groupBy =
-      GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
-  yield await RenderList.fromQuery(query, groupBy);
-  await for (final _ in query.watchLazy()) {
-    yield await RenderList.fromQuery(query, groupBy);
-  }
+  return renderListGenerator(query, ref);
 });

+ 3 - 12
mobile/lib/modules/asset_viewer/providers/render_list.provider.dart

@@ -3,6 +3,7 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structu
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/utils/renderlist_generator.dart';
 import 'package:isar/isar.dart';
 
 final renderListProvider =
@@ -17,16 +18,6 @@ final renderListProvider =
 
 final renderListQueryProvider = StreamProvider.family<RenderList,
     QueryBuilder<Asset, Asset, QAfterSortBy>?>(
-  (ref, query) async* {
-    if (query == null) {
-      return;
-    }
-    final settings = ref.watch(appSettingsServiceProvider);
-    final groupBy = GroupAssetsBy
-        .values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
-    yield await RenderList.fromQuery(query, groupBy);
-    await for (final _ in query.watchLazy()) {
-      yield await RenderList.fromQuery(query, groupBy);
-    }
-  },
+  (ref, query) =>
+      query == null ? const Stream.empty() : renderListGenerator(query, ref),
 );

+ 4 - 11
mobile/lib/modules/favorite/providers/favorite_provider.dart

@@ -1,15 +1,14 @@
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
-import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
-import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/db.provider.dart';
 import 'package:immich_mobile/shared/providers/user.provider.dart';
+import 'package:immich_mobile/utils/renderlist_generator.dart';
 import 'package:isar/isar.dart';
 
-final favoriteAssetsProvider = StreamProvider<RenderList>((ref) async* {
+final favoriteAssetsProvider = StreamProvider<RenderList>((ref) {
   final user = ref.watch(currentUserProvider);
-  if (user == null) return;
+  if (user == null) return const Stream.empty();
   final query = ref
       .watch(dbProvider)
       .assets
@@ -19,11 +18,5 @@ final favoriteAssetsProvider = StreamProvider<RenderList>((ref) async* {
       .isFavoriteEqualTo(true)
       .isTrashedEqualTo(false)
       .sortByFileCreatedAt();
-  final settings = ref.watch(appSettingsServiceProvider);
-  final groupBy =
-      GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
-  yield await RenderList.fromQuery(query, groupBy);
-  await for (final _ in query.watchLazy()) {
-    yield await RenderList.fromQuery(query, groupBy);
-  }
+  return renderListGenerator(query, ref);
 });

+ 74 - 27
mobile/lib/modules/home/views/home_page.dart

@@ -44,6 +44,7 @@ class HomePage extends HookConsumerWidget {
     final sharedAlbums = ref.watch(sharedAlbumProvider);
     final albumService = ref.watch(albumServiceProvider);
     final currentUser = ref.watch(currentUserProvider);
+    final timelineUsers = ref.watch(timelineUsersIdsProvider);
     final trashEnabled =
         ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
 
@@ -55,6 +56,7 @@ class HomePage extends HookConsumerWidget {
       () {
         ref.read(websocketProvider.notifier).connect();
         Future(() => ref.read(assetProvider.notifier).getAllAsset());
+        ref.read(assetProvider.notifier).getPartnerAssets();
         ref.read(albumProvider.notifier).getAllAlbums();
         ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
         ref.read(serverInfoProvider.notifier).getServerInfo();
@@ -84,20 +86,49 @@ class HomePage extends HookConsumerWidget {
             SelectionAssetState.fromSelection(selectedAssets);
       }
 
-      List<Asset> remoteOnlySelection({String? localErrorMessage}) {
-        final Set<Asset> assets = selection.value;
+      errorBuilder(String? msg) => msg != null && msg.isNotEmpty
+          ? () => ImmichToast.show(
+                context: context,
+                msg: msg,
+                gravity: ToastGravity.BOTTOM,
+              )
+          : null;
+
+      Iterable<Asset> remoteOnly(
+        Iterable<Asset> assets, {
+        void Function()? errorCallback,
+      }) {
         final bool onlyRemote = assets.every((e) => e.isRemote);
         if (!onlyRemote) {
-          if (localErrorMessage != null && localErrorMessage.isNotEmpty) {
-            ImmichToast.show(
-              context: context,
-              msg: localErrorMessage,
-              gravity: ToastGravity.BOTTOM,
-            );
-          }
-          return assets.where((a) => a.isRemote).toList();
+          if (errorCallback != null) errorCallback();
+          return assets.where((a) => a.isRemote);
         }
-        return assets.toList();
+        return assets;
+      }
+
+      Iterable<Asset> ownedOnly(
+        Iterable<Asset> assets, {
+        void Function()? errorCallback,
+      }) {
+        if (currentUser == null) return [];
+        final userId = currentUser.isarId;
+        final bool onlyOwned = assets.every((e) => e.ownerId == userId);
+        if (!onlyOwned) {
+          if (errorCallback != null) errorCallback();
+          return assets.where((a) => a.ownerId == userId);
+        }
+        return assets;
+      }
+
+      Iterable<Asset> ownedRemoteSelection({
+        String? localErrorMessage,
+        String? ownerErrorMessage,
+      }) {
+        final assets = selection.value;
+        return remoteOnly(
+          ownedOnly(assets, errorCallback: errorBuilder(ownerErrorMessage)),
+          errorCallback: errorBuilder(localErrorMessage),
+        );
       }
 
       void onShareAssets(bool shareLocal) {
@@ -105,7 +136,7 @@ class HomePage extends HookConsumerWidget {
         if (shareLocal) {
           handleShareAssets(ref, context, selection.value.toList());
         } else {
-          final ids = remoteOnlySelection().map((e) => e.remoteId!);
+          final ids = ownedRemoteSelection().map((e) => e.remoteId!);
           context.autoPush(SharedLinkEditRoute(assetsList: ids.toList()));
         }
         processing.value = false;
@@ -115,11 +146,12 @@ class HomePage extends HookConsumerWidget {
       void onFavoriteAssets() async {
         processing.value = true;
         try {
-          final remoteAssets = remoteOnlySelection(
+          final remoteAssets = ownedRemoteSelection(
             localErrorMessage: 'home_page_favorite_err_local'.tr(),
+            ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
           );
           if (remoteAssets.isNotEmpty) {
-            await handleFavoriteAssets(ref, context, remoteAssets);
+            await handleFavoriteAssets(ref, context, remoteAssets.toList());
           }
         } finally {
           processing.value = false;
@@ -130,10 +162,11 @@ class HomePage extends HookConsumerWidget {
       void onArchiveAsset() async {
         processing.value = true;
         try {
-          final remoteAssets = remoteOnlySelection(
+          final remoteAssets = ownedRemoteSelection(
             localErrorMessage: 'home_page_archive_err_local'.tr(),
+            ownerErrorMessage: 'home_page_archive_err_partner'.tr(),
           );
-          await handleArchiveAssets(ref, context, remoteAssets);
+          await handleArchiveAssets(ref, context, remoteAssets.toList());
         } finally {
           processing.value = false;
           selectionEnabledHook.value = false;
@@ -143,12 +176,16 @@ class HomePage extends HookConsumerWidget {
       void onDelete() async {
         processing.value = true;
         try {
+          final toDelete = ownedOnly(
+            selection.value,
+            errorCallback: errorBuilder('home_page_delete_err_partner'.tr()),
+          ).toList();
           await ref
               .read(assetProvider.notifier)
-              .deleteAssets(selection.value, force: !trashEnabled);
+              .deleteAssets(toDelete, force: !trashEnabled);
 
-          final hasRemote = selection.value.any((a) => a.isRemote);
-          final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
+          final hasRemote = toDelete.any((a) => a.isRemote);
+          final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset';
           final trashOrRemoved =
               !trashEnabled ? 'deleted permanently' : 'trashed';
           if (hasRemote) {
@@ -180,8 +217,9 @@ class HomePage extends HookConsumerWidget {
       void onAddToAlbum(Album album) async {
         processing.value = true;
         try {
-          final Iterable<Asset> assets = remoteOnlySelection(
+          final Iterable<Asset> assets = ownedRemoteSelection(
             localErrorMessage: "home_page_add_to_album_err_local".tr(),
+            ownerErrorMessage: "home_page_album_err_partner".tr(),
           );
           if (assets.isEmpty) {
             return;
@@ -228,8 +266,9 @@ class HomePage extends HookConsumerWidget {
       void onCreateNewAlbum() async {
         processing.value = true;
         try {
-          final Iterable<Asset> assets = remoteOnlySelection(
+          final Iterable<Asset> assets = ownedRemoteSelection(
             localErrorMessage: "home_page_add_to_album_err_local".tr(),
+            ownerErrorMessage: "home_page_album_err_partner".tr(),
           );
           if (assets.isEmpty) {
             return;
@@ -270,6 +309,9 @@ class HomePage extends HookConsumerWidget {
       Future<void> refreshAssets() async {
         final fullRefresh = refreshCount.value > 0;
         await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
+        if (timelineUsers.length > 1) {
+          await ref.read(assetProvider.notifier).getPartnerAssets();
+        }
         if (fullRefresh) {
           // refresh was forced: user requested another refresh within 2 seconds
           refreshCount.value = 0;
@@ -330,7 +372,13 @@ class HomePage extends HookConsumerWidget {
         bottom: false,
         child: Stack(
           children: [
-            ref.watch(assetsProvider(currentUser?.isarId)).when(
+            ref
+                .watch(
+                  timelineUsers.length > 1
+                      ? multiUserAssetsProvider(timelineUsers)
+                      : assetsProvider(currentUser?.isarId),
+                )
+                .when(
                   data: (data) => data.isEmpty
                       ? buildLoadingIndicator()
                       : ImmichAssetGrid(
@@ -338,11 +386,10 @@ class HomePage extends HookConsumerWidget {
                           listener: selectionListener,
                           selectionActive: selectionEnabledHook.value,
                           onRefresh: refreshAssets,
-                          topWidget: (currentUser != null &&
-                                  currentUser.memoryEnabled != null &&
-                                  currentUser.memoryEnabled!)
-                              ? const MemoryLane()
-                              : const SizedBox(),
+                          topWidget:
+                              (currentUser != null && currentUser.memoryEnabled)
+                                  ? const MemoryLane()
+                                  : const SizedBox(),
                           showStack: true,
                         ),
                   error: (error, _) => Center(child: Text(error.toString())),

+ 12 - 2
mobile/lib/modules/partner/providers/partner.provider.dart

@@ -2,21 +2,31 @@ import 'dart:async';
 
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/providers/suggested_shared_users.provider.dart';
+import 'package:immich_mobile/modules/partner/services/partner.service.dart';
 import 'package:immich_mobile/shared/models/user.dart';
 import 'package:immich_mobile/shared/providers/db.provider.dart';
 import 'package:isar/isar.dart';
 
 class PartnerSharedWithNotifier extends StateNotifier<List<User>> {
-  PartnerSharedWithNotifier(Isar db) : super([]) {
+  PartnerSharedWithNotifier(Isar db, this._ps) : super([]) {
     final query = db.users.filter().isPartnerSharedWithEqualTo(true);
     query.findAll().then((partners) => state = partners);
     query.watch().listen((partners) => state = partners);
   }
+
+  Future<bool> updatePartner(User partner, {required bool inTimeline}) {
+    return _ps.updatePartner(partner, inTimeline: inTimeline);
+  }
+
+  final PartnerService _ps;
 }
 
 final partnerSharedWithProvider =
     StateNotifierProvider<PartnerSharedWithNotifier, List<User>>((ref) {
-  return PartnerSharedWithNotifier(ref.watch(dbProvider));
+  return PartnerSharedWithNotifier(
+    ref.watch(dbProvider),
+    ref.watch(partnerServiceProvider),
+  );
 });
 
 class PartnerSharedByNotifier extends StateNotifier<List<User>> {

+ 16 - 0
mobile/lib/modules/partner/services/partner.service.dart

@@ -5,6 +5,7 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
 import 'package:isar/isar.dart';
 import 'package:logging/logging.dart';
+import 'package:openapi/api.dart';
 
 final partnerServiceProvider = Provider(
   (ref) => PartnerService(
@@ -69,4 +70,19 @@ class PartnerService {
     }
     return false;
   }
+
+  Future<bool> updatePartner(User partner, {required bool inTimeline}) async {
+    try {
+      final dto = await _apiService.partnerApi
+          .updatePartner(partner.id, UpdatePartnerDto(inTimeline: inTimeline));
+      if (dto != null) {
+        partner.inTimeline = dto.inTimeline ?? partner.inTimeline;
+        await _db.writeTxn(() => _db.users.put(partner));
+        return true;
+      }
+    } catch (e) {
+      _log.warning("failed to update partner ${partner.id}:\n$e");
+    }
+    return false;
+  }
 }

+ 42 - 0
mobile/lib/modules/partner/views/partner_detail_page.dart

@@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
+import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
 import 'package:immich_mobile/shared/models/user.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+import 'package:immich_mobile/shared/ui/immich_toast.dart';
 
 class PartnerDetailPage extends HookConsumerWidget {
   const PartnerDetailPage({Key? key, required this.partner}) : super(key: key);
@@ -14,6 +16,8 @@ class PartnerDetailPage extends HookConsumerWidget {
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     final assets = ref.watch(assetsProvider(partner.isarId));
+    final inTimeline = useState(partner.inTimeline);
+    bool toggleInProcess = false;
 
     useEffect(
       () {
@@ -23,11 +27,49 @@ class PartnerDetailPage extends HookConsumerWidget {
       [],
     );
 
+    void toggleInTimeline() async {
+      if (toggleInProcess) return;
+      toggleInProcess = true;
+      try {
+        final ok = await ref
+            .read(partnerSharedWithProvider.notifier)
+            .updatePartner(partner, inTimeline: !inTimeline.value);
+        if (ok) {
+          inTimeline.value = !inTimeline.value;
+          final action = inTimeline.value ? "shown on" : "hidden from";
+          ImmichToast.show(
+            context: context,
+            toastType: ToastType.success,
+            durationInSecond: 1,
+            msg: "${partner.name}'s assets $action your timeline",
+          );
+        } else {
+          ImmichToast.show(
+            context: context,
+            toastType: ToastType.error,
+            durationInSecond: 1,
+            msg: "Failed to toggle the timeline setting",
+          );
+        }
+      } finally {
+        toggleInProcess = false;
+      }
+    }
+
     return Scaffold(
       appBar: AppBar(
         title: Text(partner.name),
         elevation: 0,
         centerTitle: false,
+        actions: [
+          IconButton(
+            onPressed: toggleInTimeline,
+            icon: Icon(
+              inTimeline.value ? Icons.collections : Icons.collections_outlined,
+            ),
+            tooltip: "Show/hide photos on your main timeline",
+          ),
+        ],
       ),
       body: assets.when(
         data: (renderList) => renderList.isEmpty

+ 4 - 7
mobile/lib/modules/trash/providers/trashed_asset.provider.dart

@@ -5,6 +5,7 @@ import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/db.provider.dart';
 import 'package:immich_mobile/shared/providers/user.provider.dart';
 import 'package:immich_mobile/shared/services/sync.service.dart';
+import 'package:immich_mobile/utils/renderlist_generator.dart';
 import 'package:isar/isar.dart';
 import 'package:logging/logging.dart';
 
@@ -107,9 +108,9 @@ final trashProvider = StateNotifierProvider<TrashNotifier, bool>((ref) {
   );
 });
 
-final trashedAssetsProvider = StreamProvider<RenderList>((ref) async* {
+final trashedAssetsProvider = StreamProvider<RenderList>((ref) {
   final user = ref.read(currentUserProvider);
-  if (user == null) return;
+  if (user == null) return const Stream.empty();
   final query = ref
       .watch(dbProvider)
       .assets
@@ -117,9 +118,5 @@ final trashedAssetsProvider = StreamProvider<RenderList>((ref) async* {
       .ownerIdEqualTo(user.isarId)
       .isTrashedEqualTo(true)
       .sortByFileCreatedAt();
-  const groupBy = GroupAssetsBy.none;
-  yield await RenderList.fromQuery(query, groupBy);
-  await for (final _ in query.watchLazy()) {
-    yield await RenderList.fromQuery(query, groupBy);
-  }
+  return renderListGeneratorWithGroupBy(query, GroupAssetsBy.none);
 });

+ 6 - 5
mobile/lib/shared/models/user.dart

@@ -31,7 +31,8 @@ class User {
         isPartnerSharedWith = false,
         profileImagePath = dto.profileImagePath,
         isAdmin = dto.isAdmin,
-        memoryEnabled = dto.memoriesEnabled;
+        memoryEnabled = dto.memoriesEnabled ?? false,
+        inTimeline = false;
 
   User.fromPartnerDto(PartnerResponseDto dto)
       : id = dto.id,
@@ -42,8 +43,8 @@ class User {
         isPartnerSharedWith = false,
         profileImagePath = dto.profileImagePath,
         isAdmin = dto.isAdmin,
-        memoryEnabled = dto.memoriesEnabled,
-        inTimeline = dto.inTimeline;
+        memoryEnabled = dto.memoriesEnabled ?? false,
+        inTimeline = dto.inTimeline ?? false;
 
   @Index(unique: true, replace: false, type: IndexType.hash)
   String id;
@@ -54,8 +55,8 @@ class User {
   bool isPartnerSharedWith;
   bool isAdmin;
   String profileImagePath;
-  bool? memoryEnabled;
-  bool? inTimeline;
+  bool memoryEnabled;
+  bool inTimeline;
 
   @Backlink(to: 'owner')
   final IsarLinks<Album> albums = IsarLinks<Album>();

+ 8 - 40
mobile/lib/shared/models/user.g.dart

@@ -151,11 +151,11 @@ User _userDeserialize(
   final object = User(
     email: reader.readString(offsets[0]),
     id: reader.readString(offsets[1]),
-    inTimeline: reader.readBoolOrNull(offsets[2]),
+    inTimeline: reader.readBoolOrNull(offsets[2]) ?? false,
     isAdmin: reader.readBool(offsets[3]),
     isPartnerSharedBy: reader.readBoolOrNull(offsets[4]) ?? false,
     isPartnerSharedWith: reader.readBoolOrNull(offsets[5]) ?? false,
-    memoryEnabled: reader.readBoolOrNull(offsets[6]),
+    memoryEnabled: reader.readBoolOrNull(offsets[6]) ?? true,
     name: reader.readString(offsets[7]),
     profileImagePath: reader.readStringOrNull(offsets[8]) ?? '',
     updatedAt: reader.readDateTime(offsets[9]),
@@ -175,7 +175,7 @@ P _userDeserializeProp<P>(
     case 1:
       return (reader.readString(offset)) as P;
     case 2:
-      return (reader.readBoolOrNull(offset)) as P;
+      return (reader.readBoolOrNull(offset) ?? false) as P;
     case 3:
       return (reader.readBool(offset)) as P;
     case 4:
@@ -183,7 +183,7 @@ P _userDeserializeProp<P>(
     case 5:
       return (reader.readBoolOrNull(offset) ?? false) as P;
     case 6:
-      return (reader.readBoolOrNull(offset)) as P;
+      return (reader.readBoolOrNull(offset) ?? true) as P;
     case 7:
       return (reader.readString(offset)) as P;
     case 8:
@@ -638,24 +638,8 @@ extension UserQueryFilter on QueryBuilder<User, User, QFilterCondition> {
     });
   }
 
-  QueryBuilder<User, User, QAfterFilterCondition> inTimelineIsNull() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(const FilterCondition.isNull(
-        property: r'inTimeline',
-      ));
-    });
-  }
-
-  QueryBuilder<User, User, QAfterFilterCondition> inTimelineIsNotNull() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(const FilterCondition.isNotNull(
-        property: r'inTimeline',
-      ));
-    });
-  }
-
   QueryBuilder<User, User, QAfterFilterCondition> inTimelineEqualTo(
-      bool? value) {
+      bool value) {
     return QueryBuilder.apply(this, (query) {
       return query.addFilterCondition(FilterCondition.equalTo(
         property: r'inTimeline',
@@ -745,24 +729,8 @@ extension UserQueryFilter on QueryBuilder<User, User, QFilterCondition> {
     });
   }
 
-  QueryBuilder<User, User, QAfterFilterCondition> memoryEnabledIsNull() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(const FilterCondition.isNull(
-        property: r'memoryEnabled',
-      ));
-    });
-  }
-
-  QueryBuilder<User, User, QAfterFilterCondition> memoryEnabledIsNotNull() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(const FilterCondition.isNotNull(
-        property: r'memoryEnabled',
-      ));
-    });
-  }
-
   QueryBuilder<User, User, QAfterFilterCondition> memoryEnabledEqualTo(
-      bool? value) {
+      bool value) {
     return QueryBuilder.apply(this, (query) {
       return query.addFilterCondition(FilterCondition.equalTo(
         property: r'memoryEnabled',
@@ -1540,7 +1508,7 @@ extension UserQueryProperty on QueryBuilder<User, User, QQueryProperty> {
     });
   }
 
-  QueryBuilder<User, bool?, QQueryOperations> inTimelineProperty() {
+  QueryBuilder<User, bool, QQueryOperations> inTimelineProperty() {
     return QueryBuilder.apply(this, (query) {
       return query.addPropertyName(r'inTimeline');
     });
@@ -1564,7 +1532,7 @@ extension UserQueryProperty on QueryBuilder<User, User, QQueryProperty> {
     });
   }
 
-  QueryBuilder<User, bool?, QQueryOperations> memoryEnabledProperty() {
+  QueryBuilder<User, bool, QQueryOperations> memoryEnabledProperty() {
     return QueryBuilder.apply(this, (query) {
       return query.addPropertyName(r'memoryEnabled');
     });

+ 1 - 0
mobile/lib/shared/providers/app_state.provider.dart

@@ -54,6 +54,7 @@ class AppStateNotiifer extends StateNotifier<AppStateEnum> {
       switch (ref.read(tabProvider)) {
         case TabEnum.home:
           ref.read(assetProvider.notifier).getAllAsset();
+          ref.read(assetProvider.notifier).getPartnerAssets();
         case TabEnum.search:
         // nothing to do
         case TabEnum.sharing:

+ 32 - 22
mobile/lib/shared/providers/asset.provider.dart

@@ -8,12 +8,11 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
 import 'package:immich_mobile/shared/providers/user.provider.dart';
 import 'package:immich_mobile/shared/services/asset.service.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
-import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
-import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/services/sync.service.dart';
 import 'package:immich_mobile/shared/services/user.service.dart';
 import 'package:immich_mobile/utils/db.dart';
+import 'package:immich_mobile/utils/renderlist_generator.dart';
 import 'package:isar/isar.dart';
 import 'package:logging/logging.dart';
 import 'package:photo_manager/photo_manager.dart';
@@ -251,26 +250,23 @@ final assetWatcher =
   return db.assets.watchObject(asset.id, fireImmediately: true);
 });
 
-final assetsProvider =
-    StreamProvider.family<RenderList, int?>((ref, userId) async* {
-  if (userId == null) return;
-  final query = ref
-      .watch(dbProvider)
-      .assets
-      .where()
-      .ownerIdEqualToAnyChecksum(userId)
-      .filter()
-      .isArchivedEqualTo(false)
-      .isTrashedEqualTo(false)
-      .stackParentIdIsNull()
-      .sortByFileCreatedAtDesc();
-  final settings = ref.watch(appSettingsServiceProvider);
-  final groupBy =
-      GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
-  yield await RenderList.fromQuery(query, groupBy);
-  await for (final _ in query.watchLazy()) {
-    yield await RenderList.fromQuery(query, groupBy);
-  }
+final assetsProvider = StreamProvider.family<RenderList, int?>((ref, userId) {
+  if (userId == null) return const Stream.empty();
+  final query = _commonFilterAndSort(
+    _assets(ref).where().ownerIdEqualToAnyChecksum(userId),
+  );
+  return renderListGenerator(query, ref);
+});
+
+final multiUserAssetsProvider =
+    StreamProvider.family<RenderList, List<int>>((ref, userIds) {
+  if (userIds.isEmpty) return const Stream.empty();
+  final query = _commonFilterAndSort(
+    _assets(ref)
+        .where()
+        .anyOf(userIds, (q, u) => q.ownerIdEqualToAnyChecksum(u)),
+  );
+  return renderListGenerator(query, ref);
 });
 
 QueryBuilder<Asset, Asset, QAfterSortBy>? getRemoteAssetQuery(WidgetRef ref) {
@@ -289,3 +285,17 @@ QueryBuilder<Asset, Asset, QAfterSortBy>? getRemoteAssetQuery(WidgetRef ref) {
       .stackParentIdIsNull()
       .sortByFileCreatedAtDesc();
 }
+
+IsarCollection<Asset> _assets(StreamProviderRef<RenderList> ref) =>
+    ref.watch(dbProvider).assets;
+
+QueryBuilder<Asset, Asset, QAfterSortBy> _commonFilterAndSort(
+  QueryBuilder<Asset, Asset, QAfterWhereClause> query,
+) {
+  return query
+      .filter()
+      .isArchivedEqualTo(false)
+      .isTrashedEqualTo(false)
+      .stackParentIdIsNull()
+      .sortByFileCreatedAtDesc();
+}

+ 31 - 0
mobile/lib/shared/providers/user.provider.dart

@@ -3,6 +3,8 @@ import 'dart:async';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/shared/models/user.dart';
+import 'package:immich_mobile/shared/providers/db.provider.dart';
+import 'package:isar/isar.dart';
 
 class CurrentUserProvider extends StateNotifier<User?> {
   CurrentUserProvider() : super(null) {
@@ -24,3 +26,32 @@ final currentUserProvider =
     StateNotifierProvider<CurrentUserProvider, User?>((ref) {
   return CurrentUserProvider();
 });
+
+class TimelineUserIdsProvider extends StateNotifier<List<int>> {
+  TimelineUserIdsProvider(Isar db, User? currentUser) : super([]) {
+    final query = db.users
+        .filter()
+        .inTimelineEqualTo(true)
+        .or()
+        .isarIdEqualTo(currentUser?.isarId ?? Isar.autoIncrement)
+        .isarIdProperty();
+    query.findAll().then((users) => state = users);
+    streamSub = query.watch().listen((users) => state = users);
+  }
+
+  late final StreamSubscription<List<int>> streamSub;
+
+  @override
+  void dispose() {
+    streamSub.cancel();
+    super.dispose();
+  }
+}
+
+final timelineUsersIdsProvider =
+    StateNotifierProvider<TimelineUserIdsProvider, List<int>>((ref) {
+  return TimelineUserIdsProvider(
+    ref.watch(dbProvider),
+    ref.watch(currentUserProvider),
+  );
+});

+ 5 - 1
mobile/lib/shared/services/user.service.dart

@@ -99,7 +99,11 @@ class UserService {
       users,
       sharedWith,
       compare: (User a, User b) => a.id.compareTo(b.id),
-      both: (User a, User b) => a.isPartnerSharedWith = true,
+      both: (User a, User b) {
+        a.isPartnerSharedWith = true;
+        a.inTimeline = b.inTimeline;
+        return true;
+      },
       onlyFirst: (_) {},
       onlySecond: (_) {},
     );

+ 2 - 0
mobile/lib/utils/migration.dart

@@ -11,6 +11,8 @@ Future<void> migrateDatabaseIfNeeded(Isar db) async {
       await _migrateTo(db, 2);
     case 2:
       await _migrateTo(db, 3);
+    case 3:
+      await _migrateTo(db, 4);
   }
 }
 

+ 26 - 0
mobile/lib/utils/renderlist_generator.dart

@@ -0,0 +1,26 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
+import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
+import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:isar/isar.dart';
+
+Stream<RenderList> renderListGenerator(
+  QueryBuilder<Asset, Asset, QAfterSortBy> query,
+  StreamProviderRef<RenderList> ref,
+) {
+  final settings = ref.watch(appSettingsServiceProvider);
+  final groupBy =
+      GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
+  return renderListGeneratorWithGroupBy(query, groupBy);
+}
+
+Stream<RenderList> renderListGeneratorWithGroupBy(
+  QueryBuilder<Asset, Asset, QAfterSortBy> query,
+  GroupAssetsBy groupBy,
+) async* {
+  yield await RenderList.fromQuery(query, groupBy);
+  await for (final _ in query.watchLazy()) {
+    yield await RenderList.fromQuery(query, groupBy);
+  }
+}