Browse Source

feat(mobile): shared album activities (#4833)

* fix(server): global activity like duplicate search

* mobile: user_circle_avatar - fallback to text icon if no profile pic available

* mobile: use favourite icon in search "your activity"

* feat(mobile): shared album activities

* mobile: align hearts with user profile icon

* styling

* replace bottom sheet with dismissible

* add auto focus to the input

---------

Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
shenlong 1 năm trước cách đây
mục cha
commit
26fd9d7e5f

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

@@ -373,5 +373,8 @@
   "viewer_stack_use_as_main_asset": "Use as Main Asset",
   "app_bar_signout_dialog_title": "Sign out",
   "app_bar_signout_dialog_content": "Are you sure you wanna sign out?",
-  "app_bar_signout_dialog_ok": "Yes"
+  "app_bar_signout_dialog_ok": "Yes",
+  "shared_album_activities_input_hint": "Say something",
+  "shared_album_activity_remove_title": "Delete Activity",
+  "shared_album_activity_remove_content": "Do you want to delete this activity?"
 }

+ 90 - 0
mobile/lib/modules/activities/models/activity.model.dart

@@ -0,0 +1,90 @@
+import 'package:immich_mobile/shared/models/user.dart';
+import 'package:openapi/api.dart';
+
+enum ActivityType { comment, like }
+
+class Activity {
+  final String id;
+  final String? assetId;
+  final String? comment;
+  final DateTime createdAt;
+  final ActivityType type;
+  final User user;
+
+  const Activity({
+    required this.id,
+    this.assetId,
+    this.comment,
+    required this.createdAt,
+    required this.type,
+    required this.user,
+  });
+
+  Activity copyWith({
+    String? id,
+    String? assetId,
+    String? comment,
+    DateTime? createdAt,
+    ActivityType? type,
+    User? user,
+  }) {
+    return Activity(
+      id: id ?? this.id,
+      assetId: assetId ?? this.assetId,
+      comment: comment ?? this.comment,
+      createdAt: createdAt ?? this.createdAt,
+      type: type ?? this.type,
+      user: user ?? this.user,
+    );
+  }
+
+  Activity.fromDto(ActivityResponseDto dto)
+      : id = dto.id,
+        assetId = dto.assetId,
+        comment = dto.comment,
+        createdAt = dto.createdAt,
+        type = dto.type == ActivityResponseDtoTypeEnum.comment
+            ? ActivityType.comment
+            : ActivityType.like,
+        user = User(
+          email: dto.user.email,
+          firstName: dto.user.firstName,
+          lastName: dto.user.lastName,
+          profileImagePath: dto.user.profileImagePath,
+          id: dto.user.id,
+          // Placeholder values
+          isAdmin: false,
+          updatedAt: DateTime.now(),
+          isPartnerSharedBy: false,
+          isPartnerSharedWith: false,
+          memoryEnabled: false,
+        );
+
+  @override
+  String toString() {
+    return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)';
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is Activity &&
+        other.id == id &&
+        other.assetId == assetId &&
+        other.comment == comment &&
+        other.createdAt == createdAt &&
+        other.type == type &&
+        other.user == user;
+  }
+
+  @override
+  int get hashCode {
+    return id.hashCode ^
+        assetId.hashCode ^
+        comment.hashCode ^
+        createdAt.hashCode ^
+        type.hashCode ^
+        user.hashCode;
+  }
+}

+ 130 - 0
mobile/lib/modules/activities/providers/activity.provider.dart

