mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-22 22:48:04 +02:00
feat: implement IPTV settings management with streaming and display preferences
This commit is contained in:
1 parent
62fc19598a
commit
63846c090a
8 files changed
+265
-10
No files matched your search
@@ -0,0 +1,62 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
import '../database/database.dart';
|
||||||
|
import '../models/user.dart';
|
||||||
|
|
||||||
|
class SettingsHandler {
|
||||||
|
final AppDatabase db;
|
||||||
|
|
||||||
|
SettingsHandler(this.db);
|
||||||
|
|
||||||
|
Router get router {
|
||||||
|
final router = Router();
|
||||||
|
|
||||||
|
// GET /api/settings
|
||||||
|
router.get('/', (Request req) {
|
||||||
|
final user = req.context['user'] as User?;
|
||||||
|
if (user == null) {
|
||||||
|
return Response.forbidden(jsonEncode({'error': 'Authentication required'}));
|
||||||
|
}
|
||||||
|
|
||||||
|
final settingsJson = db.getUserSettings(user.id);
|
||||||
|
|
||||||
|
// If no settings exist, return empty object or default
|
||||||
|
return Response.ok(
|
||||||
|
settingsJson ?? '{}',
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/settings
|
||||||
|
router.post('/', (Request req) async {
|
||||||
|
final user = req.context['user'] as User?;
|
||||||
|
if (user == null) {
|
||||||
|
return Response.forbidden(jsonEncode({'error': 'Authentication required'}));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final body = await req.readAsString();
|
||||||
|
// Validate JSON
|
||||||
|
try {
|
||||||
|
jsonDecode(body);
|
||||||
|
} catch (e) {
|
||||||
|
return Response.badRequest(body: jsonEncode({'error': 'Invalid JSON'}));
|
||||||
|
}
|
||||||
|
|
||||||
|
db.updateUserSettings(user.id, body);
|
||||||
|
|
||||||
|
return Response.ok(
|
||||||
|
body,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return Response.internalServerError(
|
||||||
|
body: jsonEncode({'error': 'Failed to save settings: $e'}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,16 @@ class AppDatabase {
|
|||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
|
// User Settings table
|
||||||
|
_db.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS user_settings (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
settings_json TEXT NOT NULL,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
// Indexes
|
// Indexes
|
||||||
_db.execute('CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)');
|
_db.execute('CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)');
|
||||||
_db.execute('CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)');
|
_db.execute('CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)');
|
||||||
@@ -312,6 +322,33 @@ class AppDatabase {
|
|||||||
_db.execute('DELETE FROM playlists WHERE id = ?', [playlistId]);
|
_db.execute('DELETE FROM playlists WHERE id = ?', [playlistId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== User Settings ====================
|
||||||
|
|
||||||
|
/// Get user settings as JSON string
|
||||||
|
String? getUserSettings(String userId) {
|
||||||
|
final result = _db.select(
|
||||||
|
'SELECT settings_json FROM user_settings WHERE user_id = ?',
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.isEmpty) return null;
|
||||||
|
return result.first['settings_json'] as String;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update user settings
|
||||||
|
void updateUserSettings(String userId, String settingsJson) {
|
||||||
|
final now = DateTime.now().toIso8601String();
|
||||||
|
|
||||||
|
// UPSERT (Insert or Replace)
|
||||||
|
_db.execute('''
|
||||||
|
INSERT INTO user_settings (user_id, settings_json, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
settings_json = excluded.settings_json,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
''', [userId, settingsJson, now]);
|
||||||
|
}
|
||||||
|
|
||||||
/// Close database connection
|
/// Close database connection
|
||||||
void close() {
|
void close() {
|
||||||
_db.dispose();
|
_db.dispose();
|
||||||
|
|||||||
+10
-2
@@ -11,7 +11,10 @@ import 'database/database.dart';
|
|||||||
import 'models/user.dart';
|
import 'models/user.dart';
|
||||||
import 'api/auth_handler.dart';
|
import 'api/auth_handler.dart';
|
||||||
import 'api/users_handler.dart';
|
import 'api/users_handler.dart';
|
||||||
|
import 'api/auth_handler.dart';
|
||||||
|
import 'api/users_handler.dart';
|
||||||
import 'api/playlists_handler.dart';
|
import 'api/playlists_handler.dart';
|
||||||
|
import 'api/settings_handler.dart';
|
||||||
import 'middleware/auth_middleware.dart';
|
import 'middleware/auth_middleware.dart';
|
||||||
import 'middleware/security_middleware.dart';
|
import 'middleware/security_middleware.dart';
|
||||||
import 'services/cleanup_service.dart';
|
import 'services/cleanup_service.dart';
|
||||||
@@ -35,6 +38,7 @@ void main(List<String> args) async {
|
|||||||
final authHandler = AuthHandler(db);
|
final authHandler = AuthHandler(db);
|
||||||
final playlistsHandler = PlaylistsHandler(db);
|
final playlistsHandler = PlaylistsHandler(db);
|
||||||
final usersHandler = UsersHandler(db);
|
final usersHandler = UsersHandler(db);
|
||||||
|
final settingsHandler = SettingsHandler(db);
|
||||||
|
|
||||||
// Setup router
|
// Setup router
|
||||||
final apiRouter = Router()
|
final apiRouter = Router()
|
||||||
@@ -43,11 +47,15 @@ void main(List<String> args) async {
|
|||||||
// Playlists endpoints (with auth middleware)
|
// Playlists endpoints (with auth middleware)
|
||||||
..mount('/api/playlists', Pipeline()
|
..mount('/api/playlists', Pipeline()
|
||||||
.addMiddleware(authMiddleware(db))
|
.addMiddleware(authMiddleware(db))
|
||||||
.addHandler(playlistsHandler.router.call),)
|
.addHandler(playlistsHandler.router.call))
|
||||||
// Users endpoints (with auth middleware)
|
// Users endpoints (with auth middleware)
|
||||||
..mount('/api/users', Pipeline()
|
..mount('/api/users', Pipeline()
|
||||||
.addMiddleware(authMiddleware(db))
|
.addMiddleware(authMiddleware(db))
|
||||||
.addHandler(usersHandler.router.call),);
|
.addHandler(usersHandler.router.call))
|
||||||
|
// Settings endpoints (with auth middleware)
|
||||||
|
..mount('/api/settings', Pipeline()
|
||||||
|
.addMiddleware(authMiddleware(db))
|
||||||
|
.addHandler(settingsHandler.router.call));
|
||||||
|
|
||||||
// Initialize Cleanup Service
|
// Initialize Cleanup Service
|
||||||
final cleanupService = CleanupService();
|
final cleanupService = CleanupService();
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'dart:html' as html;
|
||||||
|
import '../models/app_user.dart';
|
||||||
|
|
||||||
|
class SettingsApiService {
|
||||||
|
final String _baseUrl = html.window.location.origin;
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> getSettings() async {
|
||||||
|
final token = html.window.localStorage['auth_token'];
|
||||||
|
if (token == null) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$_baseUrl/api/settings'),
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
if (response.body.isEmpty) return {};
|
||||||
|
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> saveSettings(Map<String, dynamic> settings) async {
|
||||||
|
final token = html.window.localStorage['auth_token'];
|
||||||
|
if (token == null) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$_baseUrl/api/settings'),
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: jsonEncode(settings),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.statusCode == 200;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../../../core/services/settings_api_service.dart';
|
||||||
|
import '../../auth/providers/auth_provider.dart';
|
||||||
|
|
||||||
/// Streaming quality presets for Live TV
|
/// Streaming quality presets for Live TV
|
||||||
enum StreamQuality {
|
enum StreamQuality {
|
||||||
@@ -225,28 +227,29 @@ class IptvSettings {
|
|||||||
bool matchesFilter(String categoryName) => matchesLiveTvFilter(categoryName);
|
bool matchesFilter(String categoryName) => matchesLiveTvFilter(categoryName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// IPTV Settings notifier with SharedPreferences persistence
|
/// IPTV Settings notifier with SharedPreferences and API persistence
|
||||||
class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
|
class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
|
||||||
SharedPreferences? _prefs;
|
SharedPreferences? _prefs;
|
||||||
|
final SettingsApiService _apiService = SettingsApiService();
|
||||||
|
final Ref _ref;
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
|
|
||||||
IptvSettingsNotifier() : super(const IptvSettings()) {
|
IptvSettingsNotifier(this._ref) : super(const IptvSettings()) {
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load settings from SharedPreferences
|
/// Load settings from SharedPreferences and then API
|
||||||
Future<void> _loadSettings() async {
|
Future<void> _loadSettings() async {
|
||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_prefs = await SharedPreferences.getInstance();
|
_prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
// Load filters
|
// Load local settings first (fastest)
|
||||||
final liveTv = _prefs?.getString(_SettingsKeys.liveTvFilter) ?? '';
|
final liveTv = _prefs?.getString(_SettingsKeys.liveTvFilter) ?? '';
|
||||||
final movies = _prefs?.getString(_SettingsKeys.moviesFilter) ?? '';
|
final movies = _prefs?.getString(_SettingsKeys.moviesFilter) ?? '';
|
||||||
final series = _prefs?.getString(_SettingsKeys.seriesFilter) ?? '';
|
final series = _prefs?.getString(_SettingsKeys.seriesFilter) ?? '';
|
||||||
|
|
||||||
// Load streaming settings
|
|
||||||
final quality = StreamQuality.values[
|
final quality = StreamQuality.values[
|
||||||
_prefs?.getInt(_SettingsKeys.streamQuality) ?? StreamQuality.medium.index
|
_prefs?.getInt(_SettingsKeys.streamQuality) ?? StreamQuality.medium.index
|
||||||
];
|
];
|
||||||
@@ -265,7 +268,6 @@ class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
|
|||||||
];
|
];
|
||||||
final directPlay = _prefs?.getBool(_SettingsKeys.preferDirectPlay) ?? false;
|
final directPlay = _prefs?.getBool(_SettingsKeys.preferDirectPlay) ?? false;
|
||||||
|
|
||||||
// Load Player Display Settings
|
|
||||||
final showClock = _prefs?.getBool(_SettingsKeys.showClock) ?? false;
|
final showClock = _prefs?.getBool(_SettingsKeys.showClock) ?? false;
|
||||||
final aspectRatio = _prefs?.getString(_SettingsKeys.preferredAspectRatio) ?? 'contain';
|
final aspectRatio = _prefs?.getString(_SettingsKeys.preferredAspectRatio) ?? 'contain';
|
||||||
|
|
||||||
@@ -285,18 +287,84 @@ class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
print('Settings loaded: Clock=$showClock, Ratio=$aspectRatio');
|
|
||||||
|
// Sync from API if logged in
|
||||||
|
final authState = _ref.read(authProvider);
|
||||||
|
if (authState.isAuthenticated) {
|
||||||
|
await _fetchFromApi();
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading settings: $e');
|
print('Error loading settings: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchFromApi() async {
|
||||||
|
try {
|
||||||
|
final remoteSettings = await _apiService.getSettings();
|
||||||
|
if (remoteSettings != null && remoteSettings.isNotEmpty) {
|
||||||
|
// Update state with remote settings if they exist
|
||||||
|
T? getValue<T>(String key) => remoteSettings[key] as T?;
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
liveTvCategoryFilter: getValue<String>(_SettingsKeys.liveTvFilter),
|
||||||
|
moviesCategoryFilter: getValue<String>(_SettingsKeys.moviesFilter),
|
||||||
|
seriesCategoryFilter: getValue<String>(_SettingsKeys.seriesFilter),
|
||||||
|
|
||||||
|
streamQuality: remoteSettings[_SettingsKeys.streamQuality] != null
|
||||||
|
? StreamQuality.values[remoteSettings[_SettingsKeys.streamQuality] as int] : null,
|
||||||
|
bufferSize: remoteSettings[_SettingsKeys.bufferSize] != null
|
||||||
|
? BufferSize.values[remoteSettings[_SettingsKeys.bufferSize] as int] : null,
|
||||||
|
connectionTimeout: remoteSettings[_SettingsKeys.connectionTimeout] != null
|
||||||
|
? ConnectionTimeout.values[remoteSettings[_SettingsKeys.connectionTimeout] as int] : null,
|
||||||
|
autoReconnect: getValue<bool>(_SettingsKeys.autoReconnect),
|
||||||
|
epgCacheDuration: remoteSettings[_SettingsKeys.epgCacheDuration] != null
|
||||||
|
? EpgCacheDuration.values[remoteSettings[_SettingsKeys.epgCacheDuration] as int] : null,
|
||||||
|
transcodingMode: remoteSettings[_SettingsKeys.transcodingMode] != null
|
||||||
|
? TranscodingMode.values[remoteSettings[_SettingsKeys.transcodingMode] as int] : null,
|
||||||
|
preferDirectPlay: getValue<bool>(_SettingsKeys.preferDirectPlay),
|
||||||
|
showClock: getValue<bool>(_SettingsKeys.showClock),
|
||||||
|
preferredAspectRatio: getValue<String>(_SettingsKeys.preferredAspectRatio),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save to local prefs to keep in sync
|
||||||
|
_saveToPrefs();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Error syncing settings from API: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveToApi() async {
|
||||||
|
final authState = _ref.read(authProvider);
|
||||||
|
if (!authState.isAuthenticated) return;
|
||||||
|
|
||||||
|
// Create map from state
|
||||||
|
final settingsMap = {
|
||||||
|
_SettingsKeys.liveTvFilter: state.liveTvCategoryFilter,
|
||||||
|
_SettingsKeys.moviesFilter: state.moviesCategoryFilter,
|
||||||
|
_SettingsKeys.seriesFilter: state.seriesCategoryFilter,
|
||||||
|
_SettingsKeys.streamQuality: state.streamQuality.index,
|
||||||
|
_SettingsKeys.bufferSize: state.bufferSize.index,
|
||||||
|
_SettingsKeys.connectionTimeout: state.connectionTimeout.index,
|
||||||
|
_SettingsKeys.autoReconnect: state.autoReconnect,
|
||||||
|
_SettingsKeys.epgCacheDuration: state.epgCacheDuration.index,
|
||||||
|
_SettingsKeys.transcodingMode: state.transcodingMode.index,
|
||||||
|
_SettingsKeys.preferDirectPlay: state.preferDirectPlay,
|
||||||
|
_SettingsKeys.showClock: state.showClock,
|
||||||
|
_SettingsKeys.preferredAspectRatio: state.preferredAspectRatio,
|
||||||
|
};
|
||||||
|
|
||||||
|
await _apiService.saveSettings(settingsMap);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Save Helpers =====
|
// ===== Save Helpers =====
|
||||||
|
|
||||||
Future<void> _saveString(String key, String value) async {
|
Future<void> _saveString(String key, String value) async {
|
||||||
try {
|
try {
|
||||||
_prefs ??= await SharedPreferences.getInstance();
|
_prefs ??= await SharedPreferences.getInstance();
|
||||||
await _prefs?.setString(key, value);
|
await _prefs?.setString(key, value);
|
||||||
|
await _saveToApi();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error saving setting $key: $e');
|
print('Error saving setting $key: $e');
|
||||||
}
|
}
|
||||||
@@ -306,6 +374,7 @@ class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
|
|||||||
try {
|
try {
|
||||||
_prefs ??= await SharedPreferences.getInstance();
|
_prefs ??= await SharedPreferences.getInstance();
|
||||||
await _prefs?.setInt(key, value);
|
await _prefs?.setInt(key, value);
|
||||||
|
await _saveToApi();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error saving setting $key: $e');
|
print('Error saving setting $key: $e');
|
||||||
}
|
}
|
||||||
@@ -315,11 +384,31 @@ class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
|
|||||||
try {
|
try {
|
||||||
_prefs ??= await SharedPreferences.getInstance();
|
_prefs ??= await SharedPreferences.getInstance();
|
||||||
await _prefs?.setBool(key, value);
|
await _prefs?.setBool(key, value);
|
||||||
|
await _saveToApi();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error saving setting $key: $e');
|
print('Error saving setting $key: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _saveToPrefs() async {
|
||||||
|
// Helper to bulk save state to prefs (after API fetch)
|
||||||
|
_prefs ??= await SharedPreferences.getInstance();
|
||||||
|
await _prefs?.setString(_SettingsKeys.liveTvFilter, state.liveTvCategoryFilter);
|
||||||
|
await _prefs?.setString(_SettingsKeys.moviesFilter, state.moviesCategoryFilter);
|
||||||
|
await _prefs?.setString(_SettingsKeys.seriesFilter, state.seriesCategoryFilter);
|
||||||
|
|
||||||
|
await _prefs?.setInt(_SettingsKeys.streamQuality, state.streamQuality.index);
|
||||||
|
await _prefs?.setInt(_SettingsKeys.bufferSize, state.bufferSize.index);
|
||||||
|
await _prefs?.setInt(_SettingsKeys.connectionTimeout, state.connectionTimeout.index);
|
||||||
|
await _prefs?.setBool(_SettingsKeys.autoReconnect, state.autoReconnect);
|
||||||
|
await _prefs?.setInt(_SettingsKeys.epgCacheDuration, state.epgCacheDuration.index);
|
||||||
|
await _prefs?.setInt(_SettingsKeys.transcodingMode, state.transcodingMode.index);
|
||||||
|
await _prefs?.setBool(_SettingsKeys.preferDirectPlay, state.preferDirectPlay);
|
||||||
|
|
||||||
|
await _prefs?.setBool(_SettingsKeys.showClock, state.showClock);
|
||||||
|
await _prefs?.setString(_SettingsKeys.preferredAspectRatio, state.preferredAspectRatio);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Filter Setters =====
|
// ===== Filter Setters =====
|
||||||
|
|
||||||
void setLiveTvFilter(String filter) {
|
void setLiveTvFilter(String filter) {
|
||||||
@@ -404,5 +493,13 @@ class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
|
|||||||
/// Provider for IPTV settings
|
/// Provider for IPTV settings
|
||||||
final iptvSettingsProvider =
|
final iptvSettingsProvider =
|
||||||
StateNotifierProvider<IptvSettingsNotifier, IptvSettings>((ref) {
|
StateNotifierProvider<IptvSettingsNotifier, IptvSettings>((ref) {
|
||||||
return IptvSettingsNotifier();
|
// Watch auth provider to re-fetch settings on login/logout
|
||||||
|
final authState = ref.watch(authProvider);
|
||||||
|
final notifier = IptvSettingsNotifier(ref);
|
||||||
|
|
||||||
|
// Note: Since we watch authProvider, this provider will be disposed and recreated
|
||||||
|
// whenever authState changes. This automatically handles fetching settings for
|
||||||
|
// the new user on creation.
|
||||||
|
|
||||||
|
return notifier;
|
||||||
});
|
});
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user