mirror of
https://github.com/immich-app/immich.git
synced 2025-08-07 09:04:09 -04:00
fix(mobile): use cached thumbnail in full size image provider (#20637)
This commit is contained in:
parent
9680f1290d
commit
9e6fee4064
@ -13,6 +13,9 @@ extension ContextHelper on BuildContext {
|
||||
// Returns the current height from MediaQuery
|
||||
double get height => MediaQuery.sizeOf(this).height;
|
||||
|
||||
// Returns the current size from MediaQuery
|
||||
Size get sizeData => MediaQuery.sizeOf(this);
|
||||
|
||||
// Returns true if the app is running on a mobile device (!tablets)
|
||||
bool get isMobile => width < 550;
|
||||
|
||||
|
10
mobile/lib/extensions/codec_extensions.dart
Normal file
10
mobile/lib/extensions/codec_extensions.dart
Normal file
@ -0,0 +1,10 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/painting.dart';
|
||||
|
||||
extension CodecImageInfoExtension on Codec {
|
||||
Future<ImageInfo> getImageInfo({double scale = 1.0}) async {
|
||||
final frame = await getNextFrame();
|
||||
return ImageInfo(image: frame.image, scale: scale);
|
||||
}
|
||||
}
|
@ -147,11 +147,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
// Precache both thumbnail and full image for smooth transitions
|
||||
unawaited(
|
||||
Future.wait([
|
||||
precacheImage(
|
||||
getThumbnailImageProvider(asset: asset, size: screenSize),
|
||||
context,
|
||||
onError: (_, __) {},
|
||||
),
|
||||
precacheImage(getThumbnailImageProvider(asset: asset), context, onError: (_, __) {}),
|
||||
precacheImage(getFullImageProvider(asset, size: screenSize), context, onError: (_, __) {}),
|
||||
]),
|
||||
);
|
||||
@ -482,7 +478,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: backgroundColor,
|
||||
child: Thumbnail(asset: asset, fit: BoxFit.contain, size: Size(ctx.width, ctx.height)),
|
||||
child: Thumbnail(asset: asset, fit: BoxFit.contain),
|
||||
);
|
||||
}
|
||||
|
||||
@ -513,7 +509,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
}
|
||||
|
||||
PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) {
|
||||
final size = Size(ctx.width, ctx.height);
|
||||
final size = ctx.sizeData;
|
||||
return PhotoViewGalleryPageOptions(
|
||||
key: ValueKey(asset.heroTag),
|
||||
imageProvider: getFullImageProvider(asset, size: size),
|
||||
@ -529,10 +525,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
onTapDown: _onTapDown,
|
||||
onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: ctx.width,
|
||||
height: ctx.height,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
color: backgroundColor,
|
||||
child: Thumbnail(asset: asset, fit: BoxFit.contain, size: size),
|
||||
child: Thumbnail(asset: asset, fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -562,7 +558,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
asset: asset,
|
||||
image: Image(
|
||||
key: ValueKey(asset),
|
||||
image: getFullImageProvider(asset, size: Size(ctx.width, ctx.height)),
|
||||
image: getFullImageProvider(asset, size: ctx.sizeData),
|
||||
fit: BoxFit.contain,
|
||||
height: ctx.height,
|
||||
width: ctx.width,
|
||||
|
@ -4,13 +4,14 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
|
||||
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920)}) {
|
||||
// Create new provider and cache it
|
||||
final ImageProvider provider;
|
||||
if (_shouldUseLocalAsset(asset)) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||
provider = LocalFullImageProvider(id: id, name: asset.name, size: size, type: asset.type);
|
||||
provider = LocalFullImageProvider(id: id, size: size, type: asset.type, updatedAt: asset.updatedAt);
|
||||
} else {
|
||||
final String assetId;
|
||||
if (asset is LocalAsset && asset.hasRemote) {
|
||||
@ -26,7 +27,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
|
||||
return provider;
|
||||
}
|
||||
|
||||
ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Size size = const Size.square(256)}) {
|
||||
ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Size size = kThumbnailResolution}) {
|
||||
assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided');
|
||||
|
||||
if (remoteId != null) {
|
||||
@ -35,7 +36,7 @@ ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Siz
|
||||
|
||||
if (_shouldUseLocalAsset(asset!)) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||
return LocalThumbProvider(id: id, updatedAt: asset.updatedAt, name: asset.name, size: size);
|
||||
return LocalThumbProvider(id: id, updatedAt: asset.updatedAt, size: size);
|
||||
}
|
||||
|
||||
final String assetId;
|
||||
@ -52,3 +53,25 @@ ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Siz
|
||||
|
||||
bool _shouldUseLocalAsset(BaseAsset asset) =>
|
||||
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage));
|
||||
|
||||
ImageInfo? getCachedImage(ImageProvider key) {
|
||||
ImageInfo? thumbnail;
|
||||
final ImageStreamCompleter? stream = PaintingBinding.instance.imageCache.putIfAbsent(
|
||||
key,
|
||||
() => throw Exception(), // don't bother loading if it isn't cached
|
||||
);
|
||||
|
||||
if (stream != null) {
|
||||
void listener(ImageInfo info, bool synchronousCall) {
|
||||
thumbnail = info;
|
||||
}
|
||||
|
||||
try {
|
||||
stream.addListener(ImageStreamListener(listener));
|
||||
} finally {
|
||||
stream.removeListener(ImageStreamListener(listener));
|
||||
}
|
||||
}
|
||||
|
||||
return thumbnail;
|
||||
}
|
||||
|
@ -2,15 +2,17 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/extensions/codec_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
||||
import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart';
|
||||
@ -22,14 +24,12 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
|
||||
|
||||
final String id;
|
||||
final DateTime updatedAt;
|
||||
final String name;
|
||||
final Size size;
|
||||
|
||||
const LocalThumbProvider({
|
||||
required this.id,
|
||||
required this.updatedAt,
|
||||
required this.name,
|
||||
this.size = const Size.square(kTimelineFixedTileExtent),
|
||||
this.size = kThumbnailResolution,
|
||||
this.cacheManager,
|
||||
});
|
||||
|
||||
@ -45,10 +45,8 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
|
||||
codec: _codec(key, cache, decode),
|
||||
scale: 1.0,
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Id', key.id),
|
||||
DiagnosticsProperty<DateTime>('Updated at', key.updatedAt),
|
||||
DiagnosticsProperty<String>('Name', key.name),
|
||||
DiagnosticsProperty<Size>('Size', key.size),
|
||||
],
|
||||
);
|
||||
@ -68,7 +66,7 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
|
||||
final thumbnailBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size);
|
||||
if (thumbnailBytes == null) {
|
||||
PaintingBinding.instance.imageCache.evict(key);
|
||||
throw StateError("Loading thumb for local photo ${key.name} failed");
|
||||
throw StateError("Loading thumb for local photo ${key.id} failed");
|
||||
}
|
||||
|
||||
final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes);
|
||||
@ -94,11 +92,11 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
|
||||
final StorageRepository _storageRepository = const StorageRepository();
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final Size size;
|
||||
final AssetType type;
|
||||
final DateTime updatedAt; // temporary, only exists to fetch cached thumbnail until local disk cache is removed
|
||||
|
||||
const LocalFullImageProvider({required this.id, required this.name, required this.size, required this.type});
|
||||
const LocalFullImageProvider({required this.id, required this.size, required this.type, required this.updatedAt});
|
||||
|
||||
@override
|
||||
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@ -107,52 +105,45 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key, decode),
|
||||
scale: 1.0,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription(name);
|
||||
},
|
||||
return OneFramePlaceholderImageStreamCompleter(
|
||||
_codec(key, decode),
|
||||
initialImage: getCachedImage(LocalThumbProvider(id: key.id, updatedAt: key.updatedAt)),
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<String>('Id', key.id),
|
||||
DiagnosticsProperty<DateTime>('Updated at', key.updatedAt),
|
||||
DiagnosticsProperty<Size>('Size', key.size),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<Codec> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||
Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) {
|
||||
try {
|
||||
switch (key.type) {
|
||||
case AssetType.image:
|
||||
yield* _decodeProgressive(key, decode);
|
||||
break;
|
||||
case AssetType.video:
|
||||
final codec = await _getThumbnailCodec(key, decode);
|
||||
if (codec == null) {
|
||||
throw StateError("Failed to load preview for ${key.name}");
|
||||
}
|
||||
yield codec;
|
||||
break;
|
||||
case AssetType.other:
|
||||
case AssetType.audio:
|
||||
throw StateError('Unsupported asset type ${key.type}');
|
||||
}
|
||||
return switch (key.type) {
|
||||
AssetType.image => _decodeProgressive(key, decode),
|
||||
AssetType.video => _getThumbnailCodec(key, decode),
|
||||
_ => throw StateError('Unsupported asset type ${key.type}'),
|
||||
};
|
||||
} catch (error, stack) {
|
||||
Logger('ImmichLocalImageProvider').severe('Error loading local image ${key.name}', error, stack);
|
||||
Logger('ImmichLocalImageProvider').severe('Error loading local image ${key.id}', error, stack);
|
||||
throw const ImageLoadingException('Could not load image from local storage');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Codec?> _getThumbnailCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async {
|
||||
Stream<ImageInfo> _getThumbnailCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||
final thumbBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size);
|
||||
if (thumbBytes == null) {
|
||||
return null;
|
||||
throw StateError("Failed to load preview for ${key.id}");
|
||||
}
|
||||
final buffer = await ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
return decode(buffer);
|
||||
final codec = await decode(buffer);
|
||||
yield await codec.getImageInfo();
|
||||
}
|
||||
|
||||
Stream<Codec> _decodeProgressive(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||
Stream<ImageInfo> _decodeProgressive(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||
final file = await _storageRepository.getFileForAsset(key.id);
|
||||
if (file == null) {
|
||||
throw StateError("Opening file for asset ${key.name} failed");
|
||||
throw StateError("Opening file for asset ${key.id} failed");
|
||||
}
|
||||
|
||||
final fileSize = await file.length();
|
||||
@ -171,7 +162,8 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
|
||||
final mediumThumb = await _assetMediaRepository.getThumbnail(key.id, size: size);
|
||||
if (mediumThumb != null) {
|
||||
final mediumBuffer = await ImmutableBuffer.fromUint8List(mediumThumb);
|
||||
yield await decode(mediumBuffer);
|
||||
final codec = await decode(mediumBuffer);
|
||||
yield await codec.getImageInfo();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
@ -187,24 +179,26 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
|
||||
final highThumb = await _assetMediaRepository.getThumbnail(key.id, size: size);
|
||||
if (highThumb != null) {
|
||||
final highBuffer = await ImmutableBuffer.fromUint8List(highThumb);
|
||||
yield await decode(highBuffer);
|
||||
final codec = await decode(highBuffer);
|
||||
yield await codec.getImageInfo();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final buffer = await ImmutableBuffer.fromFilePath(file.path);
|
||||
yield await decode(buffer);
|
||||
final codec = await decode(buffer);
|
||||
yield await codec.getImageInfo();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is LocalFullImageProvider) {
|
||||
return id == other.id && size == other.size && type == other.type && name == other.name;
|
||||
return id == other.id && size == other.size && type == other.type;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ size.hashCode ^ type.hashCode ^ name.hashCode;
|
||||
int get hashCode => id.hashCode ^ size.hashCode ^ type.hashCode;
|
||||
}
|
||||
|
@ -0,0 +1,67 @@
|
||||
// The below code is adapted from cached_network_image package's
|
||||
// MultiImageStreamCompleter to better suit one-frame image loading.
|
||||
// In particular, it allows providing an initial image to emit synchronously.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
|
||||
/// An ImageStreamCompleter with support for loading multiple images.
|
||||
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
|
||||
ImageInfo? _initialImage;
|
||||
|
||||
/// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images]
|
||||
/// should be the primary images to display (typically asynchronously as they load).
|
||||
/// The [initialImage] is an optional image that will be emitted synchronously
|
||||
/// until the first stream image is completed, useful as a thumbnail or placeholder.
|
||||
OneFramePlaceholderImageStreamCompleter(
|
||||
Stream<ImageInfo> images, {
|
||||
ImageInfo? initialImage,
|
||||
InformationCollector? informationCollector,
|
||||
}) {
|
||||
_initialImage = initialImage;
|
||||
images.listen(
|
||||
_onImage,
|
||||
onError: (Object error, StackTrace stack) {
|
||||
reportError(
|
||||
context: ErrorDescription('resolving a single-frame image stream'),
|
||||
exception: error,
|
||||
stack: stack,
|
||||
informationCollector: informationCollector,
|
||||
silent: true,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onImage(ImageInfo image) {
|
||||
setImage(image);
|
||||
_initialImage?.dispose();
|
||||
_initialImage = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void addListener(ImageStreamListener listener) {
|
||||
final initialImage = _initialImage;
|
||||
if (initialImage != null) {
|
||||
try {
|
||||
listener.onImage(initialImage.clone(), true);
|
||||
} catch (exception, stack) {
|
||||
reportError(
|
||||
context: ErrorDescription('by a synchronously-called image listener'),
|
||||
exception: exception,
|
||||
stack: stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
super.addListener(listener);
|
||||
}
|
||||
|
||||
@override
|
||||
void onDisposed() {
|
||||
_initialImage?.dispose();
|
||||
_initialImage = null;
|
||||
super.onDisposed();
|
||||
}
|
||||
}
|
@ -1,12 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/extensions/codec_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
@ -81,36 +83,28 @@ class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> {
|
||||
@override
|
||||
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
|
||||
final cache = cacheManager ?? RemoteImageCacheManager();
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode, chunkEvents),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
return OneFramePlaceholderImageStreamCompleter(
|
||||
_codec(key, cache, decode),
|
||||
initialImage: getCachedImage(RemoteThumbProvider(assetId: key.assetId)),
|
||||
);
|
||||
}
|
||||
|
||||
Stream<Codec> _codec(
|
||||
RemoteFullImageProvider key,
|
||||
CacheManager cache,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkController,
|
||||
) async* {
|
||||
yield await ImageLoader.loadImageFromCache(
|
||||
Stream<ImageInfo> _codec(RemoteFullImageProvider key, CacheManager cache, ImageDecoderCallback decode) async* {
|
||||
final codec = await ImageLoader.loadImageFromCache(
|
||||
getPreviewUrlForRemoteId(key.assetId),
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
chunkEvents: chunkController,
|
||||
);
|
||||
yield await codec.getImageInfo();
|
||||
|
||||
if (AppSetting.get(Setting.loadOriginal)) {
|
||||
yield await ImageLoader.loadImageFromCache(
|
||||
final codec = await ImageLoader.loadImageFromCache(
|
||||
getOriginalUrlForRemoteId(key.assetId),
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
chunkEvents: chunkController,
|
||||
);
|
||||
yield await codec.getImageInfo();
|
||||
}
|
||||
await chunkController.close();
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -19,7 +19,7 @@ class Thumbnail extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final thumbHash = asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null;
|
||||
final provider = getThumbnailImageProvider(asset: asset, remoteId: remoteId, size: size);
|
||||
final provider = getThumbnailImageProvider(asset: asset, remoteId: remoteId);
|
||||
|
||||
return OctoImage.fromSet(
|
||||
image: provider,
|
||||
|
@ -1,5 +1,8 @@
|
||||
import 'dart:ui';
|
||||
|
||||
const double kTimelineHeaderExtent = 80.0;
|
||||
const double kTimelineFixedTileExtent = 256;
|
||||
const Size kTimelineFixedTileExtent = Size.square(256);
|
||||
const Size kThumbnailResolution = kTimelineFixedTileExtent;
|
||||
const double kTimelineSpacing = 2.0;
|
||||
const int kTimelineColumnCount = 3;
|
||||
|
||||
|
@ -21,7 +21,7 @@ abstract class SegmentBuilder {
|
||||
static Widget buildPlaceholder(
|
||||
BuildContext context,
|
||||
int count, {
|
||||
Size size = const Size.square(kTimelineFixedTileExtent),
|
||||
Size size = kTimelineFixedTileExtent,
|
||||
double spacing = kTimelineSpacing,
|
||||
}) => RepaintBoundary(
|
||||
child: FixedTimelineRow(
|
||||
|
Loading…
x
Reference in New Issue
Block a user