@@ -0,0 +1,130 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/activities/models/activity.model.dart';
+import 'package:immich_mobile/modules/activities/services/activity.service.dart';
+
+class ActivityNotifier extends StateNotifier<AsyncValue<List<Activity>>> {
+  final Ref _ref;
+  final ActivityService _activityService;
+  final String albumId;
+  final String? assetId;
+
+  ActivityNotifier(
+    this._ref,
+    this._activityService,
+    this.albumId,
+    this.assetId,
+  ) : super(
+          const AsyncData([]),
+        ) {
+    fetchActivity();
+  }
+
+  Future<void> fetchActivity() async {
+    state = const AsyncLoading();
+    state = await AsyncValue.guard(
+      () => _activityService.getAllActivities(albumId, assetId),
+    );
+  }
+
+  Future<void> removeActivity(String id) async {
+    final activities = state.asData?.value ?? [];
+    if (await _activityService.removeActivity(id)) {
+      final removedActivity = activities.firstWhere((a) => a.id == id);
+      activities.remove(removedActivity);
+      state = AsyncData(activities);
+      if (removedActivity.type == ActivityType.comment) {
+        _ref
+            .read(
+              activityStatisticsStateProvider(
+                (albumId: albumId, assetId: assetId),
+              ).notifier,
+            )
+            .removeActivity();
+      }
+    }
+  }
+
+  Future<void> addComment(String comment) async {
+    final activity = await _activityService.addActivity(
+      albumId,
+      ActivityType.comment,
+      assetId: assetId,
+      comment: comment,
+    );
+
+    if (activity != null) {
+      final activities = state.asData?.value ?? [];
+      state = AsyncData([...activities, activity]);
+      _ref
+          .read(
+            activityStatisticsStateProvider(
+              (albumId: albumId, assetId: assetId),
+            ).notifier,
+          )
+          .addActivity();
+      if (assetId != null) {
+        // Add a count to the current album's provider as well
+        _ref
+            .read(
+              activityStatisticsStateProvider(
+                (albumId: albumId, assetId: null),
+              ).notifier,
+            )
+            .addActivity();
+      }
+    }
+  }
+
+  Future<void> addLike() async {
+    final activity = await _activityService
+        .addActivity(albumId, ActivityType.like, assetId: assetId);
+    if (activity != null) {
+      final activities = state.asData?.value ?? [];
+      state = AsyncData([...activities, activity]);
+    }
+  }
+}
+
+class ActivityStatisticsNotifier extends StateNotifier<int> {
+  final String albumId;
+  final String? assetId;
+  final ActivityService _activityService;
+  ActivityStatisticsNotifier(this._activityService, this.albumId, this.assetId)
+      : super(0) {
+    fetchStatistics();
+  }
+
+  Future<void> fetchStatistics() async {
+    state = await _activityService.getStatistics(albumId, assetId: assetId);
+  }
+
+  Future<void> addActivity() async {
+    state = state + 1;
+  }
+
+  Future<void> removeActivity() async {
+    state = state - 1;
+  }
+}
+
+typedef ActivityParams = ({String albumId, String? assetId});
+
+final activityStateProvider = StateNotifierProvider.autoDispose
+    .family<ActivityNotifier, AsyncValue<List<Activity>>, ActivityParams>(
+        (ref, args) {
+  return ActivityNotifier(
+    ref,
+    ref.watch(activityServiceProvider),
+    args.albumId,
+    args.assetId,
+  );
+});
+
+final activityStatisticsStateProvider = StateNotifierProvider.autoDispose
+    .family<ActivityStatisticsNotifier, int, ActivityParams>((ref, args) {
+  return ActivityStatisticsNotifier(
+    ref.watch(activityServiceProvider),
+    args.albumId,
+    args.assetId,
+  );
+});

+ 85 - 0
mobile/lib/modules/activities/services/activity.service.dart

@@ -0,0 +1,85 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/activities/models/activity.model.dart';
+import 'package:immich_mobile/shared/providers/api.provider.dart';
+import 'package:immich_mobile/shared/services/api.service.dart';
+import 'package:logging/logging.dart';
+import 'package:openapi/api.dart';
+
+final activityServiceProvider =
+    Provider((ref) => ActivityService(ref.watch(apiServiceProvider)));
+
+class ActivityService {
+  final ApiService _apiService;
+  final Logger _log = Logger("ActivityService");
+
+  ActivityService(this._apiService);
+
+  Future<List<Activity>> getAllActivities(
+    String albumId,
+    String? assetId,
+  ) async {
+    try {
+      final list = await _apiService.activityApi
+          .getActivities(albumId, assetId: assetId);
+      return list != null ? list.map(Activity.fromDto).toList() : [];
+    } catch (e) {
+      _log.severe(
+        "failed to fetch activities for albumId - $albumId; assetId - $assetId -> $e",
+      );
+      rethrow;
+    }
+  }
+
+  Future<int> getStatistics(String albumId, {String? assetId}) async {
+    try {
+      final dto = await _apiService.activityApi
+          .getActivityStatistics(albumId, assetId: assetId);
+      return dto?.comments ?? 0;
+    } catch (e) {
+      _log.severe(
+        "failed to fetch activity statistics for albumId - $albumId; assetId - $assetId -> $e",
+      );
+    }
+    return 0;
+  }
+
+  Future<bool> removeActivity(String id) async {
+    try {
+      await _apiService.activityApi.deleteActivity(id);
+      return true;
+    } catch (e) {
+      _log.severe(
+        "failed to remove activity id - $id -> $e",
+      );
+    }
+    return false;
+  }
+
+  Future<Activity?> addActivity(
+    String albumId,
+    ActivityType type, {
+    String? assetId,
+    String? comment,
+  }) async {
+    try {
+      final dto = await _apiService.activityApi.createActivity(
+        ActivityCreateDto(
+          albumId: albumId,
+          type: type == ActivityType.comment
+              ? ReactionType.comment
+              : ReactionType.like,
+          assetId: assetId,
+          comment: comment,
+        ),
+      );
+      if (dto != null) {
+        return Activity.fromDto(dto);
+      }
+    } catch (e) {
+      _log.severe(
+        "failed to add activity for albumId - $albumId; assetId - $assetId -> $e",
+      );
+    }
+    return null;
+  }
+}

+ 312 - 0
mobile/lib/modules/activities/views/activities_page.dart

