fix: shared album control permissions (#22435)

* fix: shared album control permissions

* fix: properly display "add photos"

* fix: dont allow modification of album order

* fix: album title/description edit from app bar

* chore: code review changes

* chore: format translations

* chore: lintings
This commit is contained in:
Brandon Wees 2025-10-13 21:34:22 -05:00 committed by GitHub
parent 146973b072
commit 8473dab684
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 99 additions and 54 deletions

View file

@ -1039,6 +1039,7 @@
"exif_bottom_sheet_description_error": "Error updating description", "exif_bottom_sheet_description_error": "Error updating description",
"exif_bottom_sheet_details": "DETAILS", "exif_bottom_sheet_details": "DETAILS",
"exif_bottom_sheet_location": "LOCATION", "exif_bottom_sheet_location": "LOCATION",
"exif_bottom_sheet_no_description": "No description",
"exif_bottom_sheet_people": "PEOPLE", "exif_bottom_sheet_people": "PEOPLE",
"exif_bottom_sheet_person_add_person": "Add name", "exif_bottom_sheet_person_add_person": "Add name",
"exit_slideshow": "Exit Slideshow", "exit_slideshow": "Exit Slideshow",

View file

@ -120,6 +120,10 @@ class RemoteAlbumService {
return _repository.getSharedUsers(albumId); return _repository.getSharedUsers(albumId);
} }
Future<AlbumUserRole?> getUserRole(String albumId, String userId) {
return _repository.getUserRole(albumId, userId);
}
Future<List<RemoteAsset>> getAssets(String albumId) { Future<List<RemoteAsset>> getAssets(String albumId) {
return _repository.getAssets(albumId); return _repository.getAssets(albumId);
} }

View file

