feat: Add file selector support and enhance game data directory management
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -1,10 +1,3 @@
|
|||||||
/// Flutter entry point for the GUI host application.
|
|
||||||
///
|
|
||||||
/// The GUI bootstraps bundled and discoverable game data through
|
|
||||||
/// [Wolf3dFlutterEngine]
|
|
||||||
/// before presenting the game-selection flow.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
|
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||||
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||||
#include <window_manager/window_manager_plugin.h>
|
#include <window_manager/window_manager_plugin.h>
|
||||||
|
|
||||||
@@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
|||||||
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
|
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
|
||||||
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
|
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||||
|
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
|
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
|
||||||
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
|
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
audioplayers_linux
|
audioplayers_linux
|
||||||
|
file_selector_linux
|
||||||
screen_retriever_linux
|
screen_retriever_linux
|
||||||
window_manager
|
window_manager
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class DefaultGameDataDirectoryPersistence {
|
|||||||
return _resolvedPath!;
|
return _resolvedPath!;
|
||||||
}
|
}
|
||||||
|
|
||||||
_resolvedPath = '$platformConfigDir/data_source.json';
|
_resolvedPath = '$platformConfigDir/settings.json';
|
||||||
return _resolvedPath!;
|
return _resolvedPath!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,18 +61,36 @@ class DefaultGameDataDirectoryPersistence {
|
|||||||
try {
|
try {
|
||||||
final normalized = directoryPath?.trim();
|
final normalized = directoryPath?.trim();
|
||||||
final file = File(filePath);
|
final file = File(filePath);
|
||||||
|
Map<String, Object?> existingPayload = <String, Object?>{};
|
||||||
|
|
||||||
|
if (await file.exists()) {
|
||||||
|
try {
|
||||||
|
final String raw = await file.readAsString();
|
||||||
|
final Object? decoded = jsonDecode(raw);
|
||||||
|
if (decoded is Map<String, Object?>) {
|
||||||
|
existingPayload = decoded;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore malformed existing content.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await file.parent.create(recursive: true);
|
await file.parent.create(recursive: true);
|
||||||
if (normalized == null || normalized.isEmpty) {
|
if (normalized == null || normalized.isEmpty) {
|
||||||
if (await file.exists()) {
|
existingPayload.remove('dataDirectory');
|
||||||
await file.delete();
|
if (existingPayload.isEmpty) {
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
final payload = jsonEncode(existingPayload);
|
||||||
|
await file.writeAsString(payload, flush: true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final payload = jsonEncode(<String, Object>{
|
existingPayload['dataDirectory'] = normalized;
|
||||||
'dataDirectory': normalized,
|
final payload = jsonEncode(existingPayload);
|
||||||
});
|
|
||||||
await file.writeAsString(payload, flush: true);
|
await file.writeAsString(payload, flush: true);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Best-effort only.
|
// Best-effort only.
|
||||||
|
|||||||
@@ -9,11 +9,27 @@ class NoGameDataScreen extends StatelessWidget {
|
|||||||
const NoGameDataScreen({
|
const NoGameDataScreen({
|
||||||
super.key,
|
super.key,
|
||||||
this.configuredDataDirectory,
|
this.configuredDataDirectory,
|
||||||
|
this.onPickGameDataDirectory,
|
||||||
|
this.onPickGameDataFiles,
|
||||||
|
this.isLoadingGameData = false,
|
||||||
|
this.pickerError,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Previously configured external game-data directory, if any.
|
/// Previously configured external game-data directory, if any.
|
||||||
final String? configuredDataDirectory;
|
final String? configuredDataDirectory;
|
||||||
|
|
||||||
|
/// Invoked when the user requests selecting a game-data directory.
|
||||||
|
final Future<void> Function()? onPickGameDataDirectory;
|
||||||
|
|
||||||
|
/// Invoked when the user requests selecting one or more data files.
|
||||||
|
final Future<void> Function()? onPickGameDataFiles;
|
||||||
|
|
||||||
|
/// Whether the host is currently reloading after picker selection.
|
||||||
|
final bool isLoadingGameData;
|
||||||
|
|
||||||
|
/// Optional picker/reload error shown to the user.
|
||||||
|
final String? pickerError;
|
||||||
|
|
||||||
static Color _colorFromVgaIndex(int index) {
|
static Color _colorFromVgaIndex(int index) {
|
||||||
final int packed = ColorPalette.vga32Bit[index]; // 0xAABBGGRR
|
final int packed = ColorPalette.vga32Bit[index]; // 0xAABBGGRR
|
||||||
final int r = packed & 0xFF;
|
final int r = packed & 0xFF;
|
||||||
@@ -64,7 +80,7 @@ class NoGameDataScreen extends StatelessWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'No game files were discovered.\n\n'
|
'No game files were discovered.\n\n'
|
||||||
'Select a game-data directory in setup.',
|
'Select a game-data directory, or select one or more game-data files.',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: _bodyColor,
|
color: _bodyColor,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
@@ -81,6 +97,58 @@ class NoGameDataScreen extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: isLoadingGameData
|
||||||
|
? null
|
||||||
|
: onPickGameDataDirectory,
|
||||||
|
child: Text(
|
||||||
|
isLoadingGameData
|
||||||
|
? 'Loading data...'
|
||||||
|
: 'Select data directory',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: isLoadingGameData
|
||||||
|
? null
|
||||||
|
: onPickGameDataFiles,
|
||||||
|
child: Text(
|
||||||
|
isLoadingGameData
|
||||||
|
? 'Loading data...'
|
||||||
|
: 'Select data files',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (isLoadingGameData)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 10),
|
||||||
|
child: Text(
|
||||||
|
'Scanning selected locations...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _bodyColor,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (pickerError != null && pickerError!.trim().isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: Text(
|
||||||
|
pickerError!.trim(),
|
||||||
|
style: TextStyle(
|
||||||
|
color: _emphasisColor,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.3,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (configuredDataDirectory != null &&
|
if (configuredDataDirectory != null &&
|
||||||
configuredDataDirectory!.trim().isNotEmpty)
|
configuredDataDirectory!.trim().isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:file_selector/file_selector.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
|
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
|
||||||
|
|
||||||
/// Minimal app shell that binds a prepared [Wolf3dFlutterEngine] instance to
|
/// Minimal app shell that binds a prepared [Wolf3dFlutterEngine] instance to
|
||||||
/// host screens.
|
/// host screens.
|
||||||
class Wolf3dApp extends StatelessWidget {
|
class Wolf3dApp extends StatefulWidget {
|
||||||
/// Shared initialized facade that owns game data, input, and audio services.
|
/// Shared initialized facade that owns game data, input, and audio services.
|
||||||
final Wolf3dFlutterEngine engine;
|
final Wolf3dFlutterEngine engine;
|
||||||
|
|
||||||
@@ -14,12 +17,124 @@ class Wolf3dApp extends StatelessWidget {
|
|||||||
required this.engine,
|
required this.engine,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<Wolf3dApp> createState() => _Wolf3dAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Wolf3dAppState extends State<Wolf3dApp> {
|
||||||
|
bool _isLoadingGameData = false;
|
||||||
|
String? _pickerError;
|
||||||
|
|
||||||
|
Future<void> _pickGameDataDirectory() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingGameData = true;
|
||||||
|
_pickerError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final String? directoryPath = await getDirectoryPath(
|
||||||
|
confirmButtonText: 'Use this folder',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (directoryPath == null || directoryPath.trim().isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await widget.engine.init(directory: directoryPath.trim());
|
||||||
|
} catch (error) {
|
||||||
|
setState(() {
|
||||||
|
_pickerError = 'Unable to load selected directory: $error';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingGameData = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickGameDataFiles() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingGameData = true;
|
||||||
|
_pickerError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final List<XFile> selectedFiles = await openFiles();
|
||||||
|
|
||||||
|
if (selectedFiles.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final LinkedHashSet<String> directories = LinkedHashSet<String>();
|
||||||
|
for (final XFile file in selectedFiles) {
|
||||||
|
final String directory = _directoryFromFilePath(file.path);
|
||||||
|
if (directory.isNotEmpty) {
|
||||||
|
directories.add(directory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directories.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_pickerError = 'Selected files do not expose local filesystem paths.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<String> orderedDirectories = directories.toList(
|
||||||
|
growable: false,
|
||||||
|
);
|
||||||
|
await widget.engine.init(
|
||||||
|
directory: orderedDirectories.first,
|
||||||
|
additionalDirectories: orderedDirectories.skip(1),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setState(() {
|
||||||
|
_pickerError = 'Unable to load selected files: $error';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingGameData = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _directoryFromFilePath(String path) {
|
||||||
|
final String trimmedPath = path.trim();
|
||||||
|
if (trimmedPath.isEmpty) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
final int slashIndex = trimmedPath.lastIndexOf('/');
|
||||||
|
final int backslashIndex = trimmedPath.lastIndexOf(r'\');
|
||||||
|
final int separatorIndex = slashIndex > backslashIndex
|
||||||
|
? slashIndex
|
||||||
|
: backslashIndex;
|
||||||
|
|
||||||
|
if (separatorIndex < 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (separatorIndex == 0) {
|
||||||
|
return trimmedPath[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmedPath.substring(0, separatorIndex);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return engine.availableGames.isEmpty
|
return widget.engine.availableGames.isEmpty
|
||||||
? NoGameDataScreen(
|
? NoGameDataScreen(
|
||||||
configuredDataDirectory: engine.configuredDataDirectory,
|
configuredDataDirectory: widget.engine.configuredDataDirectory,
|
||||||
|
onPickGameDataDirectory: _pickGameDataDirectory,
|
||||||
|
onPickGameDataFiles: _pickGameDataFiles,
|
||||||
|
isLoadingGameData: _isLoadingGameData,
|
||||||
|
pickerError: _pickerError,
|
||||||
)
|
)
|
||||||
: GameScreen(wolf3d: engine);
|
: GameScreen(wolf3d: widget.engine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
|
|||||||
/// Initializes the engine by loading available game data.
|
/// Initializes the engine by loading available game data.
|
||||||
Future<Wolf3dFlutterEngine> init({
|
Future<Wolf3dFlutterEngine> init({
|
||||||
String? directory,
|
String? directory,
|
||||||
|
Iterable<String>? additionalDirectories,
|
||||||
}) async {
|
}) async {
|
||||||
await desktop_windowing_support.ensureDesktopWindowingInitialized();
|
await desktop_windowing_support.ensureDesktopWindowingInitialized();
|
||||||
await audio.init();
|
await audio.init();
|
||||||
@@ -128,21 +129,37 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// On non-web platforms, also scan the local filesystem for user-supplied
|
// On non-web platforms, also scan local filesystem locations for
|
||||||
// data folders so the host can pick up extra versions automatically.
|
// user-supplied data folders so the host can pick up extra versions.
|
||||||
if (!kIsWeb && resolvedDirectory != null) {
|
final Set<String> directoriesToScan = <String>{};
|
||||||
try {
|
if (resolvedDirectory != null && resolvedDirectory.isNotEmpty) {
|
||||||
final externalGames = await WolfensteinLoader.discover(
|
directoriesToScan.add(resolvedDirectory);
|
||||||
directoryPath: resolvedDirectory,
|
}
|
||||||
recursive: true,
|
|
||||||
);
|
if (additionalDirectories != null) {
|
||||||
for (var entry in externalGames.entries) {
|
for (final String directoryPath in additionalDirectories) {
|
||||||
if (!availableGames.any((g) => g.version == entry.key)) {
|
final String trimmedPath = directoryPath.trim();
|
||||||
availableGames.add(entry.value);
|
if (trimmedPath.isNotEmpty) {
|
||||||
}
|
directoriesToScan.add(trimmedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!kIsWeb && directoriesToScan.isNotEmpty) {
|
||||||
|
for (final String directoryPath in directoriesToScan) {
|
||||||
|
try {
|
||||||
|
final externalGames = await WolfensteinLoader.discover(
|
||||||
|
directoryPath: directoryPath,
|
||||||
|
recursive: true,
|
||||||
|
);
|
||||||
|
for (var entry in externalGames.entries) {
|
||||||
|
if (!availableGames.any((g) => g.version == entry.key)) {
|
||||||
|
availableGames.add(entry.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("External discovery failed: $e");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
debugPrint("External discovery failed: $e");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
audioplayers: ^6.6.0
|
audioplayers: ^6.6.0
|
||||||
|
file_selector: ^1.0.3
|
||||||
window_manager: ^0.5.1
|
window_manager: ^0.5.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final persistence = DefaultGameDataDirectoryPersistence(
|
final persistence = DefaultGameDataDirectoryPersistence(
|
||||||
filePath: '${tempDir.path}/data_source.json',
|
filePath: '${tempDir.path}/settings.json',
|
||||||
);
|
);
|
||||||
|
|
||||||
await persistence.saveDataDirectory('/tmp/wolf-data');
|
await persistence.saveDataDirectory('/tmp/wolf-data');
|
||||||
@@ -70,7 +70,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
final path = '${tempDir.path}/data_source.json';
|
final path = '${tempDir.path}/settings.json';
|
||||||
final persistence = DefaultGameDataDirectoryPersistence(filePath: path);
|
final persistence = DefaultGameDataDirectoryPersistence(filePath: path);
|
||||||
|
|
||||||
await persistence.saveDataDirectory('/tmp/wolf-data');
|
await persistence.saveDataDirectory('/tmp/wolf-data');
|
||||||
@@ -79,6 +79,39 @@ void main() {
|
|||||||
expect(await File(path).exists(), isFalse);
|
expect(await File(path).exists(), isFalse);
|
||||||
expect(await persistence.loadDataDirectory(), isNull);
|
expect(await persistence.loadDataDirectory(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('preserves unrelated settings when updating data directory', () async {
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp(
|
||||||
|
'wolf3d-data-config-',
|
||||||
|
);
|
||||||
|
addTearDown(() async {
|
||||||
|
if (await tempDir.exists()) {
|
||||||
|
await tempDir.delete(recursive: true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final path = '${tempDir.path}/settings.json';
|
||||||
|
final file = File(path);
|
||||||
|
await file.parent.create(recursive: true);
|
||||||
|
await file.writeAsString(
|
||||||
|
'{"renderScale":2.0,"preferSoftwareRenderer":false}',
|
||||||
|
);
|
||||||
|
|
||||||
|
final persistence = DefaultGameDataDirectoryPersistence(filePath: path);
|
||||||
|
|
||||||
|
await persistence.saveDataDirectory('/tmp/wolf-data');
|
||||||
|
|
||||||
|
final updated = await file.readAsString();
|
||||||
|
expect(updated, contains('"renderScale":2.0'));
|
||||||
|
expect(updated, contains('"preferSoftwareRenderer":false'));
|
||||||
|
expect(updated, contains('"dataDirectory":"/tmp/wolf-data"'));
|
||||||
|
|
||||||
|
await persistence.saveDataDirectory(null);
|
||||||
|
final cleared = await file.readAsString();
|
||||||
|
expect(cleared, contains('"renderScale":2.0'));
|
||||||
|
expect(cleared, contains('"preferSoftwareRenderer":false'));
|
||||||
|
expect(cleared.contains('"dataDirectory"'), isFalse);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Wolf3dApp forwards configured directory to no-data screen', (
|
testWidgets('Wolf3dApp forwards configured directory to no-data screen', (
|
||||||
|
|||||||
Reference in New Issue
Block a user