@@ -0,0 +1,312 @@
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:collection/collection.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/activities/models/activity.model.dart';
+import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
+import 'package:immich_mobile/shared/models/store.dart';
+import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
+import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
+import 'package:immich_mobile/utils/datetime_extensions.dart';
+import 'package:immich_mobile/utils/image_url_builder.dart';
+
+class ActivitiesPage extends HookConsumerWidget {
+  final String albumId;
+  final String? assetId;
+  final bool withAssetThumbs;
+  final String appBarTitle;
+  final bool isOwner;
+  const ActivitiesPage(
+    this.albumId, {
+    this.appBarTitle = "",
+    this.assetId,
+    this.withAssetThumbs = true,
+    this.isOwner = false,
+    super.key,
+  });
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final provider =
+        activityStateProvider((albumId: albumId, assetId: assetId));
+    final activities = ref.watch(provider);
+    final inputController = useTextEditingController();
+    final inputFocusNode = useFocusNode();
+    final listViewScrollController = useScrollController();
+    final currentUser = Store.tryGet(StoreKey.currentUser);
+
+    useEffect(
+      () {
+        inputFocusNode.requestFocus();
+        return null;
+      },
+      [],
+    );
+    buildTitleWithTimestamp(Activity activity, {bool leftAlign = true}) {
+      final textColor = Theme.of(context).brightness == Brightness.dark
+          ? Colors.white
+          : Colors.black;
+      final textStyle = Theme.of(context)
+          .textTheme
+          .bodyMedium
+          ?.copyWith(color: textColor.withOpacity(0.6));
+
+      return Row(
+        mainAxisAlignment: leftAlign
+            ? MainAxisAlignment.start
+            : MainAxisAlignment.spaceBetween,
+        mainAxisSize: leftAlign ? MainAxisSize.min : MainAxisSize.max,
+        children: [
+          Text(
+            "${activity.user.firstName} ${activity.user.lastName}",
+            style: textStyle,
+            overflow: TextOverflow.ellipsis,
+          ),
+          if (leftAlign)
+            Text(
+              " • ",
+              style: textStyle,
+            ),
+          Expanded(
+            child: Text(
+              activity.createdAt.copyWith().timeAgo(),
+              style: textStyle,
+              overflow: TextOverflow.ellipsis,
+              textAlign: leftAlign ? TextAlign.left : TextAlign.right,
+            ),
+          ),
+        ],
+      );
+    }
+
+    buildAssetThumbnail(Activity activity) {
+      return withAssetThumbs && activity.assetId != null
+          ? Container(
+              width: 40,
+              height: 30,
+              decoration: BoxDecoration(
+                borderRadius: BorderRadius.circular(4),
+                image: DecorationImage(
+                  image: CachedNetworkImageProvider(
+                    getThumbnailUrlForRemoteId(
+                      activity.assetId!,
+                    ),
+                    cacheKey: getThumbnailCacheKeyForRemoteId(
+                      activity.assetId!,
+                    ),
+                    headers: {
+                      "Authorization":
+                          'Bearer ${Store.get(StoreKey.accessToken)}',
+                    },
+                  ),
+                  fit: BoxFit.cover,
+                ),
+              ),
+              child: const SizedBox.shrink(),
+            )
+          : null;
+    }
+
+    buildTextField(String? likedId) {
+      final liked = likedId != null;
+      return Padding(
+        padding: const EdgeInsets.only(bottom: 10),
+        child: TextField(
+          controller: inputController,
+          focusNode: inputFocusNode,
+          textInputAction: TextInputAction.send,
+          autofocus: false,
+          decoration: InputDecoration(
+            border: InputBorder.none,
+            focusedBorder: InputBorder.none,
+            prefixIcon: currentUser != null
+                ? Padding(
+                    padding: const EdgeInsets.symmetric(horizontal: 15),
+                    child: UserCircleAvatar(
+                      user: currentUser,
+                      size: 30,
+                      radius: 15,
+                    ),
+                  )
+                : null,
+            suffixIcon: Padding(
+              padding: const EdgeInsets.only(right: 10),
+              child: IconButton(
+                icon: Icon(
+                  liked
+                      ? Icons.favorite_rounded
+                      : Icons.favorite_border_rounded,
+                ),
+                onPressed: () async {
+                  liked
+                      ? await ref
+                          .read(provider.notifier)
+                          .removeActivity(likedId)
+                      : await ref.read(provider.notifier).addLike();
+                },
+              ),
+            ),
+            suffixIconColor: liked ? Colors.red[700] : null,
+            hintText: 'shared_album_activities_input_hint'.tr(),
+            hintStyle: TextStyle(
+              fontWeight: FontWeight.normal,
+              fontSize: 14,
+              color: Colors.grey[600],
+            ),
+          ),
+          onEditingComplete: () async {
+            await ref.read(provider.notifier).addComment(inputController.text);
+            inputController.clear();
+            inputFocusNode.unfocus();
+            listViewScrollController.animateTo(
+              listViewScrollController.position.maxScrollExtent,
+              duration: const Duration(milliseconds: 800),
+              curve: Curves.fastOutSlowIn,
+            );
+          },
+          onTapOutside: (_) => inputFocusNode.unfocus(),
+        ),
+      );
+    }
+
+    getDismissibleWidget(
+      Widget widget,
+      Activity activity,
+      bool canDelete,
+    ) {
+      return Dismissible(
+        key: Key(activity.id),
+        dismissThresholds: const {
+          DismissDirection.horizontal: 0.7,
+        },
+        direction: DismissDirection.horizontal,
+        confirmDismiss: (direction) => canDelete
+            ? showDialog(
+                context: context,
+                builder: (context) => ConfirmDialog(
+                  onOk: () {},
+                  title: "shared_album_activity_remove_title",
+                  content: "shared_album_activity_remove_content",
+                  ok: "delete_dialog_ok",
+                ),
+              )
+            : Future.value(false),
+        onDismissed: (direction) async =>
+            await ref.read(provider.notifier).removeActivity(activity.id),
+        background: Container(
+          color: canDelete ? Colors.red[400] : Colors.grey[600],
+          alignment: AlignmentDirectional.centerStart,
+          child: canDelete
+              ? const Padding(
+                  padding: EdgeInsets.all(15),
+                  child: Icon(
+                    Icons.delete_sweep_rounded,
+                    color: Colors.black,
+                  ),
+                )
+              : null,
+        ),
+        secondaryBackground: Container(
+          color: canDelete ? Colors.red[400] : Colors.grey[600],
+          alignment: AlignmentDirectional.centerEnd,
+          child: canDelete
+              ? const Padding(
+                  padding: EdgeInsets.all(15),
+                  child: Icon(
+                    Icons.delete_sweep_rounded,
+                    color: Colors.black,
+                  ),
+                )
+              : null,
+        ),
+        child: widget,
+      );
+    }
+
+    return Scaffold(
+      appBar: AppBar(title: Text(appBarTitle)),
+      body: activities.maybeWhen(
+        orElse: () {
+          return const Center(child: ImmichLoadingIndicator());
+        },
+        data: (data) {
+          final liked = data.firstWhereOrNull(
+            (a) =>
+                a.type == ActivityType.like &&
+                a.user.id == currentUser?.id &&
+                a.assetId == assetId,
+          );
+
+          return Stack(
+            children: [
+              ListView.builder(
+                controller: listViewScrollController,
+                itemCount: data.length + 1,
+                itemBuilder: (context, index) {
+                  // Vertical gap after the last element
+                  if (index == data.length) {
+                    return const SizedBox(
+                      height: 80,
+                    );
+                  }
+
+                  final activity = data[index];
+                  final canDelete =
+                      activity.user.id == currentUser?.id || isOwner;
+
+                  return Padding(
+                    padding: const EdgeInsets.all(5),
+                    child: activity.type == ActivityType.comment
+                        ? getDismissibleWidget(
+                            ListTile(
+                              minVerticalPadding: 15,
+                              leading: UserCircleAvatar(user: activity.user),
+                              title: buildTitleWithTimestamp(
+                                activity,
+                                leftAlign:
+                                    withAssetThumbs && activity.assetId != null,
+                              ),
+                              titleAlignment: ListTileTitleAlignment.top,
+                              trailing: buildAssetThumbnail(activity),
+                              subtitle: Text(activity.comment!),
+                            ),
+                            activity,
+                            canDelete,
+                          )
+                        : getDismissibleWidget(
+                            ListTile(
+                              minVerticalPadding: 15,
+                              leading: Container(
+                                width: 44,
+                                alignment: Alignment.center,
+                                child: Icon(
+                                  Icons.favorite_rounded,
+                                  color: Colors.red[700],
+                                ),
+                              ),
+                              title: buildTitleWithTimestamp(activity),
+                              trailing: buildAssetThumbnail(activity),
+                            ),
+                            activity,
+                            canDelete,
+                          ),
+                  );
+                },
+              ),
+              Align(
+                alignment: Alignment.bottomCenter,
+                child: Container(
+                  color: Theme.of(context).scaffoldBackgroundColor,
+                  child: buildTextField(liked?.id),
+                ),
+              ),
+            ],
+          );
+        },
+      ),
+    );
+  }
+}