@ -221,6 +221,15 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.get(); .get();
} }
Future<AlbumUserRole?> getUserRole(String albumId, String userId) async {
final query = _db.remoteAlbumUserEntity.select()
..where((row) => row.albumId.equals(albumId) & row.userId.equals(userId))
..limit(1);
final result = await query.getSingleOrNull();
return result?.role;
}
Future<List<RemoteAsset>> getAssets(String albumId) { Future<List<RemoteAsset>> getAssets(String albumId) {
final query = _db.remoteAlbumAssetEntity.select().join([ final query = _db.remoteAlbumAssetEntity.select().join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)), innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),

View file

@ -169,9 +169,11 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
context.pushRoute(const DriftActivitiesRoute()); context.pushRoute(const DriftActivitiesRoute());
} }
void showOptionSheet(BuildContext context) { Future<void> showOptionSheet(BuildContext context) async {
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);
final isOwner = user != null ? user.id == _album.ownerId : false; final isOwner = user != null ? user.id == _album.ownerId : false;
final canAddPhotos =
await ref.read(remoteAlbumServiceProvider).getUserRole(_album.id, user?.id ?? '') == AlbumUserRole.editor;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@ -193,22 +195,30 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
context.pop(); context.pop();
} }
: null, : null,
onAddPhotos: () async { onAddPhotos: isOwner || canAddPhotos
await addAssets(context); ? () async {
context.pop(); await addAssets(context);
}, context.pop();
onToggleAlbumOrder: () async { }
await toggleAlbumOrder(); : null,
context.pop(); onToggleAlbumOrder: isOwner
}, ? () async {
onEditAlbum: () async { await toggleAlbumOrder();
context.pop(); context.pop();
await showEditTitleAndDescription(context); }
}, : null,
onCreateSharedLink: () async { onEditAlbum: isOwner
context.pop(); ? () async {
context.pushRoute(SharedLinkEditRoute(albumId: _album.id)); context.pop();
}, await showEditTitleAndDescription(context);
}
: null,
onCreateSharedLink: isOwner
? () async {
context.pop();
context.pushRoute(SharedLinkEditRoute(albumId: _album.id));
}
: null,
onShowOptions: () { onShowOptions: () {
context.pop(); context.pop();
context.pushRoute(const DriftAlbumOptionsRoute()); context.pushRoute(const DriftAlbumOptionsRoute());
@ -220,6 +230,9 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final user = ref.watch(currentUserProvider);
final isOwner = user != null ? user.id == _album.ownerId : false;
return PopScope( return PopScope(
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
if (didPop) { if (didPop) {
@ -243,8 +256,8 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
appBar: RemoteAlbumSliverAppBar( appBar: RemoteAlbumSliverAppBar(
icon: Icons.photo_album_outlined, icon: Icons.photo_album_outlined,
onShowOptions: () => showOptionSheet(context), onShowOptions: () => showOptionSheet(context),
onToggleAlbumOrder: () => toggleAlbumOrder(), onToggleAlbumOrder: isOwner ? () => toggleAlbumOrder() : null,
onEditTitle: () => showEditTitleAndDescription(context), onEditTitle: isOwner ? () => showEditTitleAndDescription(context) : null,
onActivity: () => showActivity(context), onActivity: () => showActivity(context),
), ),
bottomSheet: RemoteAlbumBottomSheet(album: _album), bottomSheet: RemoteAlbumBottomSheet(album: _album),

View file

@ -43,7 +43,7 @@ class ViewerBottomBar extends ConsumerWidget {
final actions = <Widget>[ final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer), const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(), if (asset.type == AssetType.image && isOwner) const EditImageActionButton(),
if (isOwner) ...[ if (isOwner) ...[
if (asset.hasRemote && isOwner && isArchived) if (asset.hasRemote && isOwner && isArchived)
const UnArchiveActionButton(source: ActionSource.viewer) const UnArchiveActionButton(source: ActionSource.viewer)

View file

@ -140,6 +140,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final cameraTitle = _getCameraInfoTitle(exifInfo); final cameraTitle = _getCameraInfoTitle(exifInfo);
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
return SliverList.list( return SliverList.list(
children: [ children: [
@ -147,10 +148,10 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
_SheetTile( _SheetTile(
title: _getDateTime(context, asset), title: _getDateTime(context, asset),
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
trailing: asset.hasRemote ? const Icon(Icons.edit, size: 18) : null, trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
onTap: asset.hasRemote ? () async => await _editDateTime(context, ref) : null, onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
), ),
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo), if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
const SheetPeopleDetails(), const SheetPeopleDetails(),
const SheetLocationDetails(), const SheetLocationDetails(),
// Details header // Details header
@ -265,8 +266,9 @@ class _SheetTile extends ConsumerWidget {
class _SheetAssetDescription extends ConsumerStatefulWidget { class _SheetAssetDescription extends ConsumerStatefulWidget {
final ExifInfo exif; final ExifInfo exif;
final bool isEditable;
const _SheetAssetDescription({required this.exif}); const _SheetAssetDescription({required this.exif, this.isEditable = true});
@override @override
ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState(); ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState();
@ -312,27 +314,33 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
// Update controller text when EXIF data changes // Update controller text when EXIF data changes
final currentDescription = currentExifInfo?.description ?? ''; final currentDescription = currentExifInfo?.description ?? '';
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
context: context,
);
if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) { if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) {
_controller.text = currentDescription; _controller.text = currentDescription;
} }
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: TextField( child: IgnorePointer(
controller: _controller, ignoring: !widget.isEditable,
keyboardType: TextInputType.multiline, child: TextField(
focusNode: _descriptionFocus, controller: _controller,
maxLines: null, // makes it grow as text is added keyboardType: TextInputType.multiline,
decoration: InputDecoration( focusNode: _descriptionFocus,
hintText: 'exif_bottom_sheet_description'.t(context: context), maxLines: null, // makes it grow as text is added
border: InputBorder.none, decoration: InputDecoration(
enabledBorder: InputBorder.none, hintText: hintText,
focusedBorder: InputBorder.none, border: InputBorder.none,
disabledBorder: InputBorder.none, enabledBorder: InputBorder.none,
errorBorder: InputBorder.none, focusedBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none, disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
),
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
), ),
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
), ),
); );
} }

View file

@ -45,7 +45,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
(previousRouteName != TabShellRoute.name || tabRoute == TabEnum.search) && (previousRouteName != TabShellRoute.name || tabRoute == TabEnum.search) &&
previousRouteName != AssetViewerRoute.name && previousRouteName != AssetViewerRoute.name &&
previousRouteName != null && previousRouteName != null &&
previousRouteName != LocalTimelineRoute.name; previousRouteName != LocalTimelineRoute.name &&
isOwner;
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));

View file

@ -24,6 +24,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
class RemoteAlbumBottomSheet extends ConsumerStatefulWidget { class RemoteAlbumBottomSheet extends ConsumerStatefulWidget {
@ -53,6 +54,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
Widget build(BuildContext context) { Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider); final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final ownsAlbum = ref.watch(currentUserProvider)?.id == widget.album.ownerId;
Future<void> addAssetsToAlbum(RemoteAlbum album) async { Future<void> addAssetsToAlbum(RemoteAlbum album) async {
final selectedAssets = multiselect.selectedAssets; final selectedAssets = multiselect.selectedAssets;
@ -93,28 +95,35 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
const ShareActionButton(source: ActionSource.timeline), const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[ if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline), const ShareLinkActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline), if (ownsAlbum) ...[
const ArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
],
const DownloadActionButton(source: ActionSource.timeline), const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable if (ownsAlbum) ...[
? const TrashActionButton(source: ActionSource.timeline) isTrashEnable
: const DeletePermanentActionButton(source: ActionSource.timeline), ? const TrashActionButton(source: ActionSource.timeline)
const EditDateTimeActionButton(source: ActionSource.timeline), : const DeletePermanentActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline), const EditDateTimeActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline), if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
], ],
if (multiselect.hasLocal) ...[ if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline), const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(source: ActionSource.timeline), const UploadActionButton(source: ActionSource.timeline),
], ],
RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id), if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
],
slivers: [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
], ],
slivers: ownsAlbum
? [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
]
: null,
); );
} }
} }