+ 38 - 0
mobile/lib/modules/album/ui/album_viewer_appbar.dart

@@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:fluttertoast/fluttertoast.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
 import 'package:immich_mobile/modules/album/providers/album.provider.dart';
 import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
 import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
@@ -26,6 +27,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
     required this.titleFocusNode,
     this.onAddPhotos,
     this.onAddUsers,
+    required this.onActivities,
   }) : super(key: key);
 
   final Album album;
@@ -35,11 +37,19 @@ class AlbumViewerAppbar extends HookConsumerWidget
   final FocusNode titleFocusNode;
   final Function(Album album)? onAddPhotos;
   final Function(Album album)? onAddUsers;
+  final Function(Album album) onActivities;
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
     final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
+    final comments = album.shared
+        ? ref.watch(
+            activityStatisticsStateProvider(
+              (albumId: album.remoteId!, assetId: null),
+            ),
+          )
+        : 0;
 
     deleteAlbum() async {
       ImmichLoadingOverlayController.appLoader.show();
@@ -310,6 +320,33 @@ class AlbumViewerAppbar extends HookConsumerWidget
       );
     }
 
+    Widget buildActivitiesButton() {
+      return IconButton(
+        onPressed: () {
+          onActivities(album);
+        },
+        icon: Row(
+          crossAxisAlignment: CrossAxisAlignment.center,
+          children: [
+            const Icon(
+              Icons.mode_comment_outlined,
+            ),
+            if (comments != 0)
+              Padding(
+                padding: const EdgeInsets.only(left: 5),
+                child: Text(
+                  comments.toString(),
+                  style: TextStyle(
+                    fontWeight: FontWeight.bold,
+                    color: Theme.of(context).primaryColor,
+                  ),
+                ),
+              ),
+          ],
+        ),
+      );
+    }
+
     buildLeadingButton() {
       if (selected.isNotEmpty) {
         return IconButton(
@@ -353,6 +390,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
       title: selected.isNotEmpty ? Text('${selected.length}') : null,
       centerTitle: false,
       actions: [
+        if (album.shared) buildActivitiesButton(),
         if (album.isRemote)
           IconButton(
             splashRadius: 25,

+ 14 - 0
mobile/lib/modules/album/views/album_viewer_page.dart

@@ -232,6 +232,18 @@ class AlbumViewerPage extends HookConsumerWidget {
       );
     }
 
+    onActivitiesPressed(Album album) {
+      if (album.remoteId != null) {
+        AutoRouter.of(context).push(
+          ActivitiesRoute(
+            albumId: album.remoteId!,
+            appBarTitle: album.name,
+            isOwner: userId == album.ownerId,
+          ),
+        );
+      }
+    }
+
     return Scaffold(
       appBar: album.when(
         data: (data) => AlbumViewerAppbar(
@@ -242,6 +254,7 @@ class AlbumViewerPage extends HookConsumerWidget {
           selectionDisabled: disableSelection,
           onAddPhotos: onAddPhotosPressed,
           onAddUsers: onAddUsersPressed,
+          onActivities: onActivitiesPressed,
         ),
         error: (error, stackTrace) => AppBar(title: const Text("Error")),
         loading: () => AppBar(),
@@ -266,6 +279,7 @@ class AlbumViewerPage extends HookConsumerWidget {
                 ],
               ),
               isOwner: userId == data.ownerId,
+              sharedAlbumId: data.remoteId,
             ),
           ),
         ),

+ 41 - 0
mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart

@@ -1,6 +1,7 @@
 import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 
@@ -16,6 +17,8 @@ class TopControlAppBar extends HookConsumerWidget {
     required this.onFavorite,
     required this.onUploadPressed,
     required this.isOwner,
+    required this.shareAlbumId,
+    required this.onActivitiesPressed,
   }) : super(key: key);
 
   final Asset asset;
@@ -24,14 +27,23 @@ class TopControlAppBar extends HookConsumerWidget {
   final VoidCallback? onDownloadPressed;
   final VoidCallback onToggleMotionVideo;
   final VoidCallback onAddToAlbumPressed;
+  final VoidCallback onActivitiesPressed;
   final Function(Asset) onFavorite;
   final bool isPlayingMotionVideo;
   final bool isOwner;
+  final String? shareAlbumId;
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     const double iconSize = 22.0;
     final a = ref.watch(assetWatcher(asset)).value ?? asset;
+    final comments = shareAlbumId != null
+        ? ref.watch(
+            activityStatisticsStateProvider(
+              (albumId: shareAlbumId!, assetId: asset.remoteId),
+            ),
+          )
+        : 0;
 
     Widget buildFavoriteButton(a) {
       return IconButton(
@@ -94,6 +106,34 @@ class TopControlAppBar extends HookConsumerWidget {
       );
     }
 
+    Widget buildActivitiesButton() {
+      return IconButton(
+        onPressed: () {
+          onActivitiesPressed();
+        },
+        icon: Row(
+          crossAxisAlignment: CrossAxisAlignment.center,
+          children: [
+            Icon(
+              Icons.mode_comment_outlined,
+              color: Colors.grey[200],
+            ),
+            if (comments != 0)
+              Padding(
+                padding: const EdgeInsets.only(left: 5),
+                child: Text(
+                  comments.toString(),
+                  style: TextStyle(
+                    fontWeight: FontWeight.bold,
+                    color: Colors.grey[200],
+                  ),
+                ),
+              ),
+          ],
+        ),
+      );
+    }
+
     Widget buildUploadButton() {
       return IconButton(
         onPressed: onUploadPressed,
@@ -130,6 +170,7 @@ class TopControlAppBar extends HookConsumerWidget {
         if (asset.isLocal && !asset.isRemote) buildUploadButton(),
         if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
         if (asset.isRemote && isOwner) buildAddToAlbumButtom(),
+        if (shareAlbumId != null) buildActivitiesButton(),
         buildMoreInfoButton(),
       ],
     );

+ 17 - 0
mobile/lib/modules/asset_viewer/views/gallery_viewer.dart

@@ -49,6 +49,7 @@ class GalleryViewerPage extends HookConsumerWidget {
   final int heroOffset;
   final bool showStack;
   final bool isOwner;
+  final String? sharedAlbumId;
 
   GalleryViewerPage({
     super.key,
@@ -58,6 +59,7 @@ class GalleryViewerPage extends HookConsumerWidget {
     this.heroOffset = 0,
     this.showStack = false,
     this.isOwner = true,
+    this.sharedAlbumId,
   }) : controller = PageController(initialPage: initialIndex);
 
   final PageController controller;
@@ -327,6 +329,19 @@ class GalleryViewerPage extends HookConsumerWidget {
       );
     }
 
+    handleActivities() {
+      if (sharedAlbumId != null) {
+        AutoRouter.of(context).push(
+          ActivitiesRoute(
+            albumId: sharedAlbumId!,
+            assetId: asset().remoteId,
+            withAssetThumbs: false,
+            isOwner: isOwner,
+          ),
+        );
+      }
+    }
+
     buildAppBar() {
       return IgnorePointer(
         ignoring: !ref.watch(showControlsProvider),
@@ -355,6 +370,8 @@ class GalleryViewerPage extends HookConsumerWidget {
                 isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
               }),
               onAddToAlbumPressed: () => addToAlbum(asset()),
+              shareAlbumId: sharedAlbumId,
+              onActivitiesPressed: handleActivities,
             ),
           ),
         ),

+ 3 - 0
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart

@@ -34,6 +34,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
   final bool showDragScroll;
   final bool showStack;
   final bool isOwner;
+  final String? sharedAlbumId;
 
   const ImmichAssetGrid({
     super.key,
@@ -55,6 +56,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
     this.showDragScroll = true,
     this.showStack = false,
     this.isOwner = true,
+    this.sharedAlbumId,
   });
 
   @override
@@ -120,6 +122,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
           showDragScroll: showDragScroll,
           showStack: showStack,
           isOwner: isOwner,
+          sharedAlbumId: sharedAlbumId,
         ),
       );
     }

+ 3 - 0
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart

@@ -39,6 +39,7 @@ class ImmichAssetGridView extends StatefulWidget {
   final bool showDragScroll;
   final bool showStack;
   final bool isOwner;
+  final String? sharedAlbumId;
 
   const ImmichAssetGridView({
     super.key,
@@ -60,6 +61,7 @@ class ImmichAssetGridView extends StatefulWidget {
     this.showDragScroll = true,
     this.showStack = false,
     this.isOwner = true,
+    this.sharedAlbumId,
   });
 
   @override
@@ -141,6 +143,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
       heroOffset: widget.heroOffset,
       showStack: widget.showStack,
       isOwner: widget.isOwner,
+      sharedAlbumId: widget.sharedAlbumId,
     );
   }
 

+ 3 - 0
mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart

@@ -21,6 +21,7 @@ class ThumbnailImage extends StatelessWidget {
   final Function? onSelect;
   final Function? onDeselect;
   final int heroOffset;
+  final String? sharedAlbumId;
 
   const ThumbnailImage({
     Key? key,
@@ -31,6 +32,7 @@ class ThumbnailImage extends StatelessWidget {
     this.showStorageIndicator = true,
     this.showStack = false,
     this.isOwner = true,
+    this.sharedAlbumId,
     this.useGrayBoxPlaceholder = false,
     this.isSelected = false,
     this.multiselectEnabled = false,
@@ -184,6 +186,7 @@ class ThumbnailImage extends StatelessWidget {
               heroOffset: heroOffset,
               showStack: showStack,
               isOwner: isOwner,
+              sharedAlbumId: sharedAlbumId,
             ),
           );
         }

+ 1 - 1
mobile/lib/modules/search/views/search_page.dart

@@ -172,7 +172,7 @@ class SearchPage extends HookConsumerWidget {
                 ),
                 ListTile(
                   leading: Icon(
-                    Icons.star_outline,
+                    Icons.favorite_border_rounded,
                     color: categoryIconColor,
                   ),
                   title:

+ 7 - 0
mobile/lib/routing/router.dart

@@ -1,6 +1,7 @@
 import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/activities/views/activities_page.dart';
 import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
 import 'package:immich_mobile/modules/album/views/album_options_part.dart';
 import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
@@ -160,6 +161,12 @@ part 'router.gr.dart';
     AutoRoute(page: TrashPage, guards: [AuthGuard, DuplicateGuard]),
     AutoRoute(page: SharedLinkPage, guards: [AuthGuard, DuplicateGuard]),
     AutoRoute(page: SharedLinkEditPage, guards: [AuthGuard, DuplicateGuard]),
+    CustomRoute(
+      page: ActivitiesPage,
+      guards: [AuthGuard, DuplicateGuard],
+      transitionsBuilder: TransitionsBuilders.slideLeft,
+      durationInMilliseconds: 200,
+    ),
   ],
 )
 class AppRouter extends _$AppRouter {

+ 87 - 1
mobile/lib/routing/router.gr.dart

@@ -73,6 +73,7 @@ class _$AppRouter extends RootStackRouter {
           heroOffset: args.heroOffset,
           showStack: args.showStack,
           isOwner: args.isOwner,
+          sharedAlbumId: args.sharedAlbumId,
         ),
       );
     },
@@ -337,6 +338,24 @@ class _$AppRouter extends RootStackRouter {
         ),
       );
     },
+    ActivitiesRoute.name: (routeData) {
+      final args = routeData.argsAs<ActivitiesRouteArgs>();
+      return CustomPage<dynamic>(
+        routeData: routeData,
+        child: ActivitiesPage(
+          args.albumId,
+          appBarTitle: args.appBarTitle,
+          assetId: args.assetId,
+          withAssetThumbs: args.withAssetThumbs,
+          isOwner: args.isOwner,
+          key: args.key,
+        ),
+        transitionsBuilder: TransitionsBuilders.slideLeft,
+        durationInMilliseconds: 200,
+        opaque: true,
+        barrierDismissible: false,
+      );
+    },
     HomeRoute.name: (routeData) {
       return MaterialPageX<dynamic>(
         routeData: routeData,
@@ -674,6 +693,14 @@ class _$AppRouter extends RootStackRouter {
             duplicateGuard,
           ],
         ),
+        RouteConfig(
+          ActivitiesRoute.name,
+          path: '/activities-page',
+          guards: [
+            authGuard,
+            duplicateGuard,
+          ],
+        ),
       ];
 }
 
@@ -749,6 +776,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
     int heroOffset = 0,
     bool showStack = false,
     bool isOwner = true,
+    String? sharedAlbumId,
   }) : super(
           GalleryViewerRoute.name,
           path: '/gallery-viewer-page',
@@ -760,6 +788,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
             heroOffset: heroOffset,
             showStack: showStack,
             isOwner: isOwner,
+            sharedAlbumId: sharedAlbumId,
           ),
         );
 
@@ -775,6 +804,7 @@ class GalleryViewerRouteArgs {
     this.heroOffset = 0,
     this.showStack = false,
     this.isOwner = true,
+    this.sharedAlbumId,
   });
 
   final Key? key;
@@ -791,9 +821,11 @@ class GalleryViewerRouteArgs {
 
   final bool isOwner;
 
+  final String? sharedAlbumId;
+
   @override
   String toString() {
-    return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack, isOwner: $isOwner}';
+    return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack, isOwner: $isOwner, sharedAlbumId: $sharedAlbumId}';
   }
 }
 
@@ -1527,6 +1559,60 @@ class SharedLinkEditRouteArgs {
   }
 }
 
+/// generated route for
+/// [ActivitiesPage]
+class ActivitiesRoute extends PageRouteInfo<ActivitiesRouteArgs> {
+  ActivitiesRoute({
+    required String albumId,
+    String appBarTitle = "",
+    String? assetId,
+    bool withAssetThumbs = true,
+    bool isOwner = false,
+    Key? key,
+  }) : super(
+          ActivitiesRoute.name,
+          path: '/activities-page',
+          args: ActivitiesRouteArgs(
+            albumId: albumId,
+            appBarTitle: appBarTitle,
+            assetId: assetId,
+            withAssetThumbs: withAssetThumbs,
+            isOwner: isOwner,
+            key: key,
+          ),
+        );
+
+  static const String name = 'ActivitiesRoute';
+}
+
+class ActivitiesRouteArgs {
+  const ActivitiesRouteArgs({
+    required this.albumId,
+    this.appBarTitle = "",
+    this.assetId,
+    this.withAssetThumbs = true,
+    this.isOwner = false,
+    this.key,
+  });
+
+  final String albumId;
+
+  final String appBarTitle;
+
+  final String? assetId;
+
+  final bool withAssetThumbs;
+
+  final bool isOwner;
+
+  final Key? key;
+
+  @override
+  String toString() {
+    return 'ActivitiesRouteArgs{albumId: $albumId, appBarTitle: $appBarTitle, assetId: $assetId, withAssetThumbs: $withAssetThumbs, isOwner: $isOwner, key: $key}';
+  }
+}
+
 /// generated route for
 /// [HomePage]
 class HomeRoute extends PageRouteInfo<void> {

+ 2 - 0
mobile/lib/shared/services/api.service.dart

@@ -22,6 +22,7 @@ class ApiService {
   late PersonApi personApi;
   late AuditApi auditApi;
   late SharedLinkApi sharedLinkApi;
+  late ActivityApi activityApi;
 
   ApiService() {
     final endpoint = Store.tryGet(StoreKey.serverEndpoint);
@@ -47,6 +48,7 @@ class ApiService {
     personApi = PersonApi(_apiClient);
     auditApi = AuditApi(_apiClient);
     sharedLinkApi = SharedLinkApi(_apiClient);
+    activityApi = ActivityApi(_apiClient);
   }
 
   Future<String> resolveAndSetEndpoint(String serverUrl) async {

+ 12 - 9
mobile/lib/shared/ui/user_circle_avatar.dart

@@ -40,19 +40,23 @@ class UserCircleAvatar extends ConsumerWidget {
 
     final profileImageUrl =
         '${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${user.id}?d=${Random().nextInt(1024)}';
+
+    final textIcon = Text(
+      user.firstName[0].toUpperCase(),
+      style: TextStyle(
+        fontWeight: FontWeight.bold,
+        color: Theme.of(context).brightness == Brightness.dark
+            ? Colors.black
+            : Colors.white,
+      ),
+    );
     return CircleAvatar(
       backgroundColor: useRandomBackgroundColor
           ? randomColors[Random().nextInt(randomColors.length)]
           : Theme.of(context).primaryColor,
       radius: radius,
       child: user.profileImagePath == ""
-          ? Text(
-              user.firstName[0].toUpperCase(),
-              style: const TextStyle(
-                fontWeight: FontWeight.bold,
-                color: Colors.black,
-              ),
-            )
+          ? textIcon
           : ClipRRect(
               borderRadius: BorderRadius.circular(50),
               child: CachedNetworkImage(
@@ -66,8 +70,7 @@ class UserCircleAvatar extends ConsumerWidget {
                   "Authorization": "Bearer ${Store.get(StoreKey.accessToken)}",
                 },
                 fadeInDuration: const Duration(milliseconds: 300),
-                errorWidget: (context, error, stackTrace) =>
-                    Image.memory(kTransparentImage),
+                errorWidget: (context, error, stackTrace) => textIcon,
               ),
             ),
     );

+ 36 - 0
mobile/lib/utils/datetime_extensions.dart

@@ -0,0 +1,36 @@
+extension TimeAgoExtension on DateTime {
+  String timeAgo({bool numericDates = true}) {
+    DateTime date = toLocal();
+    final date2 = DateTime.now().toLocal();
+    final difference = date2.difference(date);
+
+    if (difference.inSeconds < 5) {
+      return 'Just now';
+    } else if (difference.inSeconds < 60) {
+      return '${difference.inSeconds} seconds ago';
+    } else if (difference.inMinutes <= 1) {
+      return (numericDates) ? '1 minute ago' : 'A minute ago';
+    } else if (difference.inMinutes < 60) {
+      return '${difference.inMinutes} minutes ago';
+    } else if (difference.inHours <= 1) {
+      return (numericDates) ? '1 hour ago' : 'An hour ago';
+    } else if (difference.inHours < 60) {
+      return '${difference.inHours} hours ago';
+    } else if (difference.inDays <= 1) {
+      return (numericDates) ? '1 day ago' : 'Yesterday';
+    } else if (difference.inDays < 6) {
+      return '${difference.inDays} days ago';
+    } else if ((difference.inDays / 7).ceil() <= 1) {
+      return (numericDates) ? '1 week ago' : 'Last week';
+    } else if ((difference.inDays / 7).ceil() < 4) {
+      return '${(difference.inDays / 7).ceil()} weeks ago';
+    } else if ((difference.inDays / 30).ceil() <= 1) {
+      return (numericDates) ? '1 month ago' : 'Last month';
+    } else if ((difference.inDays / 30).ceil() < 30) {
+      return '${(difference.inDays / 30).ceil()} months ago';
+    } else if ((difference.inDays / 365).ceil() <= 1) {
+      return (numericDates) ? '1 year ago' : 'Last year';
+    }
+    return '${(difference.inDays / 365).floor()} years ago';
+  }
+}

+ 1 - 0
server/src/domain/activity/activity.service.ts

@@ -58,6 +58,7 @@ export class ActivityService {
       delete dto.comment;
       [activity] = await this.repository.search({
         ...common,
+        isGlobal: !dto.assetId,
         isLiked: true,
       });
       duplicate = !!activity;

+ 4 - 3
server/src/infra/repositories/activity.repository.ts

@@ -1,7 +1,7 @@
 import { IActivityRepository } from '@app/domain';
 import { Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
-import { Repository } from 'typeorm';
+import { IsNull, Repository } from 'typeorm';
 import { ActivityEntity } from '../entities/activity.entity';
 
 export interface ActivitySearch {
@@ -9,6 +9,7 @@ export interface ActivitySearch {
   assetId?: string;
   userId?: string;
   isLiked?: boolean;
+  isGlobal?: boolean;
 }
 
 @Injectable()
@@ -16,11 +17,11 @@ export class ActivityRepository implements IActivityRepository {
   constructor(@InjectRepository(ActivityEntity) private repository: Repository<ActivityEntity>) {}
 
   search(options: ActivitySearch): Promise<ActivityEntity[]> {
-    const { userId, assetId, albumId, isLiked } = options;
+    const { userId, assetId, albumId, isLiked, isGlobal } = options;
     return this.repository.find({
       where: {
         userId,
-        assetId,
+        assetId: isGlobal ? IsNull() : assetId,
         albumId,
         isLiked,
       },