diff --git a/android/app/src/main/kotlin/com/androidircx/flutter/AndroidIrcxEngineManager.kt b/android/app/src/main/kotlin/com/androidircx/flutter/AndroidIrcxEngineManager.kt index 9caa9c6..310c881 100644 --- a/android/app/src/main/kotlin/com/androidircx/flutter/AndroidIrcxEngineManager.kt +++ b/android/app/src/main/kotlin/com/androidircx/flutter/AndroidIrcxEngineManager.kt @@ -5,6 +5,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.graphics.BitmapFactory import android.os.Build import android.provider.Settings import io.flutter.embedding.engine.FlutterEngine @@ -195,6 +196,9 @@ object AndroidIrcxEngineManager { } builder .setSmallIcon(R.drawable.ic_stat_androidircx) + .setLargeIcon( + BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher), + ) .setContentTitle(title) .setContentText(body) .setStyle(Notification.BigTextStyle().bigText(body)) diff --git a/android/app/src/main/kotlin/com/androidircx/flutter/AndroidIrcxForegroundService.kt b/android/app/src/main/kotlin/com/androidircx/flutter/AndroidIrcxForegroundService.kt index 0b5bca4..6eadd11 100644 --- a/android/app/src/main/kotlin/com/androidircx/flutter/AndroidIrcxForegroundService.kt +++ b/android/app/src/main/kotlin/com/androidircx/flutter/AndroidIrcxForegroundService.kt @@ -8,6 +8,7 @@ import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo +import android.graphics.BitmapFactory import android.os.Build import android.os.IBinder @@ -119,6 +120,9 @@ class AndroidIrcxForegroundService : Service() { builder .setSmallIcon(R.drawable.ic_stat_androidircx) + .setLargeIcon( + BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher), + ) .setContentTitle(getString(R.string.notification_connection_title)) .setContentText(compactText) .setStyle(Notification.BigTextStyle().bigText(bigText)) diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index f60d39e..dc09ce9 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index d99cf78..009bf57 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index f3c50f4..3a374b1 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index f2c5799..e20f58b 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 2ef766a..fde254a 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4..5363f82 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 4829c80..2dd5641 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index fcfe672..d2b6fa1 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 3be43d8..e223bb0 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index e186b8b..f4abb9b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index 74092cb..983a032 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 17c0d57..6762389 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index fcfe672..d2b6fa1 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index d75525a..3db0244 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index e1f7bc4..a553526 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index e1f7bc4..a553526 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index 663891b..575b8b1 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 41c96f4..21bb7a9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index ba07ce7..40b331f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 02b0d80..8d29374 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png index 4b8e543..5363f82 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png differ diff --git a/lib/core/app/app_version.dart b/lib/core/app/app_version.dart new file mode 100644 index 0000000..74f25fb --- /dev/null +++ b/lib/core/app/app_version.dart @@ -0,0 +1,5 @@ +const appVersionName = '1.0.6'; +const appVersionCode = 9; +const appVersion = '$appVersionName+$appVersionCode'; + +const ctcpVersionReply = 'AndroidIRCX Flutter v$appVersion'; diff --git a/lib/features/chat/application/ban_mask_service.dart b/lib/features/chat/application/ban_mask_service.dart new file mode 100644 index 0000000..f709ddb --- /dev/null +++ b/lib/features/chat/application/ban_mask_service.dart @@ -0,0 +1,145 @@ +class BanMaskType { + const BanMaskType({ + required this.id, + required this.pattern, + required this.description, + required this.example, + }); + + final int id; + final String pattern; + final String description; + final String example; +} + +const List banMaskTypes = [ + BanMaskType( + id: 0, + pattern: '*!user@host', + description: 'Ban by user@host', + example: '*!john@192.168.1.1', + ), + BanMaskType( + id: 1, + pattern: '*!*user@host', + description: 'Ban by *user@host', + example: '*!*john@192.168.1.1', + ), + BanMaskType( + id: 2, + pattern: '*!*@host', + description: 'Ban by host only', + example: '*!*@192.168.1.1', + ), + BanMaskType( + id: 3, + pattern: '*!*user@*.host', + description: 'Ban by *user@*.domain', + example: '*!*john@*.example.com', + ), + BanMaskType( + id: 4, + pattern: '*!*@*.host', + description: 'Ban by *.domain only', + example: '*!*@*.example.com', + ), + BanMaskType( + id: 5, + pattern: 'nick!user@host', + description: 'Ban exact nick!user@host', + example: 'John!john@192.168.1.1', + ), + BanMaskType( + id: 6, + pattern: 'nick!*user@host', + description: 'Ban nick with *user@host', + example: 'John!*john@192.168.1.1', + ), + BanMaskType( + id: 7, + pattern: 'nick!*@host', + description: 'Ban nick with any user@host', + example: 'John!*@192.168.1.1', + ), + BanMaskType( + id: 8, + pattern: 'nick!*user@*.host', + description: 'Ban nick with *user@*.domain', + example: 'John!*john@*.example.com', + ), + BanMaskType( + id: 9, + pattern: 'nick!*@*.host', + description: 'Ban nick with *.domain', + example: 'John!*@*.example.com', + ), + BanMaskType( + id: 10, + pattern: 'nick!*@*', + description: 'Ban by nick only', + example: 'John!*@*', + ), + BanMaskType( + id: 11, + pattern: '*!ident@*', + description: 'Ban by ident only', + example: '*!john@*', + ), +]; + +class BanMaskService { + const BanMaskService(); + + String generateBanMask({ + required String nick, + required String ident, + required String host, + required int type, + }) { + final processedIdent = ident.startsWith('~') ? ident.substring(1) : ident; + final processedHost = _processHost(host, type); + + switch (type) { + case 0: + return '*!$processedIdent@$host'; + case 1: + return '*!*$processedIdent@$host'; + case 2: + return '*!*@$host'; + case 3: + return '*!*$processedIdent@$processedHost'; + case 4: + return '*!*@$processedHost'; + case 5: + return '$nick!$processedIdent@$host'; + case 6: + return '$nick!*$processedIdent@$host'; + case 7: + return '$nick!*@$host'; + case 8: + return '$nick!*$processedIdent@$processedHost'; + case 9: + return '$nick!*@$processedHost'; + case 10: + return '$nick!*@*'; + case 11: + return '*!$processedIdent@*'; + default: + return '*!*@$host'; + } + } + + String _processHost(String host, int type) { + if (!const [3, 4, 8, 9].contains(type)) { + return host; + } + if (RegExp(r'^\d+\.\d+\.\d+\.\d+$').hasMatch(host)) { + return host.replaceFirst(RegExp(r'\.\d+$'), '.*'); + } + final parts = host.split('.'); + if (parts.length >= 2) { + return '*.${parts.skip(parts.length - 2).join('.')}'; + } + return '*.$host'; + } +} diff --git a/lib/features/chat/application/chat_session_controller.dart b/lib/features/chat/application/chat_session_controller.dart index 9d3b1ad..ee9841e 100644 --- a/lib/features/chat/application/chat_session_controller.dart +++ b/lib/features/chat/application/chat_session_controller.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:androidircx/core/app/app_version.dart'; import 'package:androidircx/core/models/chat_tab.dart'; import 'package:androidircx/core/models/connection_state.dart'; import 'package:androidircx/core/models/dcc_session.dart'; @@ -9,6 +10,7 @@ import 'package:androidircx/core/models/network_config.dart'; import 'package:androidircx/core/models/app_settings.dart'; import 'package:androidircx/core/platform/foreground_connection_service.dart'; import 'package:androidircx/dcc/services/dcc_service.dart'; +import 'package:androidircx/features/chat/application/ban_mask_service.dart'; import 'package:androidircx/features/chat/application/command_service.dart'; import 'package:androidircx/features/chat/application/message_history_formatter.dart'; import 'package:androidircx/core/storage/settings_repository.dart'; @@ -48,11 +50,122 @@ class ComposerAutocompleteSuggestion { final int tokenEnd; } -enum ChannelUserAction { whois, query, op, deop, voice, devoice, kick, ban } +enum ChannelUserAction { + whois, + whowas, + query, + op, + deop, + voice, + devoice, + kick, + ban, + kickBan, + ignoreToggle, + ctcpPing, + ctcpVersion, + ctcpTime, + dccChat, +} + +enum ChannelModerationAction { kick, ban, kickBan, quiet } + +class IrcUserInfo { + const IrcUserInfo({ + required this.nick, + this.ident, + this.host, + this.realName, + this.account, + this.awayMessage, + this.server, + this.serverInfo, + this.idleSeconds, + this.signedOn, + this.channels = const [], + this.isRegistered = false, + this.isOper = false, + this.isSecure = false, + this.extra = const [], + this.fromWhowas = false, + }); + + final String nick; + final String? ident; + final String? host; + final String? realName; + final String? account; + final String? awayMessage; + final String? server; + final String? serverInfo; + final int? idleSeconds; + final DateTime? signedOn; + final List channels; + final bool isRegistered; + final bool isOper; + final bool isSecure; + final List extra; + final bool fromWhowas; + + String get userhost { + final user = (ident ?? '').trim(); + final hostValue = (host ?? '').trim(); + if (user.isEmpty && hostValue.isEmpty) { + return ''; + } + return '${user.isEmpty ? '*' : user}@${hostValue.isEmpty ? '*' : hostValue}'; + } + + String get hostmask { + final uh = userhost; + if (uh.isEmpty) { + return ''; + } + return '$nick!$uh'; + } + + IrcUserInfo copyWith({ + String? nick, + String? ident, + String? host, + String? realName, + String? account, + String? awayMessage, + String? server, + String? serverInfo, + int? idleSeconds, + DateTime? signedOn, + List? channels, + bool? isRegistered, + bool? isOper, + bool? isSecure, + List? extra, + bool? fromWhowas, + bool clearAway = false, + }) { + return IrcUserInfo( + nick: nick ?? this.nick, + ident: ident ?? this.ident, + host: host ?? this.host, + realName: realName ?? this.realName, + account: account ?? this.account, + awayMessage: clearAway ? null : (awayMessage ?? this.awayMessage), + server: server ?? this.server, + serverInfo: serverInfo ?? this.serverInfo, + idleSeconds: idleSeconds ?? this.idleSeconds, + signedOn: signedOn ?? this.signedOn, + channels: channels ?? this.channels, + isRegistered: isRegistered ?? this.isRegistered, + isOper: isOper ?? this.isOper, + isSecure: isSecure ?? this.isSecure, + extra: extra ?? this.extra, + fromWhowas: fromWhowas ?? this.fromWhowas, + ); + } +} class ChatSessionController extends ChangeNotifier { static const _historyPageSize = 200; - static const _ctcpVersionReply = 'AndroidIRCx Flutter 1.0.0'; static const _ctcpClientInfoReply = 'ACTION CLIENTINFO DCC FINGER PING SOURCE TIME USERINFO VERSION'; static const _ctcpUserInfoReply = 'AndroidIRCx Flutter user'; @@ -111,8 +224,8 @@ class ChatSessionController extends ChangeNotifier { final SettingsRepository _settingsRepository; final CommandService _commandService; final UserListsRepository? _userListsRepository; - List _autoModeEntries = const []; - bool _autoModeEntriesLoaded = false; + List _userListEntries = const []; + bool _userListEntriesLoaded = false; final int _maxReconnectAttempts; final Duration _reconnectBaseDelay; final Duration _reconnectMaxDelay; @@ -127,6 +240,7 @@ class ChatSessionController extends ChangeNotifier { final Map _nickHosts = {}; final Map _nickIdents = {}; final Map _nickAwayMessages = {}; + final Map _userInfoByNick = {}; final Map>> _channelUserModes = {}; final Map _activeBatches = {}; final Set _autoHistoryRequestedChannels = {}; @@ -139,6 +253,8 @@ class ChatSessionController extends ChangeNotifier { final StreamController _notificationController = StreamController.broadcast(sync: true); Timer? _reconnectTimer; + final List _timedUnbanTimers = []; + final Set _blacklistEnforcements = {}; Future? _startInFlight; late List _tabs; @@ -162,6 +278,8 @@ class ChatSessionController extends ChangeNotifier { List get tabs => List.unmodifiable(_tabs); String get activeTabId => _activeTabId; + String get channelPrefixChars => _channelPrefixChars; + String get nickPrefixChars => _nickPrefixChars; ConnectionSnapshot get connection => _connection; AppSettings get settings => _settings; List get commandHistory => _commandService.history; @@ -368,6 +486,28 @@ class ChatSessionController extends ChangeNotifier { return '$users users • $modes'; } + bool get canModerateActiveChannel { + if (activeTab.type != ChatTabType.channel) { + return false; + } + final ownModes = + _channelUserModes[activeTab.id]?[currentNick.trim().toLowerCase()] ?? + const {}; + return ownModes.contains('o') || + ownModes.contains('a') || + ownModes.contains('q'); + } + + bool get canVoiceOrKickActiveChannel { + if (activeTab.type != ChatTabType.channel) { + return false; + } + final ownModes = + _channelUserModes[activeTab.id]?[currentNick.trim().toLowerCase()] ?? + const {}; + return canModerateActiveChannel || ownModes.contains('h'); + } + List get activeChannelUsers { final users = _channelUsers[activeTab.id]; if (users == null) { @@ -454,23 +594,17 @@ class ChatSessionController extends ChangeNotifier { } String userDetailsForNick(String nick) { - final key = nick.trim().toLowerCase(); - if (key.isEmpty) { + final info = userInfoForNick(nick); + if (info.nick.trim().isEmpty) { return ''; } final details = [ - if ((_nickAccounts[key] ?? '').isNotEmpty) - 'account: ${_nickAccounts[key]}', - if ((_nickRealNames[key] ?? '').isNotEmpty) - 'realname: ${_nickRealNames[key]}', - if ((_nickIdents[key] ?? '').isNotEmpty || - (_nickHosts[key] ?? '').isNotEmpty) - '${(_nickIdents[key] ?? '').isEmpty ? '*' : _nickIdents[key]}@${(_nickHosts[key] ?? '').isEmpty ? '*' : _nickHosts[key]}', - if ((_nickAwayMessages[key] ?? '').isNotEmpty) - _nickAwayMessages[key] == '__away__' - ? 'away' - : 'away: ${_nickAwayMessages[key]}', + if ((info.account ?? '').isNotEmpty) 'account: ${info.account}', + if ((info.realName ?? '').isNotEmpty) 'realname: ${info.realName}', + if (info.userhost.isNotEmpty) info.userhost, + if ((info.awayMessage ?? '').isNotEmpty) + info.awayMessage == '__away__' ? 'away' : 'away: ${info.awayMessage}', if (_activeTabId.isNotEmpty) if ((_channelUserPrefixFor(activeTabId, nick) ?? '').isNotEmpty) 'mode: ${_channelUserPrefixFor(activeTabId, nick)}', @@ -478,6 +612,33 @@ class ChatSessionController extends ChangeNotifier { return details.join(' • '); } + IrcUserInfo userInfoForNick(String nick) { + final trimmed = _stripModePrefix(nick).trim(); + final key = trimmed.toLowerCase(); + if (key.isEmpty) { + return const IrcUserInfo(nick: ''); + } + final cached = _userInfoByNick[key] ?? IrcUserInfo(nick: trimmed); + return IrcUserInfo( + nick: cached.nick.isEmpty ? trimmed : cached.nick, + account: _nickAccounts[key], + realName: _nickRealNames[key], + ident: _nickIdents[key], + host: _nickHosts[key], + awayMessage: _nickAwayMessages[key], + server: cached.server, + serverInfo: cached.serverInfo, + idleSeconds: cached.idleSeconds, + signedOn: cached.signedOn, + channels: cached.channels, + isRegistered: cached.isRegistered, + isOper: cached.isOper, + isSecure: cached.isSecure, + extra: cached.extra, + fromWhowas: cached.fromWhowas, + ); + } + void _rememberFrameSenderState(IrcMessageFrame frame) { final nick = frame.senderNick; if (nick == null || nick.trim().isEmpty) { @@ -586,6 +747,26 @@ class ChatSessionController extends ChangeNotifier { _nickAwayMessages[key] = normalized; } } + + final previous = _userInfoByNick[key] ?? IrcUserInfo(nick: nick.trim()); + _userInfoByNick[key] = IrcUserInfo( + nick: previous.nick.isEmpty ? nick.trim() : previous.nick, + account: _nickAccounts[key], + realName: _nickRealNames[key], + ident: _nickIdents[key], + host: _nickHosts[key], + awayMessage: _nickAwayMessages[key], + server: previous.server, + serverInfo: previous.serverInfo, + idleSeconds: previous.idleSeconds, + signedOn: previous.signedOn, + channels: previous.channels, + isRegistered: previous.isRegistered, + isOper: previous.isOper, + isSecure: previous.isSecure, + extra: previous.extra, + fromWhowas: previous.fromWhowas, + ); } ({String? ident, String? host}) _senderIdentity(IrcMessageFrame frame) { @@ -697,52 +878,99 @@ class ChatSessionController extends ChangeNotifier { users.putIfAbsent(key, () => {}).addAll(modes); } + /// All locally managed user-list rules for this session. + List get userListEntries => + List.unmodifiable(_userListEntries); + /// Automatic-mode rules (auto-op / auto-halfop / auto-voice) applied when a /// matching user joins a channel where we hold the needed privilege. - List get autoModeEntries => - List.unmodifiable(_autoModeEntries); + List get autoModeEntries => List.unmodifiable( + _userListEntries.where((entry) => entry.type.isAutoMode), + ); + + List userListEntriesForType(UserListType type) => + List.unmodifiable( + _userListEntries.where((entry) => entry.type == type), + ); + + List get blacklistEntries => + userListEntriesForType(UserListType.blacklist); Future _loadAutoModeEntries() async { - if (_autoModeEntriesLoaded) { + if (_userListEntriesLoaded) { return; } final repository = _userListsRepository; if (repository != null) { try { - _autoModeEntries = await repository.loadAll(); + _userListEntries = await repository.loadAll(); } catch (_) { - _autoModeEntries = const []; + _userListEntries = const []; } } - _autoModeEntriesLoaded = true; + _userListEntriesLoaded = true; } - Future addAutoModeEntry(UserListEntry entry) async { + Future addUserListEntry(UserListEntry entry) async { final repository = _userListsRepository; if (repository != null) { - _autoModeEntries = await repository.add(entry); + _userListEntries = await repository.add(entry); } else { - _autoModeEntries = [ - ..._autoModeEntries.where((e) => e.key != entry.key), + _userListEntries = [ + ..._userListEntries.where((e) => e.key != entry.key), entry, ]; } - _autoModeEntriesLoaded = true; + _userListEntriesLoaded = true; + await _applyUserListSideEffect(entry, add: true); notifyListeners(); } - Future removeAutoModeEntry(UserListEntry entry) async { + Future removeUserListEntry(UserListEntry entry) async { final repository = _userListsRepository; if (repository != null) { - _autoModeEntries = await repository.remove(entry); + _userListEntries = await repository.remove(entry); } else { - _autoModeEntries = _autoModeEntries + _userListEntries = _userListEntries .where((e) => e.key != entry.key) .toList(); } + await _applyUserListSideEffect(entry, add: false); notifyListeners(); } + Future addAutoModeEntry(UserListEntry entry) { + assert(entry.type.isAutoMode); + return addUserListEntry(entry); + } + + Future removeAutoModeEntry(UserListEntry entry) { + return removeUserListEntry(entry); + } + + Future _applyUserListSideEffect( + UserListEntry entry, { + required bool add, + }) async { + if (entry.type != UserListType.notify) { + return; + } + final nick = _bareNickForMonitor(entry.mask); + if (nick == null) { + return; + } + await _ircService.sendRaw('MONITOR ${add ? '+' : '-'} $nick'); + } + + String? _bareNickForMonitor(String mask) { + final trimmed = mask.trim(); + if (trimmed.isEmpty || trimmed.contains('*') || trimmed.contains('?')) { + return null; + } + final nick = trimmed.split('!').first.split('@').first.trim(); + return nick.isEmpty ? null : nick; + } + /// When [nick] joins [channel], grants the highest auto-mode we are entitled /// to and the user is listed for. No-op for our own joins or when we lack the /// needed channel privilege. @@ -753,7 +981,7 @@ class ChatSessionController extends ChangeNotifier { String? ident, String? host, }) { - if (_autoModeEntries.isEmpty || _isSelfNick(nick)) { + if (autoModeEntries.isEmpty || _isSelfNick(nick)) { return; } final ownModes = @@ -764,12 +992,8 @@ class ChatSessionController extends ChangeNotifier { ownModes.contains('q') || ownModes.contains('a'); final hasHalfOp = ownModes.contains('h'); - for (final type in const [ - UserListType.autoOp, - UserListType.autoHalfOp, - UserListType.autoVoice, - ]) { - final matched = _autoModeEntries.any( + for (final type in UserListType.autoModeTypes) { + final matched = autoModeEntries.any( (entry) => entry.type == type && entry.matches( @@ -786,9 +1010,12 @@ class ChatSessionController extends ChangeNotifier { final canApply = switch (type) { UserListType.autoOp || UserListType.autoHalfOp => hasOp, UserListType.autoVoice => hasOp || hasHalfOp, + _ => false, }; if (canApply) { - unawaited(_ircService.sendRaw('MODE $channel +${type.modeChar} $nick')); + unawaited( + _ircService.sendRaw('MODE $channel +${type.modeChar!} $nick'), + ); return; } } @@ -1536,6 +1763,8 @@ class ChatSessionController extends ChangeNotifier { switch (action) { case ChannelUserAction.whois: await handleComposerSubmit('/whois $bare'); + case ChannelUserAction.whowas: + await handleComposerSubmit('/whowas $bare'); case ChannelUserAction.query: await handleComposerSubmit('/query $bare'); case ChannelUserAction.op: @@ -1550,6 +1779,108 @@ class ChatSessionController extends ChangeNotifier { await handleComposerSubmit('/kick $bare'); case ChannelUserAction.ban: await handleComposerSubmit('/ban $bare'); + case ChannelUserAction.kickBan: + await handleComposerSubmit('/kickban $bare'); + case ChannelUserAction.ignoreToggle: + if (_ignoreMasks.contains(bare.toLowerCase())) { + await handleComposerSubmit('/unignore $bare'); + } else { + await handleComposerSubmit('/ignore $bare'); + } + case ChannelUserAction.ctcpPing: + await handleComposerSubmit( + '/ctcp $bare ping ${DateTime.now().millisecondsSinceEpoch}', + ); + case ChannelUserAction.ctcpVersion: + await handleComposerSubmit('/ctcp $bare version'); + case ChannelUserAction.ctcpTime: + await handleComposerSubmit('/ctcp $bare time'); + case ChannelUserAction.dccChat: + await handleComposerSubmit('/dccchat $bare'); + } + } + + String banMaskPreviewForNick(String nick, int banMaskType) { + final identity = _banIdentityForNickOrMask(nick, banMaskType); + return identity.mask; + } + + Future performChannelModerationAction({ + required String nick, + required ChannelModerationAction action, + String? reason, + int banMaskType = 10, + Duration? timedRemoval, + }) async { + if (activeTab.type != ChatTabType.channel) { + _appendMessage( + tabId: activeTab.id, + sender: 'error', + content: 'Moderation actions require an active channel tab.', + kind: IrcMessageKind.error, + ); + notifyListeners(); + return; + } + + final channel = activeTab.name; + final bare = _stripModePrefix(nick).trim(); + if (bare.isEmpty) { + return; + } + final cleanReason = (reason ?? '').trim(); + + switch (action) { + case ChannelModerationAction.kick: + await _ircService.sendKick( + channel: channel, + nick: bare, + reason: cleanReason.isEmpty ? null : cleanReason, + ); + case ChannelModerationAction.ban: + final mask = _banIdentityForNickOrMask(bare, banMaskType).mask; + await _ircService.sendChannelMode( + channel: channel, + mode: '+b', + target: mask, + ); + _scheduleTimedModeRemoval( + channel: channel, + mode: 'b', + mask: mask, + duration: timedRemoval, + ); + case ChannelModerationAction.kickBan: + final mask = _banIdentityForNickOrMask(bare, banMaskType).mask; + await _ircService.sendChannelMode( + channel: channel, + mode: '+b', + target: mask, + ); + await _ircService.sendKick( + channel: channel, + nick: bare, + reason: cleanReason.isEmpty ? null : cleanReason, + ); + _scheduleTimedModeRemoval( + channel: channel, + mode: 'b', + mask: mask, + duration: timedRemoval, + ); + case ChannelModerationAction.quiet: + final mask = _banIdentityForNickOrMask(bare, banMaskType).mask; + await _ircService.sendChannelMode( + channel: channel, + mode: '+q', + target: mask, + ); + _scheduleTimedModeRemoval( + channel: channel, + mode: 'q', + mask: mask, + duration: timedRemoval, + ); } } @@ -2708,6 +3039,42 @@ class ChatSessionController extends ChangeNotifier { case 'autolists': _handleAutoListCommand(); return; + case 'notify': + await _handleUserListCommand(UserListType.notify, rest, remove: false); + return; + case 'unnotify': + await _handleUserListCommand(UserListType.notify, rest, remove: true); + return; + case 'protect': + await _handleUserListCommand( + UserListType.protectedUser, + rest, + remove: false, + ); + return; + case 'unprotect': + await _handleUserListCommand( + UserListType.protectedUser, + rest, + remove: true, + ); + return; + case 'otherlist': + await _handleUserListCommand(UserListType.other, rest, remove: false); + return; + case 'unotherlist': + await _handleUserListCommand(UserListType.other, rest, remove: true); + return; + case 'blacklist': + await _handleBlacklistCommand(rest, remove: false); + return; + case 'unblacklist': + await _handleBlacklistCommand(rest, remove: true); + return; + case 'userlist': + case 'userlists': + _handleUserListReport(rest); + return; default: await _ircService.sendRaw(commandLine); return; @@ -2748,7 +3115,7 @@ class ChatSessionController extends ChangeNotifier { type: type, mask: mask, ).normalizedMask.toLowerCase(); - final matches = _autoModeEntries + final matches = autoModeEntries .where( (entry) => entry.type == type && @@ -2789,7 +3156,7 @@ class ChatSessionController extends ChangeNotifier { } void _handleAutoListCommand() { - if (_autoModeEntries.isEmpty) { + if (autoModeEntries.isEmpty) { _appendMessage( tabId: activeTab.id, sender: '*', @@ -2798,7 +3165,7 @@ class ChatSessionController extends ChangeNotifier { ); return; } - for (final entry in _autoModeEntries) { + for (final entry in autoModeEntries) { final scope = entry.channels.isEmpty ? 'all channels' : entry.channels.join(', '); @@ -2811,6 +3178,152 @@ class ChatSessionController extends ChangeNotifier { } } + Future _handleUserListCommand( + UserListType type, + String rest, { + required bool remove, + }) async { + final tokens = rest + .trim() + .split(RegExp(r'\s+')) + .where((token) => token.isNotEmpty) + .toList(); + final mask = tokens.isEmpty ? '' : tokens.first; + if (mask.isEmpty) { + _appendMessage( + tabId: activeTab.id, + sender: 'error', + content: 'Usage: /${remove ? 'un' : ''}${type.id} ', + kind: IrcMessageKind.error, + ); + notifyListeners(); + return; + } + + if (remove) { + final normalized = UserListEntry( + type: type, + mask: mask, + ).normalizedMask.toLowerCase(); + final matches = userListEntriesForType(type) + .where( + (entry) => + entry.normalizedMask.toLowerCase() == normalized && + (entry.network == null || entry.network == network.id), + ) + .toList(); + for (final entry in matches) { + await removeUserListEntry(entry); + } + _appendMessage( + tabId: activeTab.id, + sender: '*', + content: matches.isEmpty + ? '$mask was not on ${type.label}.' + : 'Removed $mask from ${type.label}.', + kind: IrcMessageKind.system, + ); + return; + } + + await addUserListEntry( + UserListEntry(type: type, mask: mask, network: network.id), + ); + _appendMessage( + tabId: activeTab.id, + sender: '*', + content: 'Added $mask to ${type.label}.', + kind: IrcMessageKind.system, + ); + } + + Future _handleBlacklistCommand( + String rest, { + required bool remove, + }) async { + final tokens = rest + .trim() + .split(RegExp(r'\s+')) + .where((token) => token.isNotEmpty) + .toList(); + if (tokens.isEmpty) { + _appendMessage( + tabId: activeTab.id, + sender: remove ? 'error' : '*', + content: remove + ? 'Usage: /unblacklist ' + : _formatUserListReport(UserListType.blacklist), + kind: remove ? IrcMessageKind.error : IrcMessageKind.system, + ); + notifyListeners(); + return; + } + final mask = tokens.first; + if (remove) { + await _handleUserListCommand(UserListType.blacklist, mask, remove: true); + return; + } + + final maybeAction = tokens.length > 1 + ? BlacklistAction.fromId(tokens[1].toLowerCase()) + : null; + final reasonStart = maybeAction == null ? 1 : 2; + final reason = tokens.length > reasonStart + ? tokens.skip(reasonStart).join(' ') + : null; + await addUserListEntry( + UserListEntry( + type: UserListType.blacklist, + mask: mask, + network: network.id, + blacklistAction: maybeAction ?? BlacklistAction.ignore, + reason: reason, + ), + ); + _appendMessage( + tabId: activeTab.id, + sender: '*', + content: + 'Added $mask to Blacklist (${(maybeAction ?? BlacklistAction.ignore).label}).', + kind: IrcMessageKind.system, + ); + } + + void _handleUserListReport(String rest) { + final requested = rest.trim(); + if (requested.isNotEmpty) { + final type = UserListType.fromId(requested); + _appendMessage( + tabId: activeTab.id, + sender: type == null ? 'error' : '*', + content: type == null + ? 'Unknown user list: $requested' + : _formatUserListReport(type), + kind: type == null ? IrcMessageKind.error : IrcMessageKind.system, + ); + notifyListeners(); + return; + } + + for (final type in UserListType.managementTypes) { + _appendMessage( + tabId: activeTab.id, + sender: '*', + content: _formatUserListReport(type), + kind: IrcMessageKind.system, + ); + } + notifyListeners(); + } + + String _formatUserListReport(UserListType type) { + final entries = userListEntriesForType(type); + if (entries.isEmpty) { + return '${type.label}: empty'; + } + return '${type.label}: ${entries.map((entry) => entry.mask).join(', ')}'; + } + void _handleHelpCommand(String rest) { final requested = rest.trim(); if (requested.isNotEmpty) { @@ -3091,6 +3604,173 @@ class ChatSessionController extends ChangeNotifier { return false; } + bool _enforceBlacklist(IrcMessageFrame frame, String target) { + if (blacklistEntries.isEmpty || _isSelfEcho(frame.senderNick)) { + return false; + } + final parsedPrefix = _parseHostmask(frame.prefix ?? ''); + final nick = frame.senderNick ?? parsedPrefix?.nick; + if (nick == null || nick.trim().isEmpty) { + return false; + } + final identity = _senderIdentity(frame); + final ident = identity.ident ?? parsedPrefix?.ident; + final host = identity.host ?? parsedPrefix?.host; + final channel = _isChannelName(target) ? target : ''; + + for (final entry in blacklistEntries) { + if (!entry.matches( + nick: nick, + ident: ident, + host: host, + channel: channel, + networkId: network.id, + )) { + continue; + } + _runBlacklistAction( + entry: entry, + nick: nick, + userhost: '${ident ?? '*'}@${host ?? '*'}', + hostmask: frame.prefix ?? '$nick!${ident ?? '*'}@${host ?? '*'}', + channel: channel, + ); + return true; + } + return false; + } + + void _runBlacklistAction({ + required UserListEntry entry, + required String nick, + required String userhost, + required String hostmask, + required String channel, + }) { + final action = entry.effectiveBlacklistAction; + final key = '${entry.key}|$channel|${nick.toLowerCase()}|${action.id}'; + final firstHit = _blacklistEnforcements.add(key); + if (!firstHit) { + return; + } + + final targetTabId = channel.isEmpty + ? _serverTabId(network.id) + : _ensureChannelTab(channel).id; + final reason = (entry.reason ?? 'Blacklisted').trim(); + final mask = entry.normalizedMask; + + switch (action) { + case BlacklistAction.ignore: + break; + case BlacklistAction.ban: + if (channel.isNotEmpty) { + unawaited( + _ircService.sendChannelMode( + channel: channel, + mode: '+b', + target: mask, + ), + ); + _scheduleTimedModeRemoval( + channel: channel, + mode: 'b', + mask: mask, + duration: entry.duration, + ); + } + case BlacklistAction.kickBan: + if (channel.isNotEmpty) { + unawaited( + _ircService.sendChannelMode( + channel: channel, + mode: '+b', + target: mask, + ), + ); + unawaited( + _ircService.sendKick(channel: channel, nick: nick, reason: reason), + ); + _scheduleTimedModeRemoval( + channel: channel, + mode: 'b', + mask: mask, + duration: entry.duration, + ); + } + case BlacklistAction.quiet: + if (channel.isNotEmpty) { + unawaited( + _ircService.sendChannelMode( + channel: channel, + mode: '+q', + target: mask, + ), + ); + _scheduleTimedModeRemoval( + channel: channel, + mode: 'q', + mask: mask, + duration: entry.duration, + ); + } + case BlacklistAction.custom: + final raw = _formatBlacklistRawTemplate( + entry.customRaw, + nick: nick, + userhost: userhost, + hostmask: hostmask, + mask: mask, + channel: channel, + reason: reason, + duration: entry.duration, + ); + if (raw != null) { + unawaited(_ircService.sendRaw(raw)); + } + } + + _appendMessage( + tabId: targetTabId, + sender: '*', + content: + 'Blacklist ${action.label.toLowerCase()} matched $nick ($mask)${reason.isEmpty ? '' : ': $reason'}', + kind: IrcMessageKind.system, + ); + } + + String? _formatBlacklistRawTemplate( + String? template, { + required String nick, + required String userhost, + required String hostmask, + required String mask, + required String channel, + required String reason, + Duration? duration, + }) { + var raw = (template ?? '').trim(); + if (raw.isEmpty) { + return null; + } + final replacements = { + '{nick}': nick, + '{user}': userhost.split('@').first, + '{host}': userhost.contains('@') ? userhost.split('@').last : '', + '{userhost}': userhost, + '{hostmask}': hostmask, + '{mask}': mask, + '{usermask}': mask, + '{channel}': channel, + '{reason}': reason, + '{duration}': duration == null ? '' : '${duration.inMinutes}m', + }; + for (final entry in replacements.entries) { + raw = raw.replaceAll(entry.key, entry.value); + } + return raw; + } + static bool _matchesIgnoreMask(String mask, {String? prefix, String? nick}) { final hasHostMask = mask.contains('!') || mask.contains('@'); final target = hasHostMask ? (prefix ?? nick) : (nick ?? prefix); @@ -3556,6 +4236,14 @@ class ChatSessionController extends ChangeNotifier { kind: IrcMessageKind.system, ); case '311': + if (frame.params.length > 3) { + _rememberNickState( + frame.params[1], + ident: frame.params[2], + host: frame.params[3], + realName: frame.trailing, + ); + } _appendWhoisMessage( frame, 'WHOIS: ${frame.params.length > 3 ? '${frame.params[1]} is ${frame.params[2]}@${frame.params[3]}' : frame.raw}', @@ -3575,11 +4263,23 @@ class ChatSessionController extends ChangeNotifier { kind: IrcMessageKind.system, ); case '312': + if (frame.params.length > 2) { + _mergeUserInfo( + frame.params[1], + (info) => info.copyWith( + server: frame.params[2], + serverInfo: frame.trailing, + ), + ); + } _appendWhoisMessage( frame, 'WHOIS server: ${frame.params.length > 2 ? '${frame.params[1]} on ${frame.params[2]} ${frame.trailing ?? ''}'.trim() : frame.raw}', ); case '301': + if (frame.params.length > 1) { + _rememberNickState(frame.params[1], awayMessage: frame.trailing); + } _appendWhoisMessage( frame, frame.trailing == null @@ -3588,12 +4288,56 @@ class ChatSessionController extends ChangeNotifier { .trim(), ); case '307': + if (frame.params.length > 1) { + _mergeUserInfo( + frame.params[1], + (info) => info.copyWith(isRegistered: true), + ); + } + _appendWhoisMessage( + frame, + frame.trailing ?? frame.params.skip(1).join(' '), + ); case '313': + if (frame.params.length > 1) { + _mergeUserInfo( + frame.params[1], + (info) => info.copyWith(isOper: true), + ); + } + _appendWhoisMessage( + frame, + frame.trailing ?? frame.params.skip(1).join(' '), + ); case '330': + if (frame.params.length > 2) { + _rememberNickState(frame.params[1], account: frame.params[2]); + _mergeUserInfo( + frame.params[1], + (info) => info.copyWith(isRegistered: true), + ); + } + _appendWhoisMessage( + frame, + frame.trailing ?? frame.params.skip(1).join(' '), + ); case '338': case '378': case '379': + if (frame.params.length > 1) { + _appendUserInfoExtra(frame.params[1], frame.trailing); + } + _appendWhoisMessage( + frame, + frame.trailing ?? frame.params.skip(1).join(' '), + ); case '671': + if (frame.params.length > 1) { + _mergeUserInfo( + frame.params[1], + (info) => info.copyWith(isSecure: true), + ); + } _appendWhoisMessage( frame, frame.trailing ?? frame.params.skip(1).join(' '), @@ -3608,6 +4352,24 @@ class ChatSessionController extends ChangeNotifier { case '769': _handleMetadataFrame(frame); case '317': + if (frame.params.length > 2) { + final idle = int.tryParse(frame.params[2]); + final signedOnSeconds = frame.params.length > 3 + ? int.tryParse(frame.params[3]) + : null; + _mergeUserInfo( + frame.params[1], + (info) => info.copyWith( + idleSeconds: idle, + signedOn: signedOnSeconds == null + ? null + : DateTime.fromMillisecondsSinceEpoch( + signedOnSeconds * 1000, + isUtc: true, + ).toLocal(), + ), + ); + } _appendWhoisMessage( frame, 'WHOIS idle: ${frame.params.length > 2 ? '${frame.params[1]} idle ${frame.params[2]}s' : frame.raw}', @@ -3621,6 +4383,14 @@ class ChatSessionController extends ChangeNotifier { kind: IrcMessageKind.system, ); case '319': + if (frame.params.length > 1) { + _mergeUserInfo( + frame.params[1], + (info) => info.copyWith( + channels: _parseWhoisChannels(frame.trailing ?? ''), + ), + ); + } _appendWhoisMessage( frame, 'WHOIS channels: ${frame.params.length > 1 ? '${frame.params[1]} ${frame.trailing ?? ''}'.trim() : frame.raw}', @@ -3632,6 +4402,18 @@ class ChatSessionController extends ChangeNotifier { .trim(), ); case '314': + if (frame.params.length > 3) { + _rememberNickState( + frame.params[1], + ident: frame.params[2], + host: frame.params[3], + realName: frame.trailing, + ); + _mergeUserInfo( + frame.params[1], + (info) => info.copyWith(fromWhowas: true), + ); + } _appendWhoisMessage( frame, 'WHOWAS: ${frame.params.length > 3 ? '${frame.params[1]} was ${frame.params[2]}@${frame.params[3]}' : frame.raw}', @@ -4059,8 +4841,12 @@ class ChatSessionController extends ChangeNotifier { return; } - _rememberFrameSenderState(frame); final contextualTarget = _messageContextTarget(target, frame.tags); + if (_enforceBlacklist(frame, contextualTarget)) { + return; + } + + _rememberFrameSenderState(frame); _rememberContextualChannelUser(contextualTarget, frame.senderNick); final ctcp = parseCtcp(content); @@ -4098,8 +4884,12 @@ class ChatSessionController extends ChangeNotifier { return; } - _rememberFrameSenderState(frame); final contextualTarget = _messageContextTarget(target, frame.tags); + if (_enforceBlacklist(frame, contextualTarget)) { + return; + } + + _rememberFrameSenderState(frame); _rememberContextualChannelUser(contextualTarget, frame.senderNick); final intentTag = frame.tags['draft/intent']?.toUpperCase(); @@ -5043,7 +5833,7 @@ class ChatSessionController extends ChangeNotifier { await _ircService.sendCtcpReply( target: from, command: 'VERSION', - args: _ctcpVersionReply, + args: ctcpVersionReply, ); case 'TIME': await _ircService.sendCtcpReply( @@ -5937,6 +6727,43 @@ class ChatSessionController extends ChangeNotifier { return values.first; } + void _mergeUserInfo(String nick, IrcUserInfo Function(IrcUserInfo) update) { + final normalized = _stripModePrefix(nick).trim(); + final key = normalized.toLowerCase(); + if (key.isEmpty) { + return; + } + _userInfoByNick[key] = update(userInfoForNick(normalized)); + } + + void _appendUserInfoExtra(String nick, String? value) { + final text = (value ?? '').trim(); + if (text.isEmpty) { + return; + } + _mergeUserInfo(nick, (info) { + if (info.extra.contains(text)) { + return info; + } + return info.copyWith(extra: [...info.extra, text]); + }); + } + + List _parseWhoisChannels(String value) { + final channels = []; + for (final raw in value.split(RegExp(r'\s+'))) { + var token = raw.trim(); + while (token.isNotEmpty && !_isChannelName(token)) { + token = token.substring(1); + } + if (token.isNotEmpty && _isChannelName(token)) { + channels.add(token); + } + } + channels.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return channels; + } + void _appendWhoisMessage(IrcMessageFrame frame, String content) { final nick = frame.params.length > 1 ? frame.params[1] : null; final targetTabId = nick == null @@ -6272,6 +7099,83 @@ class ChatSessionController extends ChangeNotifier { return value.contains('!') || value.contains('@') ? value : '$value!*@*'; } + ({String nick, String mask}) _banIdentityForNickOrMask( + String value, + int banMaskType, + ) { + final trimmed = _stripModePrefix(value).trim(); + final parsed = _parseHostmask(trimmed); + if (parsed != null) { + return ( + nick: parsed.nick, + mask: const BanMaskService().generateBanMask( + nick: parsed.nick, + ident: parsed.ident, + host: parsed.host, + type: banMaskType, + ), + ); + } + + final info = userInfoForNick(trimmed); + final ident = (info.ident ?? '').trim(); + final host = (info.host ?? '').trim(); + final effectiveType = ident.isEmpty || host.isEmpty ? 10 : banMaskType; + return ( + nick: trimmed, + mask: const BanMaskService().generateBanMask( + nick: trimmed, + ident: ident.isEmpty ? '*' : ident, + host: host.isEmpty ? '*' : host, + type: effectiveType, + ), + ); + } + + ({String nick, String ident, String host})? _parseHostmask(String value) { + final bang = value.indexOf('!'); + final at = value.indexOf('@'); + if (bang <= 0 || at <= bang + 1 || at >= value.length - 1) { + return null; + } + return ( + nick: value.substring(0, bang), + ident: value.substring(bang + 1, at), + host: value.substring(at + 1), + ); + } + + void _scheduleTimedModeRemoval({ + required String channel, + required String mode, + required String mask, + Duration? duration, + }) { + if (duration == null || duration <= Duration.zero) { + return; + } + late final Timer timer; + timer = Timer(duration, () { + _timedUnbanTimers.remove(timer); + unawaited( + _ircService.sendChannelMode( + channel: channel, + mode: '-$mode', + target: mask, + ), + ); + _appendMessage( + tabId: _ensureChannelTab(channel).id, + sender: '*', + content: 'Timed ${mode == 'q' ? 'quiet' : 'ban'} removed: $mask', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + }); + _timedUnbanTimers.add(timer); + } + bool _isChannelName(String value) { if (value.isEmpty) { return false; @@ -6666,6 +7570,10 @@ class ChatSessionController extends ChangeNotifier { timer.cancel(); } _commandTimers.clear(); + for (final timer in _timedUnbanTimers) { + timer.cancel(); + } + _timedUnbanTimers.clear(); _autoAwayTimer?.cancel(); _lagTimer?.cancel(); for (final subscription in _subscriptions) { diff --git a/lib/features/chat/application/command_service.dart b/lib/features/chat/application/command_service.dart index a609578..ade957c 100644 --- a/lib/features/chat/application/command_service.dart +++ b/lib/features/chat/application/command_service.dart @@ -743,6 +743,49 @@ class CommandService { description: 'List configured auto-mode rules', kind: CommandKind.local, ), + CommandDefinition( + name: 'notify', + usage: '/notify ', + description: 'Add a nick to the notify/watch list', + kind: CommandKind.utility, + ), + CommandDefinition( + name: 'unnotify', + usage: '/unnotify ', + description: 'Remove a nick from the notify/watch list', + kind: CommandKind.utility, + ), + CommandDefinition( + name: 'protect', + usage: '/protect ', + description: 'Add a user to the protected list', + kind: CommandKind.utility, + ), + CommandDefinition( + name: 'unprotect', + usage: '/unprotect ', + description: 'Remove a user from the protected list', + kind: CommandKind.utility, + ), + CommandDefinition( + name: 'blacklist', + usage: + '/blacklist [ignore|ban|kick_ban|quiet|custom] [reason]', + description: 'Add a blacklist rule and optional enforcement action', + kind: CommandKind.utility, + ), + CommandDefinition( + name: 'unblacklist', + usage: '/unblacklist ', + description: 'Remove a blacklist rule', + kind: CommandKind.utility, + ), + CommandDefinition( + name: 'userlist', + usage: '/userlist [notify|protected|other|blacklist|autoop|autovoice]', + description: 'List user-list entries', + kind: CommandKind.local, + ), ]; static final Map _commandRegistry = { diff --git a/lib/features/chat/data/user_list_entry.dart b/lib/features/chat/data/user_list_entry.dart index 3000bac..f73195d 100644 --- a/lib/features/chat/data/user_list_entry.dart +++ b/lib/features/chat/data/user_list_entry.dart @@ -1,23 +1,48 @@ import 'dart:convert'; -/// The kinds of automatic-mode user lists. Each grants a channel mode to a -/// matching user when they join a channel where we hold the needed privilege. +/// The kinds of user lists supported by the IRC client. +/// +/// Auto-mode entries grant channel modes on join. Notify/protected/other are +/// local watch lists. Blacklist entries may also carry an enforcement action. enum UserListType { autoOp('autoop', 'o', 'Auto-op'), autoHalfOp('autohalfop', 'h', 'Auto-halfop'), - autoVoice('autovoice', 'v', 'Auto-voice'); + autoVoice('autovoice', 'v', 'Auto-voice'), + notify('notify', null, 'Notify'), + protectedUser('protected', null, 'Protected'), + other('other', null, 'Other'), + blacklist('blacklist', null, 'Blacklist'); const UserListType(this.id, this.modeChar, this.label); /// Stable storage id. final String id; - /// The channel mode letter this list grants (`o`, `h`, `v`). - final String modeChar; + /// The channel mode letter this list grants (`o`, `h`, `v`) for auto-mode + /// entries. Null for local/watch-list-only entries. + final String? modeChar; /// Human-readable label. final String label; + bool get isAutoMode => modeChar != null; + + static const List autoModeTypes = [ + autoOp, + autoHalfOp, + autoVoice, + ]; + + static const List managementTypes = [ + autoOp, + autoHalfOp, + autoVoice, + notify, + protectedUser, + other, + blacklist, + ]; + static UserListType? fromId(String id) { for (final type in UserListType.values) { if (type.id == id) { @@ -28,6 +53,28 @@ enum UserListType { } } +enum BlacklistAction { + ignore('ignore', 'Ignore'), + ban('ban', 'Ban'), + kickBan('kick_ban', 'Kick + ban'), + quiet('quiet', 'Quiet'), + custom('custom', 'Custom raw'); + + const BlacklistAction(this.id, this.label); + + final String id; + final String label; + + static BlacklistAction? fromId(String id) { + for (final action in BlacklistAction.values) { + if (action.id == id) { + return action; + } + } + return null; + } +} + /// A single automatic-mode rule: grant [type]'s mode to users matching [mask] /// in the given [channels] (empty = all channels) on the given [network] /// (null = all networks). @@ -37,6 +84,10 @@ class UserListEntry { required this.mask, this.channels = const [], this.network, + this.blacklistAction, + this.reason, + this.duration, + this.customRaw, }); final UserListType type; @@ -51,6 +102,25 @@ class UserListEntry { /// Network id this rule applies to; null means all networks. final String? network; + /// Enforcement action for [UserListType.blacklist]. Defaults to ignore when + /// absent. + final BlacklistAction? blacklistAction; + + /// Optional reason used for blacklist and kick/ban flows. + final String? reason; + + /// Optional action duration. For moderation actions this is used as timed + /// unban/unquiet duration; for custom raw it feeds the `{duration}` token. + final Duration? duration; + + /// Custom raw IRC template for [BlacklistAction.custom]. + final String? customRaw; + + BlacklistAction get effectiveBlacklistAction => + blacklistAction ?? BlacklistAction.ignore; + + bool get isBlacklist => type == UserListType.blacklist; + /// Normalizes [mask] to full `nick!user@host` form for matching. String get normalizedMask { final trimmed = mask.trim(); @@ -95,13 +165,27 @@ class UserListEntry { String? mask, List? channels, String? network, + BlacklistAction? blacklistAction, + String? reason, + Duration? duration, + String? customRaw, bool clearNetwork = false, + bool clearBlacklistAction = false, + bool clearReason = false, + bool clearDuration = false, + bool clearCustomRaw = false, }) { return UserListEntry( type: type ?? this.type, mask: mask ?? this.mask, channels: channels ?? this.channels, network: clearNetwork ? null : (network ?? this.network), + blacklistAction: clearBlacklistAction + ? null + : (blacklistAction ?? this.blacklistAction), + reason: clearReason ? null : (reason ?? this.reason), + duration: clearDuration ? null : (duration ?? this.duration), + customRaw: clearCustomRaw ? null : (customRaw ?? this.customRaw), ); } @@ -110,6 +194,11 @@ class UserListEntry { 'mask': mask, if (channels.isNotEmpty) 'channels': channels, if (network != null) 'network': network, + if (blacklistAction != null) 'blacklistAction': blacklistAction!.id, + if ((reason ?? '').trim().isNotEmpty) 'reason': reason!.trim(), + if (duration != null && duration!.inMinutes > 0) + 'durationMinutes': duration!.inMinutes, + if ((customRaw ?? '').trim().isNotEmpty) 'customRaw': customRaw!.trim(), }; static UserListEntry? fromJson(Map json) { @@ -122,6 +211,7 @@ class UserListEntry { final channels = rawChannels is List ? rawChannels.map((e) => '$e').where((e) => e.isNotEmpty).toList() : const []; + final durationMinutes = (json['durationMinutes'] as num?)?.toInt(); return UserListEntry( type: type, mask: mask, @@ -129,6 +219,14 @@ class UserListEntry { network: (json['network'] as String?)?.trim().isEmpty ?? true ? null : (json['network'] as String).trim(), + blacklistAction: BlacklistAction.fromId( + '${json['blacklistAction'] ?? json['action'] ?? ''}', + ), + reason: (json['reason'] as String?)?.trim(), + duration: durationMinutes == null || durationMinutes <= 0 + ? null + : Duration(minutes: durationMinutes), + customRaw: (json['customRaw'] as String?)?.trim(), ); } diff --git a/lib/features/chat/presentation/auto_mode_lists_screen.dart b/lib/features/chat/presentation/auto_mode_lists_screen.dart index c1c867b..9c1c83e 100644 --- a/lib/features/chat/presentation/auto_mode_lists_screen.dart +++ b/lib/features/chat/presentation/auto_mode_lists_screen.dart @@ -75,6 +75,7 @@ class AutoModeListsScreen extends StatelessWidget { UserListType.autoOp => Icons.shield_moon_outlined, UserListType.autoHalfOp => Icons.shield_outlined, UserListType.autoVoice => Icons.record_voice_over_outlined, + _ => Icons.rule_outlined, }; } @@ -134,7 +135,7 @@ class _AutoModeEntryDialogState extends State<_AutoModeEntryDialog> { initialValue: _type, decoration: const InputDecoration(labelText: 'Mode'), items: [ - for (final type in UserListType.values) + for (final type in UserListType.autoModeTypes) DropdownMenuItem(value: type, child: Text(type.label)), ], onChanged: (value) => setState(() => _type = value ?? _type), diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index 2e9a7a8..337be13 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -7,6 +7,7 @@ import 'package:androidircx/core/models/dcc_session.dart'; import 'package:androidircx/core/models/irc_message.dart'; import 'package:androidircx/core/models/network_config.dart'; import 'package:androidircx/dcc/services/dcc_file_picker.dart'; +import 'package:androidircx/features/chat/application/ban_mask_service.dart'; import 'package:androidircx/features/chat/application/command_service.dart'; import 'package:androidircx/features/chat/application/chat_session_controller.dart'; import 'package:androidircx/features/chat/application/session_registry.dart'; @@ -14,13 +15,14 @@ import 'package:androidircx/features/chat/data/channel_notes_repository.dart'; import 'package:androidircx/features/chat/data/user_list_entry.dart'; import 'package:androidircx/features/chat/data/user_notes_repository.dart'; import 'package:androidircx/features/connections/application/network_list_controller.dart'; -import 'package:androidircx/features/chat/presentation/auto_mode_lists_screen.dart'; import 'package:androidircx/features/chat/presentation/channel_list_screen.dart'; import 'package:androidircx/features/chat/presentation/connection_details_screen.dart'; import 'package:androidircx/features/chat/presentation/media_player_screen.dart'; import 'package:androidircx/features/chat/presentation/ignore_list_screen.dart'; import 'package:androidircx/features/chat/presentation/join_channel_dialog.dart'; +import 'package:androidircx/features/chat/presentation/user_lists_screen.dart'; import 'package:androidircx/irc/parser/irc_formatter.dart'; +import 'package:androidircx/irc/parser/interactive_message_parser.dart'; import 'package:androidircx/irc/parser/message_content_parser.dart'; import 'package:androidircx/features/settings/presentation/settings_screen.dart'; import 'package:androidircx/media/services/link_preview_service.dart'; @@ -122,395 +124,436 @@ class _ChatScreenState extends State { _controller.selectPreviousTab, }, child: AnimatedBuilder( - animation: Listenable.merge([ - _controller, - if (widget.sessionRegistry != null) widget.sessionRegistry!, - if (widget.networkController != null) widget.networkController!, - ]), - builder: (context, _) { - final baseMessages = _messageSearchVisible - ? _controller.messagesForTab( - _controller.activeTabId, - query: _messageSearchController.text, - kinds: _messageSearchFilter.kinds, - ) - : _controller.activeMessages; - final visibleMessages = _controller.settings.hideJoinPartQuit - ? baseMessages - .where((message) => message.kind != IrcMessageKind.event) - .toList(growable: false) - : baseMessages; - return Scaffold( - appBar: AppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(_controller.activeTab.name), - Text( - _controller.activeTab.type == ChatTabType.channel && - _controller.activeChannelSummary.isNotEmpty - ? _controller.activeChannelSummary - : _controller.activeTab.type == ChatTabType.dcc && - _controller.activeDccSession != null - ? _dccSummary(_controller.activeDccSession!) - : _statusText(_controller.connection), - style: Theme.of(context).textTheme.bodySmall, + animation: Listenable.merge([ + _controller, + if (widget.sessionRegistry != null) widget.sessionRegistry!, + if (widget.networkController != null) widget.networkController!, + ]), + builder: (context, _) { + final baseMessages = _messageSearchVisible + ? _controller.messagesForTab( + _controller.activeTabId, + query: _messageSearchController.text, + kinds: _messageSearchFilter.kinds, + ) + : _controller.activeMessages; + final visibleMessages = _controller.settings.hideJoinPartQuit + ? baseMessages + .where((message) => message.kind != IrcMessageKind.event) + .toList(growable: false) + : baseMessages; + return Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_controller.activeTab.name), + Text( + _controller.activeTab.type == ChatTabType.channel && + _controller.activeChannelSummary.isNotEmpty + ? _controller.activeChannelSummary + : _controller.activeTab.type == ChatTabType.dcc && + _controller.activeDccSession != null + ? _dccSummary(_controller.activeDccSession!) + : _statusText(_controller.connection), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + actions: [ + if (_controller.activeTab.type == ChatTabType.channel) + Builder( + builder: (context) { + return IconButton( + onPressed: () => Scaffold.of(context).openEndDrawer(), + icon: const Icon(Icons.people_outline), + tooltip: 'Nick list', + ); + }, + ), + if (_controller.settings.showHeaderSearchButton) + IconButton( + onPressed: _toggleMessageSearch, + icon: Icon( + _messageSearchVisible ? Icons.search_off : Icons.search, + ), + tooltip: _messageSearchVisible + ? 'Close search' + : 'Search messages', + ), + IconButton( + onPressed: _openHistoryTools, + icon: const Icon(Icons.history), + tooltip: 'History tools', ), - ], - ), - actions: [ - if (_controller.activeTab.type == ChatTabType.channel) - Builder( - builder: (context) { - return IconButton( - onPressed: () => Scaffold.of(context).openEndDrawer(), - icon: const Icon(Icons.people_outline), - tooltip: 'Nick list', - ); - }, + IconButton( + onPressed: _showJoinDialog, + icon: const Icon(Icons.tag), + tooltip: 'Join channel', ), - if (_controller.settings.showHeaderSearchButton) IconButton( - onPressed: _toggleMessageSearch, - icon: Icon( - _messageSearchVisible ? Icons.search_off : Icons.search, - ), - tooltip: _messageSearchVisible - ? 'Close search' - : 'Search messages', + onPressed: _openChannelList, + icon: const Icon(Icons.format_list_bulleted), + tooltip: 'Channel list', ), - IconButton( - onPressed: _openHistoryTools, - icon: const Icon(Icons.history), - tooltip: 'History tools', - ), - IconButton( - onPressed: _showJoinDialog, - icon: const Icon(Icons.tag), - tooltip: 'Join channel', - ), - IconButton( - onPressed: _openChannelList, - icon: const Icon(Icons.format_list_bulleted), - tooltip: 'Channel list', - ), - IconButton( - onPressed: _openSettings, - icon: const Icon(Icons.tune), - tooltip: 'Settings', - ), - IconButton( - onPressed: - _controller.connection.phase == ConnectionPhase.connected - ? _controller.disconnect - : _controller.start, - icon: Icon( - _controller.connection.phase == ConnectionPhase.connected - ? Icons.link_off - : Icons.wifi_tethering, + IconButton( + onPressed: _openSettings, + icon: const Icon(Icons.tune), + tooltip: 'Settings', ), - tooltip: + IconButton( + onPressed: + _controller.connection.phase == ConnectionPhase.connected + ? _controller.disconnect + : _controller.start, + icon: Icon( _controller.connection.phase == ConnectionPhase.connected - ? 'Disconnect' - : 'Connect', - ), - ], - ), - drawer: Drawer( - child: SafeArea( - child: ListView( - padding: EdgeInsets.zero, - children: [ - if (widget.onSwitchNetwork != null && - widget.networkController != null) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), - child: Text( - 'NETWORKS', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.bold, - letterSpacing: 0.8, + ? Icons.link_off + : Icons.wifi_tethering, + ), + tooltip: + _controller.connection.phase == ConnectionPhase.connected + ? 'Disconnect' + : 'Connect', + ), + ], + ), + drawer: Drawer( + child: SafeArea( + child: ListView( + padding: EdgeInsets.zero, + children: [ + if (widget.onSwitchNetwork != null && + widget.networkController != null) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), + child: Text( + 'NETWORKS', + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 0.8, + ), ), ), + for (final network in widget.networkController!.networks) + _buildNetworkSwitchTile(network), + ListTile( + leading: const Icon(Icons.dns_outlined), + title: const Text('Manage networks'), + subtitle: const Text('Add, edit, or browse servers'), + onTap: () { + Navigator.of(context).pop(); + widget.onManageNetworks?.call(); + }, + ), + const Divider(height: 1), + ], + ListTile( + title: Text(_controller.network.name), + subtitle: Text( + '${_controller.network.host}:${_controller.network.port}', + ), ), - for (final network in widget.networkController!.networks) - _buildNetworkSwitchTile(network), + const Divider(height: 1), + for (final tab in _controller.tabs) + _buildTabTile(context, tab), + const Divider(height: 1), ListTile( - leading: const Icon(Icons.dns_outlined), - title: const Text('Manage networks'), - subtitle: const Text('Add, edit, or browse servers'), + leading: const Icon(Icons.block), + title: const Text('Ignore list'), onTap: () { Navigator.of(context).pop(); - widget.onManageNetworks?.call(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + IgnoreListScreen(controller: _controller), + ), + ); }, ), - const Divider(height: 1), - ], - ListTile( - title: Text(_controller.network.name), - subtitle: Text( - '${_controller.network.host}:${_controller.network.port}', + ListTile( + leading: const Icon(Icons.rule_outlined), + title: const Text('User lists'), + subtitle: const Text( + 'Notify, protected, blacklist, auto-mode', + ), + onTap: () { + Navigator.of(context).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + UserListsScreen(controller: _controller), + ), + ); + }, ), - ), - const Divider(height: 1), - for (final tab in _controller.tabs) - _buildTabTile(context, tab), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.block), - title: const Text('Ignore list'), - onTap: () { - Navigator.of(context).pop(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - IgnoreListScreen(controller: _controller), - ), - ); - }, - ), - ListTile( - leading: const Icon(Icons.rule_outlined), - title: const Text('Auto-mode lists'), - subtitle: const Text('Auto-op, auto-halfop, auto-voice'), - onTap: () { - Navigator.of(context).pop(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - AutoModeListsScreen(controller: _controller), - ), - ); - }, - ), - ListTile( - leading: const Icon(Icons.info_outline), - title: const Text('Connection details'), - onTap: () { - Navigator.of(context).pop(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - ConnectionDetailsScreen(controller: _controller), - ), - ); - }, - ), - ], + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('Connection details'), + onTap: () { + Navigator.of(context).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ConnectionDetailsScreen( + controller: _controller, + ), + ), + ); + }, + ), + ], + ), ), ), - ), - endDrawer: _controller.activeTab.type == ChatTabType.channel - ? Drawer( - child: SafeArea( - child: Column( - children: [ - ListTile( - title: Text(_controller.activeTab.name), - subtitle: Text( - '${_controller.activeChannelUsers.length} users', + endDrawer: _controller.activeTab.type == ChatTabType.channel + ? Drawer( + child: SafeArea( + child: Column( + children: [ + ListTile( + title: Text(_controller.activeTab.name), + subtitle: Text( + '${_controller.activeChannelUsers.length} users', + ), + ), + const Divider(height: 1), + Expanded( + child: _controller.activeChannelUsers.isEmpty + ? const Center(child: Text('No nick list yet.')) + : ListView.builder( + itemCount: _controller + .activeChannelUserDetails + .length, + itemBuilder: (context, index) { + final entry = _controller + .activeChannelUserDetails[index]; + final nick = entry.nick; + return ListTile( + leading: const Icon( + Icons.person_outline, + ), + title: Text(nick), + subtitle: entry.details.isEmpty + ? null + : Text(entry.details), + onTap: () { + Navigator.of(context).pop(); + unawaited( + _showChannelUserActions(nick), + ); + }, + ); + }, + ), ), + ], + ), + ), + ) + : null, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + // In short viewports (landscape, or portrait with the keyboard + // open) the chrome around the message list (status/topic + // banners on top, composer + its bars on the bottom) can be + // taller than the available height and would crush the list to + // zero. When space is tight, cap the top/bottom chrome and let + // each scroll internally so the message list always keeps room + // and nothing overflows. In normal viewports the caps are the + // full height (a no-op), so interactive banners such as the DCC + // accept/decline actions stay at their natural, hittable size. + final tight = constraints.maxHeight < 380; + final topChromeMaxHeight = tight + ? constraints.maxHeight * 0.35 + : constraints.maxHeight; + final bottomClusterMaxHeight = tight + ? constraints.maxHeight * 0.5 + : constraints.maxHeight; + return Column( + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: topChromeMaxHeight, ), - const Divider(height: 1), - Expanded( - child: _controller.activeChannelUsers.isEmpty - ? const Center(child: Text('No nick list yet.')) - : ListView.builder( - itemCount: _controller - .activeChannelUserDetails - .length, - itemBuilder: (context, index) { - final entry = _controller - .activeChannelUserDetails[index]; - final nick = entry.nick; - return ListTile( - leading: const Icon(Icons.person_outline), - title: Text(nick), - subtitle: entry.details.isEmpty - ? null - : Text(entry.details), - onTap: () { - Navigator.of(context).pop(); - unawaited(_showChannelUserActions(nick)); - }, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ConnectionBanner( + controller: _controller, + network: _controller.network, + ), + if (_messageSearchVisible) + _InlineMessageSearchBar( + controller: _messageSearchController, + filter: _messageSearchFilter, + resultCount: visibleMessages.length, + onFilterChanged: (filter) => setState( + () => _messageSearchFilter = filter, + ), + onChanged: (_) => setState(() {}), + onClose: _toggleMessageSearch, + ), + if ((_controller.activeChannelTopic ?? '') + .trim() + .isNotEmpty) + _ChannelTopicBar( + topic: _controller.activeChannelTopic!.trim(), + ), + if (_controller.activeTab.type == + ChatTabType.dcc && + _controller.activeDccSession != null) + _DccSessionBanner( + session: _controller.activeDccSession!, + onAccept: _controller.acceptActiveDccSession, + onDecline: + _controller.declineActiveDccSession, + onClose: _controller.closeActiveDccSession, + ), + if (_controller.activeTab.type == + ChatTabType.server) + _ServiceQuickActions( + onRun: (service, command) async { + await _controller.sendServiceShortcut( + service, + command, ); }, ), - ), - ], - ), - ), - ) - : null, - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - // In short viewports (landscape, or portrait with the keyboard - // open) the chrome around the message list (status/topic - // banners on top, composer + its bars on the bottom) can be - // taller than the available height and would crush the list to - // zero. When space is tight, cap the top/bottom chrome and let - // each scroll internally so the message list always keeps room - // and nothing overflows. In normal viewports the caps are the - // full height (a no-op), so interactive banners such as the DCC - // accept/decline actions stay at their natural, hittable size. - final tight = constraints.maxHeight < 380; - final topChromeMaxHeight = - tight ? constraints.maxHeight * 0.35 : constraints.maxHeight; - final bottomClusterMaxHeight = - tight ? constraints.maxHeight * 0.5 : constraints.maxHeight; - return Column( - children: [ - ConstrainedBox( - constraints: BoxConstraints(maxHeight: topChromeMaxHeight), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _ConnectionBanner( - controller: _controller, - network: _controller.network, - ), - if (_messageSearchVisible) - _InlineMessageSearchBar( - controller: _messageSearchController, - filter: _messageSearchFilter, - resultCount: visibleMessages.length, - onFilterChanged: (filter) => setState( - () => _messageSearchFilter = filter, - ), - onChanged: (_) => setState(() {}), - onClose: _toggleMessageSearch, - ), - if ((_controller.activeChannelTopic ?? '') - .trim() - .isNotEmpty) - _ChannelTopicBar( - topic: _controller.activeChannelTopic!.trim(), + ], ), - if (_controller.activeTab.type == ChatTabType.dcc && - _controller.activeDccSession != null) - _DccSessionBanner( - session: _controller.activeDccSession!, - onAccept: _controller.acceptActiveDccSession, - onDecline: _controller.declineActiveDccSession, - onClose: _controller.closeActiveDccSession, - ), - if (_controller.activeTab.type == ChatTabType.server) - _ServiceQuickActions( - onRun: (service, command) async { - await _controller.sendServiceShortcut( - service, - command, - ); - }, + ), + ), + Expanded( + child: _MessageList( + messages: visibleMessages, + knownNicks: { + ..._controller.activeChannelUsers, + _controller.currentNick, + }, + channelPrefixes: _controller.channelPrefixChars, + nickPrefixes: _controller.nickPrefixChars, + showAttachmentPreviews: + _controller.settings.showAttachmentPreviews, + resolveReplyTarget: (replyId) => _controller + .messageByMsgId(_controller.activeTabId, replyId), + resolveReactions: _controller.reactionsForMessage, + onRedactMessage: _controller.redactMessage, + onQuoteMessage: (message) => _insertIntoComposer( + '> ${stripIrcFormatting(message.content)}', ), - ], - ), - ), - ), - Expanded( - child: _MessageList( - messages: visibleMessages, - showAttachmentPreviews: - _controller.settings.showAttachmentPreviews, - resolveReplyTarget: (replyId) => _controller.messageByMsgId( - _controller.activeTabId, - replyId, - ), - resolveReactions: _controller.reactionsForMessage, - onRedactMessage: _controller.redactMessage, - onQuoteMessage: (message) => _insertIntoComposer( - '> ${stripIrcFormatting(message.content)}', - ), - onReplyWithNick: (message) { - final prefix = message.sender == _controller.currentNick - ? '' - : '${message.sender}: '; - _insertIntoComposer(prefix); - }, - onReplyToMessage: _setPendingReply, - onDownloadAttachment: _downloadAttachment, - showTimestamps: _controller.settings.showTimestamps, - onLoadOlder: - _controller.hasPersistentHistory && !_messageSearchVisible - ? () async { - await _controller.loadOlderHistory( - _controller.activeTabId, - ); - } - : null, - ), - ), - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: bottomClusterMaxHeight, - ), - child: SingleChildScrollView( - reverse: true, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_controller.commandHistory.isNotEmpty) - _CommandHistoryBar( - entries: _controller.commandHistory, - onSelect: (value) => setState( - () => _composerController.text = value, + onReplyWithNick: (message) { + final prefix = + message.sender == _controller.currentNick + ? '' + : '${message.sender}: '; + _insertIntoComposer(prefix); + }, + onReplyToMessage: _setPendingReply, + onDownloadAttachment: _downloadAttachment, + onNickTap: (nick) => unawaited( + _controller.performChannelUserAction( + nick, + ChannelUserAction.query, ), ), - if (_controller.activeTypingUsers.isNotEmpty) - _TypingIndicator( - users: _controller.activeTypingUsers, - ), - if (_pendingReplyMessage != null) - _PendingReplyBar( - message: _pendingReplyMessage!, - onCancel: () => setState( - () => _pendingReplyMessage = null, + onNickLongPress: (nick) => + unawaited(_showChannelUserActions(nick)), + onChannelTap: (channel) => unawaited( + _controller.joinChannel( + JoinChannelRequest(channel: channel), ), ), - const Divider(height: 1), - _ComposerArea( - suggestions: _composerSuggestions, - autocompleteSuggestions: _autocompleteSuggestions, - controller: _composerController, - hintText: _controller.activeTab.type == ChatTabType.server - ? 'Type raw IRC or /join #channel' - : _controller.activeTab.type == ChatTabType.dcc - ? (_controller.activeDccSession?.type == - DccSessionType.chat - ? 'Type DCC chat message' - : 'DCC SEND tabs do not accept messages') - : 'Message ${_controller.activeTab.name}', - onChanged: _handleComposerChanged, - onSubmitted: _submit, - canSendDccFile: - _controller.activeTab.type == ChatTabType.query, - onPickDccFile: _pickAndSendDccFile, - onSuggestionSelected: _applyComposerSuggestion, - onAutocompleteSelected: _applyAutocompleteSuggestion, - enterToSend: _controller.settings.enterToSend, - showSendButton: _controller.settings.showSendButton, - onCameraPhoto: () => - _captureAndSendMedia(ImageSource.camera), - onGalleryImage: () => - _captureAndSendMedia(ImageSource.gallery), - onCameraVideo: () => _captureAndSendMedia( - ImageSource.camera, - video: true, - ), + showTimestamps: _controller.settings.showTimestamps, + onLoadOlder: + _controller.hasPersistentHistory && + !_messageSearchVisible + ? () async { + await _controller.loadOlderHistory( + _controller.activeTabId, + ); + } + : null, ), - ], - ), - ), - ), - ], - ); - }, + ), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: bottomClusterMaxHeight, + ), + child: SingleChildScrollView( + reverse: true, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_controller.commandHistory.isNotEmpty) + _CommandHistoryBar( + entries: _controller.commandHistory, + onSelect: (value) => setState( + () => _composerController.text = value, + ), + ), + if (_controller.activeTypingUsers.isNotEmpty) + _TypingIndicator( + users: _controller.activeTypingUsers, + ), + if (_pendingReplyMessage != null) + _PendingReplyBar( + message: _pendingReplyMessage!, + onCancel: () => setState( + () => _pendingReplyMessage = null, + ), + ), + const Divider(height: 1), + _ComposerArea( + suggestions: _composerSuggestions, + autocompleteSuggestions: + _autocompleteSuggestions, + controller: _composerController, + hintText: + _controller.activeTab.type == + ChatTabType.server + ? 'Type raw IRC or /join #channel' + : _controller.activeTab.type == + ChatTabType.dcc + ? (_controller.activeDccSession?.type == + DccSessionType.chat + ? 'Type DCC chat message' + : 'DCC SEND tabs do not accept messages') + : 'Message ${_controller.activeTab.name}', + onChanged: _handleComposerChanged, + onSubmitted: _submit, + canSendDccFile: + _controller.activeTab.type == + ChatTabType.query, + onPickDccFile: _pickAndSendDccFile, + onSuggestionSelected: _applyComposerSuggestion, + onAutocompleteSelected: + _applyAutocompleteSuggestion, + enterToSend: _controller.settings.enterToSend, + showSendButton: + _controller.settings.showSendButton, + onCameraPhoto: () => + _captureAndSendMedia(ImageSource.camera), + onGalleryImage: () => + _captureAndSendMedia(ImageSource.gallery), + onCameraVideo: () => _captureAndSendMedia( + ImageSource.camera, + video: true, + ), + ), + ], + ), + ), + ), + ], + ); + }, + ), ), - ), - ); - }, + ); + }, ), ); } @@ -570,6 +613,23 @@ class _ChatScreenState extends State { } } + Future _pickAndSendDccFileToNick(String nick) async { + try { + final filePath = await _filePicker.pickFile(); + if (!mounted || filePath == null) { + return; + } + await _controller.sendDccFileToNick(nick: nick, filePath: filePath); + } catch (error) { + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Unable to send DCC file: $error')), + ); + } + } + Future _downloadAttachment(IrcMessageAttachment attachment) async { final url = attachment.uri; if (url == null || url.trim().isEmpty) { @@ -677,9 +737,7 @@ class _ChatScreenState extends State { } await _controller.sendDccFileToNick(nick: nick, filePath: file.path); } catch (error) { - messenger.showSnackBar( - SnackBar(content: Text('Capture failed: $error')), - ); + messenger.showSnackBar(SnackBar(content: Text('Capture failed: $error'))); } } @@ -701,12 +759,16 @@ class _ChatScreenState extends State { Future _showChannelUserActions(String nick) async { final network = _controller.network.id; final note = await _userNotesRepository.getNote(network, nick); + final info = _controller.userInfoForNick(nick); if (!mounted) { return; } await showModalBottomSheet( context: context, builder: (sheetContext) { + final isIgnored = _controller.ignoreMasks.contains( + nick.trim().toLowerCase(), + ); Widget action(String label, IconData icon, ChannelUserAction act) { return ListTile( leading: Icon(icon), @@ -718,53 +780,151 @@ class _ChatScreenState extends State { ); } + Widget directAction( + String label, + IconData icon, + VoidCallback onSelected, + ) { + return ListTile( + leading: Icon(icon), + title: Text(label), + onTap: () { + Navigator.of(sheetContext).pop(); + onSelected(); + }, + ); + } + return SafeArea( child: SingleChildScrollView( child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + nick, + style: Theme.of(sheetContext).textTheme.titleMedium, + ), + subtitle: Text( + note.isEmpty ? 'Channel user actions' : 'Note: $note', + ), + ), + const Divider(height: 1), + directAction('Copy nick', Icons.copy, () { + unawaited(Clipboard.setData(ClipboardData(text: nick))); + }), + ListTile( + leading: const Icon(Icons.sticky_note_2_outlined), + title: Text(note.isEmpty ? 'Add note' : 'Edit note'), + onTap: () { + Navigator.of(sheetContext).pop(); + unawaited(_showUserNoteDialog(nick, note)); + }, + ), + for (final type in UserListType.autoModeTypes) + _autoModeToggleTile(sheetContext, nick, type), + if (info.userhost.isNotEmpty) + directAction('Copy userhost', Icons.alternate_email, () { + unawaited( + Clipboard.setData(ClipboardData(text: info.userhost)), + ); + }), + if (info.hostmask.isNotEmpty) + directAction('Copy hostmask', Icons.dns_outlined, () { + unawaited( + Clipboard.setData(ClipboardData(text: info.hostmask)), + ); + }), + directAction('User info', Icons.badge_outlined, () { + unawaited(_showUserInfoPanel(nick)); + }), + _userListToggleTile(sheetContext, nick, UserListType.notify), + _userListToggleTile( + sheetContext, nick, - style: Theme.of(sheetContext).textTheme.titleMedium, + UserListType.protectedUser, ), - subtitle: Text( - note.isEmpty ? 'Channel user actions' : 'Note: $note', + _userListToggleTile(sheetContext, nick, UserListType.other), + const Divider(height: 1), + action('WHOIS', Icons.badge_outlined, ChannelUserAction.whois), + action('WHOWAS', Icons.history, ChannelUserAction.whowas), + action( + 'Open query', + Icons.chat_bubble_outline, + ChannelUserAction.query, ), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.sticky_note_2_outlined), - title: Text(note.isEmpty ? 'Add note' : 'Edit note'), - onTap: () { - Navigator.of(sheetContext).pop(); - unawaited(_showUserNoteDialog(nick, note)); - }, - ), - for (final type in UserListType.values) - _autoModeToggleTile(sheetContext, nick, type), - const Divider(height: 1), - action('WHOIS', Icons.badge_outlined, ChannelUserAction.whois), - action( - 'Open query', - Icons.chat_bubble_outline, - ChannelUserAction.query, - ), - action('Op', Icons.shield_outlined, ChannelUserAction.op), - action( - 'Deop', - Icons.remove_moderator_outlined, - ChannelUserAction.deop, - ), - action('Voice', Icons.volume_up_outlined, ChannelUserAction.voice), - action( - 'Devoice', - Icons.volume_off_outlined, - ChannelUserAction.devoice, - ), - action('Kick', Icons.logout, ChannelUserAction.kick), - action('Ban', Icons.block, ChannelUserAction.ban), - ], + action( + isIgnored ? 'Unignore' : 'Ignore', + isIgnored ? Icons.visibility : Icons.block, + ChannelUserAction.ignoreToggle, + ), + const Divider(height: 1), + action( + 'CTCP PING', + Icons.settings_ethernet, + ChannelUserAction.ctcpPing, + ), + action( + 'CTCP VERSION', + Icons.info_outline, + ChannelUserAction.ctcpVersion, + ), + action('CTCP TIME', Icons.schedule, ChannelUserAction.ctcpTime), + action( + 'DCC Chat', + Icons.forum_outlined, + ChannelUserAction.dccChat, + ), + directAction('DCC Send file', Icons.file_upload_outlined, () { + unawaited(_pickAndSendDccFileToNick(nick)); + }), + directAction('Add blacklist rule', Icons.gpp_bad_outlined, () { + unawaited(_showBlacklistEntryDialog(nick)); + }), + const Divider(height: 1), + if (_controller.canModerateActiveChannel) ...[ + action('Op', Icons.shield_outlined, ChannelUserAction.op), + action( + 'Deop', + Icons.remove_moderator_outlined, + ChannelUserAction.deop, + ), + ], + if (_controller.canVoiceOrKickActiveChannel) ...[ + action( + 'Voice', + Icons.volume_up_outlined, + ChannelUserAction.voice, + ), + action( + 'Devoice', + Icons.volume_off_outlined, + ChannelUserAction.devoice, + ), + directAction('Kick', Icons.logout, () { + unawaited( + _showKickBanDialog(nick, ChannelModerationAction.kick), + ); + }), + ], + if (_controller.canModerateActiveChannel) ...[ + directAction('Ban', Icons.block, () { + unawaited( + _showKickBanDialog(nick, ChannelModerationAction.ban), + ); + }), + directAction('Kick + Ban', Icons.gpp_bad_outlined, () { + unawaited( + _showKickBanDialog(nick, ChannelModerationAction.kickBan), + ); + }), + directAction('Quiet', Icons.volume_off_outlined, () { + unawaited( + _showKickBanDialog(nick, ChannelModerationAction.quiet), + ); + }), + ], + ], ), ), ); @@ -876,11 +1036,34 @@ class _ChatScreenState extends State { ), title: Text(tab.name), trailing: tab.type == ChatTabType.server - ? null - : IconButton( - onPressed: () => _controller.closeTab(tab.id), - icon: const Icon(Icons.close, size: 18), - tooltip: 'Close tab', + ? IconButton( + onPressed: () { + Navigator.of(context).pop(); + unawaited(_showTabOptions(tab)); + }, + icon: const Icon(Icons.more_vert, size: 18), + tooltip: 'Tab options', + ) + : SizedBox( + width: 96, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + onPressed: () { + Navigator.of(context).pop(); + unawaited(_showTabOptions(tab)); + }, + icon: const Icon(Icons.more_vert, size: 18), + tooltip: 'Tab options', + ), + IconButton( + onPressed: () => _controller.closeTab(tab.id), + icon: const Icon(Icons.close, size: 18), + tooltip: 'Close tab', + ), + ], + ), ), onTap: () { _controller.selectTab(tab.id); @@ -895,6 +1078,78 @@ class _ChatScreenState extends State { ); } + Future _showTabOptions(ChatTab tab) async { + await showModalBottomSheet( + context: context, + builder: (sheetContext) { + Widget action(String label, IconData icon, VoidCallback onTap) { + return ListTile( + leading: Icon(icon), + title: Text(label), + onTap: () { + Navigator.of(sheetContext).pop(); + onTap(); + }, + ); + } + + return SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: Text(tab.name), subtitle: Text(tab.type.name)), + const Divider(height: 1), + action('Open', Icons.open_in_new, () { + _controller.selectTab(tab.id); + }), + action('Copy name', Icons.copy, () { + unawaited(Clipboard.setData(ClipboardData(text: tab.name))); + }), + action('Recent log', Icons.article_outlined, () { + unawaited(_showTabLog(tab)); + }), + if (tab.type == ChatTabType.channel) ...[ + action('Channel note', Icons.sticky_note_2_outlined, () { + unawaited(_showChannelNoteDialog(tab)); + }), + action('Refresh topic', Icons.topic_outlined, () { + _controller.selectTab(tab.id); + unawaited(_controller.handleComposerSubmit('/topic')); + }), + action('Refresh modes', Icons.tune, () { + _controller.selectTab(tab.id); + unawaited(_controller.handleComposerSubmit('/mode')); + }), + action('Ban list', Icons.block, () { + _controller.selectTab(tab.id); + unawaited(_controller.handleComposerSubmit('/banlist')); + }), + action('Quiet list', Icons.volume_off_outlined, () { + _controller.selectTab(tab.id); + unawaited(_controller.handleComposerSubmit('/quietlist')); + }), + ], + if (tab.type != ChatTabType.server) + action('Close', Icons.close, () { + _controller.closeTab(tab.id); + }), + ], + ), + ), + ); + }, + ); + } + + Future _showTabLog(ChatTab tab) async { + final messages = _controller.messagesForTab(tab.id); + await showDialog( + context: context, + builder: (context) => _TabLogDialog(tab: tab, messages: messages), + ); + } + Future _showChannelNoteDialog(ChatTab tab) async { final network = _controller.network.id; final existing = await _channelNotesRepository.getNote(network, tab.name); @@ -919,7 +1174,9 @@ class _ChatScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - result.trim().isEmpty ? 'Channel note cleared.' : 'Channel note saved.', + result.trim().isEmpty + ? 'Channel note cleared.' + : 'Channel note saved.', ), ), ); @@ -971,13 +1228,12 @@ class _ChatScreenState extends State { final existing = _existingAutoModeEntry(nick, type); final active = existing != null; return ListTile( - leading: Icon( - switch (type) { - UserListType.autoOp => Icons.shield_moon_outlined, - UserListType.autoHalfOp => Icons.shield_outlined, - UserListType.autoVoice => Icons.record_voice_over_outlined, - }, - ), + leading: Icon(switch (type) { + UserListType.autoOp => Icons.shield_moon_outlined, + UserListType.autoHalfOp => Icons.shield_outlined, + UserListType.autoVoice => Icons.record_voice_over_outlined, + _ => Icons.rule_outlined, + }), title: Text(type.label), trailing: active ? const Icon(Icons.check, size: 18) @@ -989,6 +1245,43 @@ class _ChatScreenState extends State { ); } + UserListEntry? _existingUserListEntry(String nick, UserListType type) { + final normalized = '${nick.trim().toLowerCase()}!*@*'; + for (final entry in _controller.userListEntriesForType(type)) { + if (entry.normalizedMask.toLowerCase() == normalized && + (entry.network == null || entry.network == _controller.network.id)) { + return entry; + } + } + return null; + } + + Widget _userListToggleTile( + BuildContext sheetContext, + String nick, + UserListType type, + ) { + final existing = _existingUserListEntry(nick, type); + final active = existing != null; + return ListTile( + leading: Icon(switch (type) { + UserListType.notify => Icons.notifications_active_outlined, + UserListType.protectedUser => Icons.verified_user_outlined, + UserListType.other => Icons.label_outline, + UserListType.blacklist => Icons.gpp_bad_outlined, + _ => Icons.rule_outlined, + }), + title: Text(type.label), + trailing: active + ? const Icon(Icons.check, size: 18) + : const Icon(Icons.add, size: 18), + onTap: () { + Navigator.of(sheetContext).pop(); + unawaited(_toggleUserList(nick, type, existing)); + }, + ); + } + Future _toggleAutoMode( String nick, UserListType type, @@ -998,11 +1291,33 @@ class _ChatScreenState extends State { await _controller.removeAutoModeEntry(existing); } else { await _controller.addAutoModeEntry( - UserListEntry( - type: type, - mask: nick, - network: _controller.network.id, + UserListEntry(type: type, mask: nick, network: _controller.network.id), + ); + } + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + existing != null + ? 'Removed $nick from ${type.label}.' + : 'Added $nick to ${type.label}.', ), + ), + ); + } + + Future _toggleUserList( + String nick, + UserListType type, + UserListEntry? existing, + ) async { + if (existing != null) { + await _controller.removeUserListEntry(existing); + } else { + await _controller.addUserListEntry( + UserListEntry(type: type, mask: nick, network: _controller.network.id), ); } if (!mounted) { @@ -1019,6 +1334,79 @@ class _ChatScreenState extends State { ); } + Future _showBlacklistEntryDialog(String nick) async { + final entry = await showDialog( + context: context, + builder: (context) => + _BlacklistEntryDialog(nick: nick, network: _controller.network.id), + ); + if (entry == null) { + return; + } + await _controller.addUserListEntry(entry); + if (!mounted) { + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Added $nick to blacklist.'))); + } + + Future _showKickBanDialog( + String nick, + ChannelModerationAction action, + ) async { + final request = await showDialog<_KickBanRequest>( + context: context, + builder: (context) => _KickBanDialog( + nick: nick, + action: action, + maskPreview: (type) => _controller.banMaskPreviewForNick(nick, type), + ), + ); + if (request == null) { + return; + } + await _controller.performChannelModerationAction( + nick: nick, + action: action, + reason: request.reason, + banMaskType: request.banMaskType, + timedRemoval: request.timedRemoval, + ); + } + + Future _showUserInfoPanel(String nick) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _UserInfoSheet( + info: _controller.userInfoForNick(nick), + onWhois: () { + Navigator.of(context).pop(); + unawaited( + _controller.performChannelUserAction(nick, ChannelUserAction.whois), + ); + }, + onWhowas: () { + Navigator.of(context).pop(); + unawaited( + _controller.performChannelUserAction( + nick, + ChannelUserAction.whowas, + ), + ); + }, + onChannelTap: (channel) { + Navigator.of(context).pop(); + unawaited( + _controller.joinChannel(JoinChannelRequest(channel: channel)), + ); + }, + ), + ); + } + IconData _iconForTab(ChatTabType type) { switch (type) { case ChatTabType.server: @@ -1190,8 +1578,9 @@ class _ComposerArea extends StatelessWidget { controller: controller, minLines: 1, maxLines: 4, - keyboardType: - enterToSend ? TextInputType.text : TextInputType.multiline, + keyboardType: enterToSend + ? TextInputType.text + : TextInputType.multiline, textInputAction: enterToSend ? TextInputAction.send : TextInputAction.newline, @@ -1239,10 +1628,7 @@ class _ComposerArea extends StatelessWidget { const SizedBox(width: 8), ], if (showSendButton || !enterToSend) - FilledButton( - onPressed: onSubmitted, - child: const Text('Send'), - ), + FilledButton(onPressed: onSubmitted, child: const Text('Send')), ], ), ), @@ -1907,6 +2293,437 @@ class _HistoryToolsSheetState extends State<_HistoryToolsSheet> { } } +class _KickBanRequest { + const _KickBanRequest({ + required this.banMaskType, + this.reason, + this.timedRemoval, + }); + + final int banMaskType; + final String? reason; + final Duration? timedRemoval; +} + +class _KickBanDialog extends StatefulWidget { + const _KickBanDialog({ + required this.nick, + required this.action, + required this.maskPreview, + }); + + final String nick; + final ChannelModerationAction action; + final String Function(int type) maskPreview; + + @override + State<_KickBanDialog> createState() => _KickBanDialogState(); +} + +class _KickBanDialogState extends State<_KickBanDialog> { + static const List _presetReasons = [ + '', + 'Spam', + 'Flooding', + 'Abuse', + 'Off-topic', + 'Policy violation', + ]; + + final TextEditingController _reason = TextEditingController(); + final TextEditingController _duration = TextEditingController(); + String _presetReason = ''; + int _banType = 10; + + @override + void dispose() { + _reason.dispose(); + _duration.dispose(); + super.dispose(); + } + + void _submit() { + final minutes = int.tryParse(_duration.text.trim()); + Navigator.of(context).pop( + _KickBanRequest( + banMaskType: _banType, + reason: _reason.text.trim().isEmpty ? null : _reason.text.trim(), + timedRemoval: minutes == null || minutes <= 0 + ? null + : Duration(minutes: minutes), + ), + ); + } + + @override + Widget build(BuildContext context) { + final title = switch (widget.action) { + ChannelModerationAction.kick => 'Kick ${widget.nick}', + ChannelModerationAction.ban => 'Ban ${widget.nick}', + ChannelModerationAction.kickBan => 'Kick + ban ${widget.nick}', + ChannelModerationAction.quiet => 'Quiet ${widget.nick}', + }; + final needsMask = widget.action != ChannelModerationAction.kick; + return AlertDialog( + title: Text(title), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (needsMask) ...[ + DropdownButtonFormField( + initialValue: _banType, + decoration: const InputDecoration(labelText: 'Ban mask'), + items: [ + for (final type in banMaskTypes) + DropdownMenuItem( + value: type.id, + child: Text('${type.id}: ${type.pattern}'), + ), + ], + onChanged: (value) => setState(() => _banType = value ?? 10), + ), + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + widget.maskPreview(_banType), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + ], + DropdownButtonFormField( + initialValue: _presetReason, + decoration: const InputDecoration(labelText: 'Preset reason'), + items: [ + for (final reason in _presetReasons) + DropdownMenuItem( + value: reason, + child: Text(reason.isEmpty ? 'Custom' : reason), + ), + ], + onChanged: (value) { + final next = value ?? ''; + setState(() => _presetReason = next); + if (next.isNotEmpty) { + _reason.text = next; + } + }, + ), + TextField( + controller: _reason, + decoration: const InputDecoration( + labelText: 'Reason', + hintText: 'optional', + ), + ), + if (needsMask) + TextField( + controller: _duration, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Timed removal minutes', + hintText: 'blank = permanent', + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton(onPressed: _submit, child: const Text('Send')), + ], + ); + } +} + +class _BlacklistEntryDialog extends StatefulWidget { + const _BlacklistEntryDialog({required this.nick, required this.network}); + + final String nick; + final String network; + + @override + State<_BlacklistEntryDialog> createState() => _BlacklistEntryDialogState(); +} + +class _BlacklistEntryDialogState extends State<_BlacklistEntryDialog> { + final TextEditingController _reason = TextEditingController(); + final TextEditingController _duration = TextEditingController(); + final TextEditingController _customRaw = TextEditingController(); + BlacklistAction _action = BlacklistAction.ignore; + + @override + void dispose() { + _reason.dispose(); + _duration.dispose(); + _customRaw.dispose(); + super.dispose(); + } + + void _submit() { + final minutes = int.tryParse(_duration.text.trim()); + Navigator.of(context).pop( + UserListEntry( + type: UserListType.blacklist, + mask: widget.nick, + network: widget.network, + blacklistAction: _action, + reason: _reason.text.trim().isEmpty ? null : _reason.text.trim(), + duration: minutes == null || minutes <= 0 + ? null + : Duration(minutes: minutes), + customRaw: _customRaw.text.trim().isEmpty + ? null + : _customRaw.text.trim(), + ), + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('Blacklist ${widget.nick}'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + initialValue: _action, + decoration: const InputDecoration(labelText: 'Action'), + items: [ + for (final action in BlacklistAction.values) + DropdownMenuItem(value: action, child: Text(action.label)), + ], + onChanged: (value) => setState(() => _action = value ?? _action), + ), + TextField( + controller: _reason, + decoration: const InputDecoration( + labelText: 'Reason', + hintText: 'optional', + ), + ), + TextField( + controller: _duration, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Duration minutes', + hintText: 'blank = permanent', + ), + ), + if (_action == BlacklistAction.custom) + TextField( + controller: _customRaw, + decoration: const InputDecoration( + labelText: 'Custom raw template', + hintText: 'GLINE {hostmask} {duration} :{reason}', + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton(onPressed: _submit, child: const Text('Save')), + ], + ); + } +} + +class _UserInfoSheet extends StatelessWidget { + const _UserInfoSheet({ + required this.info, + required this.onWhois, + required this.onWhowas, + required this.onChannelTap, + }); + + final IrcUserInfo info; + final VoidCallback onWhois; + final VoidCallback onWhowas; + final ValueChanged onChannelTap; + + @override + Widget build(BuildContext context) { + final rows = <({IconData icon, String label, String value})>[ + if (info.hostmask.isNotEmpty) + (icon: Icons.dns_outlined, label: 'Hostmask', value: info.hostmask), + if ((info.account ?? '').isNotEmpty) + (icon: Icons.verified_outlined, label: 'Account', value: info.account!), + if ((info.realName ?? '').isNotEmpty) + (icon: Icons.person_outline, label: 'Real name', value: info.realName!), + if ((info.awayMessage ?? '').isNotEmpty) + ( + icon: Icons.hourglass_empty, + label: 'Away', + value: info.awayMessage == '__away__' ? 'away' : info.awayMessage!, + ), + if ((info.server ?? '').isNotEmpty) + ( + icon: Icons.storage_outlined, + label: 'Server', + value: [info.server, info.serverInfo].whereType().join(' '), + ), + if (info.idleSeconds != null) + ( + icon: Icons.timer_outlined, + label: 'Idle', + value: '${info.idleSeconds}s', + ), + if (info.signedOn != null) + ( + icon: Icons.login, + label: 'Signed on', + value: info.signedOn.toString(), + ), + if (info.isRegistered) + (icon: Icons.how_to_reg, label: 'Registered', value: 'yes'), + if (info.isOper) + (icon: Icons.security, label: 'IRC operator', value: 'yes'), + if (info.isSecure) + (icon: Icons.lock_outline, label: 'Secure connection', value: 'yes'), + for (final extra in info.extra) + (icon: Icons.info_outline, label: 'Info', value: extra), + ]; + + return SafeArea( + child: DraggableScrollableSheet( + expand: false, + initialChildSize: 0.7, + minChildSize: 0.35, + maxChildSize: 0.95, + builder: (context, controller) { + return ListView( + controller: controller, + children: [ + ListTile( + title: Text( + info.nick, + style: Theme.of(context).textTheme.titleLarge, + ), + subtitle: Text(info.fromWhowas ? 'WHOWAS cache' : 'User info'), + ), + OverflowBar( + alignment: MainAxisAlignment.start, + children: [ + TextButton.icon( + onPressed: onWhois, + icon: const Icon(Icons.refresh), + label: const Text('WHOIS'), + ), + TextButton.icon( + onPressed: onWhowas, + icon: const Icon(Icons.history), + label: const Text('WHOWAS'), + ), + ], + ), + const Divider(height: 1), + for (final row in rows) + ListTile( + leading: Icon(row.icon), + title: Text(row.label), + subtitle: Text(row.value), + onLongPress: () => + Clipboard.setData(ClipboardData(text: row.value)), + ), + if (info.channels.isNotEmpty) ...[ + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 6), + child: Text( + 'Channels', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final channel in info.channels) + Padding( + padding: const EdgeInsets.only(left: 8), + child: ActionChip( + label: Text(channel), + onPressed: () => onChannelTap(channel), + ), + ), + ], + ), + const SizedBox(height: 16), + ], + ], + ); + }, + ), + ); + } +} + +class _TabLogDialog extends StatelessWidget { + const _TabLogDialog({required this.tab, required this.messages}); + + final ChatTab tab; + final List messages; + + @override + Widget build(BuildContext context) { + final export = messages + .map( + (message) => + '[${message.timestamp.toIso8601String()}] <${message.sender}> ${stripIrcFormatting(message.content)}', + ) + .join('\n'); + return AlertDialog( + title: Text('Recent log: ${tab.name}'), + content: SizedBox( + width: double.maxFinite, + child: messages.isEmpty + ? const Text('No messages in this tab.') + : ListView.separated( + shrinkWrap: true, + itemCount: messages.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final message = messages[index]; + return ListTile( + dense: true, + title: Text( + stripIrcFormatting(message.content), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text('${message.sender} · ${message.kind.name}'), + ); + }, + ), + ), + actions: [ + TextButton.icon( + onPressed: export.isEmpty + ? null + : () => Clipboard.setData(ClipboardData(text: export)), + icon: const Icon(Icons.copy_all), + label: const Text('Copy'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + } +} + enum _HistoryKindFilter { all('All', {}), chat('Chat', { @@ -2104,6 +2921,9 @@ class _NoteDialogState extends State<_NoteDialog> { class _MessageList extends StatelessWidget { const _MessageList({ required this.messages, + required this.knownNicks, + required this.channelPrefixes, + required this.nickPrefixes, required this.showAttachmentPreviews, required this.resolveReplyTarget, required this.resolveReactions, @@ -2112,11 +2932,17 @@ class _MessageList extends StatelessWidget { required this.onReplyWithNick, required this.onReplyToMessage, required this.onDownloadAttachment, + required this.onNickTap, + required this.onNickLongPress, + required this.onChannelTap, this.onLoadOlder, this.showTimestamps = true, }); final List messages; + final Set knownNicks; + final String channelPrefixes; + final String nickPrefixes; final bool showAttachmentPreviews; final bool showTimestamps; final Future Function()? onLoadOlder; @@ -2128,6 +2954,9 @@ class _MessageList extends StatelessWidget { final ValueChanged onReplyToMessage; final Future Function(IrcMessageAttachment attachment) onDownloadAttachment; + final ValueChanged onNickTap; + final ValueChanged onNickLongPress; + final ValueChanged onChannelTap; @override Widget build(BuildContext context) { @@ -2139,19 +2968,19 @@ class _MessageList extends StatelessWidget { child: FittedBox( fit: BoxFit.scaleDown, child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.forum_outlined, - size: 40, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 10), - Text( - 'No messages yet.', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.forum_outlined, + size: 40, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 10), + Text( + 'No messages yet.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], ), ), ); @@ -2202,15 +3031,28 @@ class _MessageList extends StatelessWidget { fontSize: (ircTheme.messageFontSize - 2).clamp(9, 100).toDouble(), color: Theme.of(context).colorScheme.onSurfaceVariant, ); + final senderStyle = messageStyle?.copyWith( + fontWeight: FontWeight.w600, + color: + ircTheme.nickColorFor(message.sender) ?? + Theme.of(context).colorScheme.onSurface, + ); final leadingSpans = [ - TextSpan( - text: message.sender, - style: messageStyle?.copyWith( - fontWeight: FontWeight.w600, - color: ircTheme.nickColorFor(message.sender) ?? - Theme.of(context).colorScheme.onSurface, - ), - ), + if (_isInteractiveSender(message)) + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => onNickTap(message.sender), + onLongPress: () => onNickLongPress(message.sender), + child: RichText( + text: TextSpan(text: message.sender, style: senderStyle), + ), + ), + ) + else + TextSpan(text: message.sender, style: senderStyle), if (showTimestamps) TextSpan( text: ' ${_formatClock(message.timestamp)}', @@ -2223,62 +3065,68 @@ class _MessageList extends StatelessWidget { return Padding( padding: EdgeInsets.only(bottom: ircTheme.messageSpacing), child: Align( - alignment: - message.isOwn ? Alignment.centerRight : Alignment.centerLeft, + alignment: message.isOwn + ? Alignment.centerRight + : Alignment.centerLeft, child: GestureDetector( - onLongPress: () => _showMessageActions(context, message), - child: DecoratedBox( - decoration: BoxDecoration( - color: message.isPlayback - ? bubbleColor.withValues(alpha: 0.88) - : bubbleColor, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: ircTheme.messageBorder), - ), - child: Padding( - padding: ircTheme.messagePadding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if ((message.tags['draft/reply'] ?? '') - .trim() - .isNotEmpty) - _ReplyPreview( - referenced: resolveReplyTarget( - message.tags['draft/reply']!.trim(), - ), - replyId: message.tags['draft/reply']!.trim(), + onLongPress: () => _showMessageActions(context, message), + child: DecoratedBox( + decoration: BoxDecoration( + color: message.isPlayback + ? bubbleColor.withValues(alpha: 0.88) + : bubbleColor, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: ircTheme.messageBorder), + ), + child: Padding( + padding: ircTheme.messagePadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if ((message.tags['draft/reply'] ?? '').trim().isNotEmpty) + _ReplyPreview( + referenced: resolveReplyTarget( + message.tags['draft/reply']!.trim(), ), - _IrcFormattedText( - message.content, - baseStyle: messageStyle, - leading: leadingSpans, + replyId: message.tags['draft/reply']!.trim(), + ), + _IrcFormattedText( + message.content, + baseStyle: messageStyle, + leading: leadingSpans, + knownNicks: {...knownNicks, message.sender}, + channelPrefixes: channelPrefixes, + nickPrefixes: nickPrefixes, + contextNick: message.sender, + onNickTap: onNickTap, + onNickLongPress: onNickLongPress, + onChannelTap: onChannelTap, + ), + if (showAttachmentPreviews && !isRedacted) + _MessageAttachments( + message: message, + onDownloadAttachment: onDownloadAttachment, + ), + if (reactions.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: reactions.entries + .map( + (entry) => Chip( + label: Text('${entry.key} ${entry.value}'), + visualDensity: VisualDensity.compact, + ), + ) + .toList(growable: false), ), - if (showAttachmentPreviews && !isRedacted) - _MessageAttachments( - message: message, - onDownloadAttachment: onDownloadAttachment, - ), - if (reactions.isNotEmpty) ...[ - const SizedBox(height: 8), - Wrap( - spacing: 6, - runSpacing: 6, - children: reactions.entries - .map( - (entry) => Chip( - label: Text('${entry.key} ${entry.value}'), - visualDensity: VisualDensity.compact, - ), - ) - .toList(growable: false), - ), - ], ], - ), + ], ), ), ), + ), ), ); }, @@ -2417,6 +3265,26 @@ class _MessageList extends StatelessWidget { }, ); } + + bool _isInteractiveSender(IrcMessage message) { + final sender = message.sender.trim(); + if (sender.isEmpty || sender == '*') { + return false; + } + switch (message.kind) { + case IrcMessageKind.chat: + case IrcMessageKind.action: + case IrcMessageKind.notice: + return true; + case IrcMessageKind.system: + case IrcMessageKind.raw: + case IrcMessageKind.error: + case IrcMessageKind.event: + case IrcMessageKind.dcc: + case IrcMessageKind.media: + return false; + } + } } class _IrcFormattedText extends StatelessWidget { @@ -2426,6 +3294,13 @@ class _IrcFormattedText extends StatelessWidget { this.maxLines, this.overflow, this.leading, + this.knownNicks = const {}, + this.channelPrefixes = '#&', + this.nickPrefixes = '~&@%+', + this.contextNick, + this.onNickTap, + this.onNickLongPress, + this.onChannelTap, }); final String text; @@ -2436,24 +3311,28 @@ class _IrcFormattedText extends StatelessWidget { /// Inline spans (e.g. sender + timestamp) rendered before the content so the /// message flows on one line and only wraps when it is long. final List? leading; + final Set knownNicks; + final String channelPrefixes; + final String nickPrefixes; + final String? contextNick; + final ValueChanged? onNickTap; + final ValueChanged? onNickLongPress; + final ValueChanged? onChannelTap; @override Widget build(BuildContext context) { - final segments = parseIrcTextWithLinks(text); + final segments = parseInteractiveMessageTokens( + text, + knownNicks: knownNicks, + channelPrefixes: channelPrefixes, + nickPrefixes: nickPrefixes, + contextNick: contextNick, + ); final contentSpans = segments.isEmpty ? [TextSpan(text: text, style: baseStyle)] : segments - .map( - (segment) => TextSpan( - text: segment.text, - style: _resolveTextStyle(baseStyle, segment), - recognizer: segment.isLink - ? (TapGestureRecognizer() - ..onTap = () => _openExternalUrl(segment.url!)) - : null, - ), - ) - .toList(growable: false); + .map((segment) => _spanForToken(context, segment)) + .toList(growable: false); return Text.rich( TextSpan(children: [...?leading, ...contentSpans]), @@ -2463,8 +3342,72 @@ class _IrcFormattedText extends StatelessWidget { ); } - TextStyle _resolveTextStyle(TextStyle? base, IrcLinkSegment segment) { + InlineSpan _spanForToken( + BuildContext context, + InteractiveMessageToken token, + ) { + final style = _resolveTextStyle(baseStyle, token); + switch (token.type) { + case InteractiveMessageTokenType.url: + return TextSpan( + text: token.text, + style: style, + recognizer: TapGestureRecognizer() + ..onTap = () => _openExternalUrl(token.url!), + ); + case InteractiveMessageTokenType.channel: + final target = token.value; + if (target == null || onChannelTap == null) { + return TextSpan(text: token.text, style: style); + } + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => onChannelTap!(target), + child: RichText( + text: TextSpan(text: token.text, style: style), + ), + ), + ); + case InteractiveMessageTokenType.nick: + case InteractiveMessageTokenType.hostmask: + case InteractiveMessageTokenType.userHost: + final target = token.value; + if (target == null || (onNickTap == null && onNickLongPress == null)) { + return TextSpan(text: token.text, style: style); + } + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onNickTap == null ? null : () => onNickTap!(target), + onLongPress: onNickLongPress == null + ? null + : () => onNickLongPress!(target), + child: RichText( + text: TextSpan(text: token.text, style: style), + ), + ), + ); + case InteractiveMessageTokenType.text: + return TextSpan(text: token.text, style: style); + } + } + + TextStyle _resolveTextStyle( + TextStyle? base, + InteractiveMessageToken segment, + ) { final style = segment.style; + final isLink = + segment.type == InteractiveMessageTokenType.url || + segment.type == InteractiveMessageTokenType.channel || + segment.type == InteractiveMessageTokenType.nick || + segment.type == InteractiveMessageTokenType.hostmask || + segment.type == InteractiveMessageTokenType.userHost; var foregroundHex = style.colorHex ?? (style.color == null ? null : getIrcColorHex(style.color!)); @@ -2504,7 +3447,7 @@ class _IrcFormattedText extends StatelessWidget { } final decorations = {}; - if (style.underline || segment.isLink) { + if (style.underline || isLink) { decorations.add(TextDecoration.underline); } if (style.strikethrough) { @@ -2516,7 +3459,7 @@ class _IrcFormattedText extends StatelessWidget { ); } - if (segment.isLink && foregroundHex == null) { + if (isLink && foregroundHex == null) { textStyle = textStyle.copyWith(color: const Color(0xFF1565C0)); } @@ -2592,7 +3535,9 @@ class _LinkPreviewCard extends StatefulWidget { } class _LinkPreviewCardState extends State<_LinkPreviewCard> { - late final Future _future = linkPreviewService.fetch(widget.url); + late final Future _future = linkPreviewService.fetch( + widget.url, + ); @override Widget build(BuildContext context) { @@ -2620,6 +3565,45 @@ class _LinkPreviewCardState extends State<_LinkPreviewCard> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if ((preview.imageUrl ?? '').isNotEmpty) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + preview.imageUrl!, + height: 150, + width: double.infinity, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + ), + const SizedBox(height: 8), + ], + if (_siteLabel(preview).isNotEmpty) ...[ + Row( + children: [ + if ((preview.faviconUrl ?? '').isNotEmpty) ...[ + Image.network( + preview.faviconUrl!, + height: 16, + width: 16, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + const SizedBox(width: 6), + ], + Expanded( + child: Text( + _siteLabel(preview), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 4), + ], if ((preview.title ?? '').isNotEmpty) Text( preview.title!, @@ -2644,6 +3628,15 @@ class _LinkPreviewCardState extends State<_LinkPreviewCard> { }, ); } + + String _siteLabel(LinkPreview preview) { + final site = preview.siteName?.trim(); + if (site != null && site.isNotEmpty) { + return site; + } + final host = Uri.tryParse(preview.url)?.host; + return host ?? ''; + } } class _AttachmentCard extends StatelessWidget { diff --git a/lib/features/chat/presentation/user_lists_screen.dart b/lib/features/chat/presentation/user_lists_screen.dart new file mode 100644 index 0000000..757ebab --- /dev/null +++ b/lib/features/chat/presentation/user_lists_screen.dart @@ -0,0 +1,294 @@ +import 'package:androidircx/features/chat/application/chat_session_controller.dart'; +import 'package:androidircx/features/chat/data/user_list_entry.dart'; +import 'package:flutter/material.dart'; + +class UserListsScreen extends StatelessWidget { + const UserListsScreen({super.key, required this.controller}); + + final ChatSessionController controller; + + Future _add(BuildContext context, UserListType type) async { + final entry = await showDialog( + context: context, + builder: (_) => _UserListEntryDialog( + network: controller.network.id, + initialType: type, + ), + ); + if (entry != null) { + await controller.addUserListEntry(entry); + } + } + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: UserListType.managementTypes.length, + child: Scaffold( + appBar: AppBar( + title: const Text('User lists'), + bottom: TabBar( + isScrollable: true, + tabs: [ + for (final type in UserListType.managementTypes) + Tab(text: type.label), + ], + ), + ), + body: TabBarView( + children: [ + for (final type in UserListType.managementTypes) + _UserListTypeView( + controller: controller, + type: type, + onAdd: () => _add(context, type), + ), + ], + ), + ), + ); + } +} + +class _UserListTypeView extends StatelessWidget { + const _UserListTypeView({ + required this.controller, + required this.type, + required this.onAdd, + }); + + final ChatSessionController controller; + final UserListType type; + final VoidCallback onAdd; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + final entries = controller.userListEntriesForType(type); + return Scaffold( + floatingActionButton: FloatingActionButton.extended( + onPressed: onAdd, + icon: const Icon(Icons.add), + label: const Text('Add'), + ), + body: entries.isEmpty + ? _EmptyState(type: type) + : ListView.separated( + padding: const EdgeInsets.only(bottom: 88), + itemCount: entries.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final entry = entries[index]; + return ListTile( + leading: Icon(_iconFor(type)), + title: Text(entry.mask), + subtitle: Text(_subtitle(entry)), + trailing: IconButton( + tooltip: 'Remove', + icon: const Icon(Icons.delete_outline), + onPressed: () => controller.removeUserListEntry(entry), + ), + ); + }, + ), + ); + }, + ); + } + + String _subtitle(UserListEntry entry) { + final parts = [ + entry.channels.isEmpty ? 'all channels' : entry.channels.join(', '), + if (entry.network != null) 'network: ${entry.network}', + if (entry.type == UserListType.blacklist) + entry.effectiveBlacklistAction.label, + if ((entry.reason ?? '').trim().isNotEmpty) 'reason: ${entry.reason}', + if (entry.duration != null) 'duration: ${entry.duration!.inMinutes}m', + ]; + return parts.join(' · '); + } + + static IconData _iconFor(UserListType type) => switch (type) { + UserListType.autoOp => Icons.shield_moon_outlined, + UserListType.autoHalfOp => Icons.shield_outlined, + UserListType.autoVoice => Icons.record_voice_over_outlined, + UserListType.notify => Icons.notifications_active_outlined, + UserListType.protectedUser => Icons.verified_user_outlined, + UserListType.other => Icons.label_outline, + UserListType.blacklist => Icons.gpp_bad_outlined, + }; +} + +class _UserListEntryDialog extends StatefulWidget { + const _UserListEntryDialog({ + required this.network, + required this.initialType, + }); + + final String network; + final UserListType initialType; + + @override + State<_UserListEntryDialog> createState() => _UserListEntryDialogState(); +} + +class _UserListEntryDialogState extends State<_UserListEntryDialog> { + final TextEditingController _mask = TextEditingController(); + final TextEditingController _channels = TextEditingController(); + final TextEditingController _reason = TextEditingController(); + final TextEditingController _duration = TextEditingController(); + final TextEditingController _customRaw = TextEditingController(); + late UserListType _type = widget.initialType; + BlacklistAction _blacklistAction = BlacklistAction.ignore; + + @override + void dispose() { + _mask.dispose(); + _channels.dispose(); + _reason.dispose(); + _duration.dispose(); + _customRaw.dispose(); + super.dispose(); + } + + void _save() { + final mask = _mask.text.trim(); + if (mask.isEmpty) { + return; + } + final channels = _channels.text + .split(',') + .map((entry) => entry.trim()) + .where((entry) => entry.isNotEmpty) + .toList(); + final durationMinutes = int.tryParse(_duration.text.trim()); + Navigator.of(context).pop( + UserListEntry( + type: _type, + mask: mask, + channels: channels, + network: widget.network, + blacklistAction: _type == UserListType.blacklist + ? _blacklistAction + : null, + reason: _reason.text.trim().isEmpty ? null : _reason.text.trim(), + duration: durationMinutes == null || durationMinutes <= 0 + ? null + : Duration(minutes: durationMinutes), + customRaw: _customRaw.text.trim().isEmpty + ? null + : _customRaw.text.trim(), + ), + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Add user-list rule'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + initialValue: _type, + decoration: const InputDecoration(labelText: 'List'), + items: [ + for (final type in UserListType.managementTypes) + DropdownMenuItem(value: type, child: Text(type.label)), + ], + onChanged: (value) => setState(() => _type = value ?? _type), + ), + TextField( + controller: _mask, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Nick or mask', + hintText: 'alice or *!*@*.example.net', + ), + ), + TextField( + controller: _channels, + decoration: const InputDecoration( + labelText: 'Channels (optional)', + hintText: '#room, #ops', + ), + ), + if (_type == UserListType.blacklist) ...[ + DropdownButtonFormField( + initialValue: _blacklistAction, + decoration: const InputDecoration(labelText: 'Action'), + items: [ + for (final action in BlacklistAction.values) + DropdownMenuItem(value: action, child: Text(action.label)), + ], + onChanged: (value) => setState( + () => _blacklistAction = value ?? _blacklistAction, + ), + ), + TextField( + controller: _reason, + decoration: const InputDecoration( + labelText: 'Reason (optional)', + ), + ), + TextField( + controller: _duration, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Duration minutes (optional)', + ), + ), + TextField( + controller: _customRaw, + decoration: const InputDecoration( + labelText: 'Custom raw template', + hintText: 'GLINE {hostmask} {duration} :{reason}', + ), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton(onPressed: _save, child: const Text('Save')), + ], + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState({required this.type}); + + final UserListType type; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.rule_folder_outlined, + size: 44, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'No ${type.label.toLowerCase()} entries', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ), + ); + } +} diff --git a/lib/irc/parser/interactive_message_parser.dart b/lib/irc/parser/interactive_message_parser.dart new file mode 100644 index 0000000..efebb6c --- /dev/null +++ b/lib/irc/parser/interactive_message_parser.dart @@ -0,0 +1,293 @@ +import 'package:androidircx/irc/parser/irc_formatter.dart'; + +enum InteractiveMessageTokenType { + text, + url, + channel, + nick, + hostmask, + userHost, +} + +class InteractiveMessageToken { + const InteractiveMessageToken({ + required this.type, + required this.text, + required this.style, + this.value, + this.url, + }); + + final InteractiveMessageTokenType type; + final String text; + final IrcFormatStyle style; + + /// Normalized interaction target. For nick-like tokens this is the nick; for + /// channel tokens this is the channel name. + final String? value; + final String? url; + + bool get isInteractive => type != InteractiveMessageTokenType.text; +} + +List parseInteractiveMessageTokens( + String text, { + Iterable knownNicks = const [], + String channelPrefixes = '#&', + String nickPrefixes = '~&@%+', + String? contextNick, +}) { + final nickMap = {}; + for (final nick in knownNicks) { + final normalized = _normalizeNick(nick, nickPrefixes); + if (normalized.isNotEmpty) { + nickMap[normalized.toLowerCase()] = normalized; + } + } + final context = _normalizeNick(contextNick ?? '', nickPrefixes); + + final output = []; + for (final segment in parseIrcTextWithLinks(text)) { + if (segment.isLink) { + output.add( + InteractiveMessageToken( + type: InteractiveMessageTokenType.url, + text: segment.text, + style: segment.style, + value: segment.url, + url: segment.url, + ), + ); + continue; + } + output.addAll( + _parsePlainInteractiveSegment( + segment.text, + style: segment.style, + nickMap: nickMap, + channelPrefixes: channelPrefixes, + nickPrefixes: nickPrefixes, + contextNick: context, + ), + ); + } + return output; +} + +List _parsePlainInteractiveSegment( + String text, { + required IrcFormatStyle style, + required Map nickMap, + required String channelPrefixes, + required String nickPrefixes, + required String contextNick, +}) { + if (text.isEmpty) { + return [ + InteractiveMessageToken( + type: InteractiveMessageTokenType.text, + text: text, + style: style, + ), + ]; + } + + final output = []; + for (final match in RegExp(r'\s+|\S+').allMatches(text)) { + final token = match.group(0)!; + if (token.trim().isEmpty) { + output.add( + InteractiveMessageToken( + type: InteractiveMessageTokenType.text, + text: token, + style: style, + ), + ); + continue; + } + output.addAll( + _classifyWordToken( + token, + style: style, + nickMap: nickMap, + channelPrefixes: channelPrefixes, + nickPrefixes: nickPrefixes, + contextNick: contextNick, + ), + ); + } + return output; +} + +List _classifyWordToken( + String token, { + required IrcFormatStyle style, + required Map nickMap, + required String channelPrefixes, + required String nickPrefixes, + required String contextNick, +}) { + final split = _splitOuterPunctuation(token); + final output = []; + void addText(String value) { + if (value.isEmpty) { + return; + } + output.add( + InteractiveMessageToken( + type: InteractiveMessageTokenType.text, + text: value, + style: style, + ), + ); + } + + addText(split.leading); + final classified = _classifyCoreToken( + split.core, + style: style, + nickMap: nickMap, + channelPrefixes: channelPrefixes, + nickPrefixes: nickPrefixes, + contextNick: contextNick, + ); + output.add(classified); + addText(split.trailing); + return output; +} + +InteractiveMessageToken _classifyCoreToken( + String core, { + required IrcFormatStyle style, + required Map nickMap, + required String channelPrefixes, + required String nickPrefixes, + required String contextNick, +}) { + if (core.isEmpty) { + return InteractiveMessageToken( + type: InteractiveMessageTokenType.text, + text: core, + style: style, + ); + } + + final hostmask = _parseHostmask(core, nickPrefixes); + if (hostmask != null) { + final resolved = + nickMap[hostmask.nick.toLowerCase()] ?? + _normalizeNick(hostmask.nick, nickPrefixes); + return InteractiveMessageToken( + type: InteractiveMessageTokenType.hostmask, + text: core, + style: style, + value: resolved, + ); + } + + if (_looksLikeUserHost(core) && contextNick.isNotEmpty) { + return InteractiveMessageToken( + type: InteractiveMessageTokenType.userHost, + text: core, + style: style, + value: contextNick, + ); + } + + if (_isChannelToken(core, channelPrefixes)) { + return InteractiveMessageToken( + type: InteractiveMessageTokenType.channel, + text: core, + style: style, + value: core, + ); + } + + final normalizedNick = _normalizeNick(core, nickPrefixes); + final resolved = nickMap[normalizedNick.toLowerCase()]; + if (resolved != null) { + return InteractiveMessageToken( + type: InteractiveMessageTokenType.nick, + text: core, + style: style, + value: resolved, + ); + } + + return InteractiveMessageToken( + type: InteractiveMessageTokenType.text, + text: core, + style: style, + ); +} + +({String leading, String core, String trailing}) _splitOuterPunctuation( + String token, +) { + var start = 0; + var end = token.length; + while (start < end && _isLeadingPunctuation(token[start])) { + start += 1; + } + while (end > start && _isTrailingPunctuation(token[end - 1])) { + end -= 1; + } + return ( + leading: token.substring(0, start), + core: token.substring(start, end), + trailing: token.substring(end), + ); +} + +bool _isLeadingPunctuation(String value) => '([{<"\''.contains(value); + +bool _isTrailingPunctuation(String value) => '.,;:!?)]}>"\''.contains(value); + +String _normalizeNick(String value, String nickPrefixes) { + var normalized = value.trim(); + while (normalized.isNotEmpty && nickPrefixes.contains(normalized[0])) { + normalized = normalized.substring(1); + } + final bangIndex = normalized.indexOf('!'); + if (bangIndex != -1) { + normalized = normalized.substring(0, bangIndex); + } + return normalized; +} + +bool _isChannelToken(String value, String channelPrefixes) { + if (value.length < 2 || channelPrefixes.isEmpty) { + return false; + } + if (!channelPrefixes.contains(value[0])) { + return false; + } + if (value.codeUnits.any((code) => code < 0x20 || code == 0x7F)) { + return false; + } + return true; +} + +({String nick})? _parseHostmask(String value, String nickPrefixes) { + final bangIndex = value.indexOf('!'); + final atIndex = value.indexOf('@'); + if (bangIndex <= 0 || + atIndex <= bangIndex + 1 || + atIndex == value.length - 1) { + return null; + } + final nick = _normalizeNick(value.substring(0, bangIndex), nickPrefixes); + if (nick.isEmpty) { + return null; + } + return (nick: nick); +} + +bool _looksLikeUserHost(String value) { + final atIndex = value.indexOf('@'); + if (atIndex <= 0 || atIndex == value.length - 1 || value.contains('!')) { + return false; + } + return !value.substring(0, atIndex).contains(RegExp(r'\s')) && + !value.substring(atIndex + 1).contains(RegExp(r'\s')); +} diff --git a/lib/media/services/link_preview_service.dart b/lib/media/services/link_preview_service.dart index 40e3031..a5b1eaf 100644 --- a/lib/media/services/link_preview_service.dart +++ b/lib/media/services/link_preview_service.dart @@ -8,17 +8,23 @@ class LinkPreview { this.title, this.description, this.imageUrl, + this.siteName, + this.faviconUrl, }); final String url; final String? title; final String? description; final String? imageUrl; + final String? siteName; + final String? faviconUrl; bool get hasContent => (title != null && title!.isNotEmpty) || (description != null && description!.isNotEmpty) || - (imageUrl != null && imageUrl!.isNotEmpty); + (imageUrl != null && imageUrl!.isNotEmpty) || + (siteName != null && siteName!.isNotEmpty) || + (faviconUrl != null && faviconUrl!.isNotEmpty); } /// Fetches a URL and returns its HTML. Injectable for tests. @@ -27,7 +33,7 @@ typedef LinkHtmlFetcher = Future Function(Uri url); /// Fetches and caches link previews by parsing OpenGraph/HTML metadata. class LinkPreviewService { LinkPreviewService({LinkHtmlFetcher? fetcher}) - : _fetcher = fetcher ?? _defaultFetch; + : _fetcher = fetcher ?? _defaultFetch; final LinkHtmlFetcher _fetcher; final Map _cache = {}; @@ -65,8 +71,7 @@ class LinkPreviewService { final buffer = StringBuffer(); await for (final chunk in response.transform(utf8.decoder)) { buffer.write(chunk); - if (buffer.length > 200000 || - buffer.toString().contains('')) { + if (buffer.length > 200000 || buffer.toString().contains('')) { break; } } @@ -104,22 +109,90 @@ LinkPreview parseLinkPreview(String html, String url) { String? title = meta('og:title'); if (title == null || title.isEmpty) { - final titleTag = - RegExp(r']*>([^<]*)', caseSensitive: false) - .firstMatch(html); + final titleTag = RegExp( + r']*>([^<]*)', + caseSensitive: false, + ).firstMatch(html); if (titleTag != null) { title = _decodeEntities(titleTag.group(1)!.trim()); } } + final ogImage = meta('og:image') ?? meta('twitter:image'); + final favicon = _faviconUrl(html); return LinkPreview( url: url, title: (title == null || title.isEmpty) ? null : title, description: meta('og:description') ?? meta('description'), - imageUrl: meta('og:image'), + imageUrl: _resolvePreviewUrl(ogImage, url) ?? _youtubeThumbnailUrl(url), + siteName: meta('og:site_name'), + faviconUrl: _resolvePreviewUrl(favicon, url), ); } +String? _faviconUrl(String html) { + final patterns = [ + RegExp( + ']*rel=["\'][^"\']*(?:icon|shortcut icon|apple-touch-icon)[^"\']*["\'][^>]*href=["\']([^"\']+)["\']', + caseSensitive: false, + ), + RegExp( + ']*href=["\']([^"\']+)["\'][^>]*rel=["\'][^"\']*(?:icon|shortcut icon|apple-touch-icon)[^"\']*["\']', + caseSensitive: false, + ), + ]; + for (final pattern in patterns) { + final match = pattern.firstMatch(html); + if (match != null) { + return _decodeEntities(match.group(1)!.trim()); + } + } + return null; +} + +String? _resolvePreviewUrl(String? candidate, String pageUrl) { + final value = candidate?.trim(); + if (value == null || value.isEmpty) { + return null; + } + final base = Uri.tryParse(pageUrl); + if (base == null || !base.hasScheme) { + return value; + } + if (value.startsWith('//')) { + return '${base.scheme}:$value'; + } + final uri = Uri.tryParse(value); + if (uri != null && uri.hasScheme) { + return value; + } + return base.resolve(value).toString(); +} + +String? _youtubeThumbnailUrl(String pageUrl) { + final uri = Uri.tryParse(pageUrl); + if (uri == null) { + return null; + } + String? videoId; + final host = uri.host.toLowerCase(); + if (host == 'youtu.be') { + videoId = uri.pathSegments.isEmpty ? null : uri.pathSegments.first; + } else if (host == 'youtube.com' || + host == 'www.youtube.com' || + host == 'm.youtube.com') { + if (uri.pathSegments.isNotEmpty && uri.pathSegments.first == 'shorts') { + videoId = uri.pathSegments.length > 1 ? uri.pathSegments[1] : null; + } else { + videoId = uri.queryParameters['v']; + } + } + if (videoId == null || videoId.trim().isEmpty) { + return null; + } + return 'https://img.youtube.com/vi/${Uri.encodeComponent(videoId)}/hqdefault.jpg'; +} + String _decodeEntities(String value) { return value .replaceAll('&', '&') diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 82b6f9d..5363f82 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index 13b35eb..5159816 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index 0a3f5fa..2733325 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index bdb5722..41af6fd 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index f083318..e34827b 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index 326c0e7..e7ed8e2 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index 2f1632c..7e66f92 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/pubspec.yaml b/pubspec.yaml index 6332d0b..91c5dc9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.5+8 +version: 1.0.6+9 environment: sdk: ^3.11.1 diff --git a/test/app_version_test.dart b/test/app_version_test.dart new file mode 100644 index 0000000..5202ca2 --- /dev/null +++ b/test/app_version_test.dart @@ -0,0 +1,20 @@ +import 'dart:io'; + +import 'package:androidircx/core/app/app_version.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('app version constants match pubspec version', () { + final pubspec = File('pubspec.yaml').readAsStringSync(); + final match = RegExp( + r'^version:\s*([0-9]+\.[0-9]+\.[0-9]+)\+([0-9]+)\s*$', + multiLine: true, + ).firstMatch(pubspec); + + expect(match, isNotNull); + expect(appVersionName, match!.group(1)); + expect(appVersionCode, int.parse(match.group(2)!)); + expect(appVersion, '${match.group(1)}+${match.group(2)}'); + expect(ctcpVersionReply, 'AndroidIRCX Flutter v$appVersion'); + }); +} diff --git a/test/ban_mask_service_test.dart b/test/ban_mask_service_test.dart new file mode 100644 index 0000000..502a562 --- /dev/null +++ b/test/ban_mask_service_test.dart @@ -0,0 +1,59 @@ +import 'package:androidircx/features/chat/application/ban_mask_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const service = BanMaskService(); + + test('generates RN-compatible ban mask types', () { + final expected = { + 0: '*!john@irc.example.com', + 1: '*!*john@irc.example.com', + 2: '*!*@irc.example.com', + 3: '*!*john@*.example.com', + 4: '*!*@*.example.com', + 5: 'John!john@irc.example.com', + 6: 'John!*john@irc.example.com', + 7: 'John!*@irc.example.com', + 8: 'John!*john@*.example.com', + 9: 'John!*@*.example.com', + 10: 'John!*@*', + 11: '*!john@*', + }; + + for (final entry in expected.entries) { + expect( + service.generateBanMask( + nick: 'John', + ident: '~john', + host: 'irc.example.com', + type: entry.key, + ), + entry.value, + ); + } + }); + + test('wildcards IP hosts for domain-style mask types', () { + expect( + service.generateBanMask( + nick: 'John', + ident: 'john', + host: '192.168.1.55', + type: 4, + ), + '*!*@192.168.1.*', + ); + }); + + test('falls back to host-only ban for unknown types', () { + expect( + service.generateBanMask( + nick: 'John', + ident: 'john', + host: 'host.test', + type: 99, + ), + '*!*@host.test', + ); + }); +} diff --git a/test/chat_session_controller_test.dart b/test/chat_session_controller_test.dart index 2a285fb..3489b39 100644 --- a/test/chat_session_controller_test.dart +++ b/test/chat_session_controller_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; +import 'package:androidircx/core/app/app_version.dart'; import 'package:androidircx/core/models/app_settings.dart'; import 'package:androidircx/core/models/chat_tab.dart'; import 'package:androidircx/core/models/connection_state.dart'; @@ -15,6 +16,7 @@ import 'package:androidircx/core/storage/settings_repository.dart'; import 'package:androidircx/features/chat/application/chat_session_controller.dart'; import 'package:androidircx/features/chat/data/chat_session_persistence.dart'; import 'package:androidircx/features/chat/data/message_history_repository.dart'; +import 'package:androidircx/features/chat/data/user_list_entry.dart'; import 'package:androidircx/features/chat/presentation/join_channel_dialog.dart'; import 'package:androidircx/irc/services/irc_service.dart'; import 'package:androidircx/irc/services/irc_transport.dart'; @@ -3440,7 +3442,7 @@ void main() { expect( transport.sentLines, - contains('NOTICE alice :\u0001VERSION AndroidIRCx Flutter 1.0.0\u0001'), + contains('NOTICE alice :\u0001VERSION $ctcpVersionReply\u0001'), ); controller.selectTab( controller.tabs @@ -3830,6 +3832,26 @@ void main() { 'alice', ChannelUserAction.whois, ); + await controller.performChannelUserAction( + 'alice', + ChannelUserAction.whowas, + ); + await controller.performChannelUserAction( + 'alice', + ChannelUserAction.kickBan, + ); + await controller.performChannelUserAction( + 'alice', + ChannelUserAction.ignoreToggle, + ); + await controller.performChannelUserAction( + 'alice', + ChannelUserAction.ignoreToggle, + ); + await controller.performChannelUserAction( + 'alice', + ChannelUserAction.ctcpVersion, + ); await controller.performChannelUserAction( 'alice', ChannelUserAction.query, @@ -3846,6 +3868,20 @@ void main() { transport.sentLines.any((line) => line.startsWith('WHOIS alice')), isTrue, ); + expect( + transport.sentLines.any((line) => line.startsWith('WHOWAS alice')), + isTrue, + ); + expect(transport.sentLines, contains('MODE #room +b alice!*@*')); + expect( + transport.sentLines.any((line) => line.startsWith('KICK #room alice')), + isTrue, + ); + expect(controller.ignoreMasks, isEmpty); + expect( + transport.sentLines, + contains('PRIVMSG alice :\u0001VERSION\u0001'), + ); expect( controller.tabs.any( (tab) => tab.type.name == 'query' && tab.name == 'alice', @@ -5040,4 +5076,195 @@ void main() { controller.dispose(); }); + + test( + 'stores rich WHOIS details and exposes clickable channel data', + () async { + final transport = _FakeTransport(); + final service = IrcService(transportConnector: (_) async => transport); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + ), + ircService: service, + ); + + await controller.start(); + transport.emit( + ':server 311 AndroidIRCX alice ident host.example * :Alice', + ); + transport.emit(':server 330 AndroidIRCX alice aliceAccount :logged in'); + transport.emit(':server 312 AndroidIRCX alice irc.example.test :Server'); + transport.emit(':server 317 AndroidIRCX alice 42 1700000000 :idle'); + transport.emit(':server 319 AndroidIRCX alice :@#room +#dart'); + transport.emit(':server 671 AndroidIRCX alice :secure connection'); + transport.emit(':server 318 AndroidIRCX alice :End'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + final info = controller.userInfoForNick('alice'); + expect(info.hostmask, 'alice!ident@host.example'); + expect(info.account, 'aliceAccount'); + expect(info.server, 'irc.example.test'); + expect(info.idleSeconds, 42); + expect(info.isRegistered, isTrue); + expect(info.isSecure, isTrue); + expect(info.channels, ['#dart', '#room']); + + controller.dispose(); + }, + ); + + test( + 'user list commands add notify, protected, and blacklist entries', + () async { + final transport = _FakeTransport(); + final service = IrcService(transportConnector: (_) async => transport); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + ), + ircService: service, + ); + + await controller.start(); + await controller.handleComposerSubmit('/notify alice'); + await controller.handleComposerSubmit('/protect bob'); + await controller.handleComposerSubmit( + '/blacklist bad!*@evil kick_ban spam', + ); + + expect( + controller.userListEntriesForType(UserListType.notify).single.mask, + 'alice', + ); + expect( + controller + .userListEntriesForType(UserListType.protectedUser) + .single + .mask, + 'bob', + ); + final blacklist = controller.blacklistEntries.single; + expect(blacklist.mask, 'bad!*@evil'); + expect(blacklist.effectiveBlacklistAction, BlacklistAction.kickBan); + expect(blacklist.reason, 'spam'); + expect(transport.sentLines, contains('MONITOR + alice')); + + await controller.handleComposerSubmit('/unnotify alice'); + expect(controller.userListEntriesForType(UserListType.notify), isEmpty); + expect(transport.sentLines, contains('MONITOR - alice')); + + controller.dispose(); + }, + ); + + test( + 'blacklist enforcement drops messages and emits moderation action', + () async { + final transport = _FakeTransport(); + final service = IrcService(transportConnector: (_) async => transport); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + ), + ircService: service, + ); + + await controller.start(); + await controller.addUserListEntry( + const UserListEntry( + type: UserListType.blacklist, + mask: 'spammer!*@evil.example', + blacklistAction: BlacklistAction.kickBan, + reason: 'spam', + ), + ); + transport.emit(':AndroidIRCX!me@host JOIN #room'); + transport.emit(':server 353 AndroidIRCX = #room :@AndroidIRCX spammer'); + transport.emit(':server 366 AndroidIRCX #room :End'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + final room = controller.tabs.firstWhere((tab) => tab.name == '#room'); + controller.selectTab(room.id); + + transport.emit(':spammer!id@evil.example PRIVMSG #room :buy now'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect( + controller.activeMessages.any( + (message) => message.content == 'buy now', + ), + isFalse, + ); + expect( + transport.sentLines, + contains('MODE #room +b spammer!*@evil.example'), + ); + expect(transport.sentLines, contains('KICK #room spammer :spam')); + expect( + controller.activeMessages.any( + (message) => message.content.contains('Blacklist kick + ban matched'), + ), + isTrue, + ); + + controller.dispose(); + }, + ); + + test('moderation action uses ban mask preview and timed removal', () async { + final transport = _FakeTransport(); + final service = IrcService(transportConnector: (_) async => transport); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + ), + ircService: service, + ); + + await controller.start(); + transport.emit(':AndroidIRCX!me@host JOIN #room'); + transport.emit( + ':server 353 AndroidIRCX = #room :@AndroidIRCX alice!id@host.example', + ); + transport.emit(':server 366 AndroidIRCX #room :End'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + final room = controller.tabs.firstWhere((tab) => tab.name == '#room'); + controller.selectTab(room.id); + + expect(controller.banMaskPreviewForNick('alice', 2), '*!*@host.example'); + await controller.performChannelModerationAction( + nick: 'alice', + action: ChannelModerationAction.kickBan, + reason: 'cleanup', + banMaskType: 2, + timedRemoval: const Duration(milliseconds: 1), + ); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(transport.sentLines, contains('MODE #room +b *!*@host.example')); + expect(transport.sentLines, contains('KICK #room alice :cleanup')); + expect(transport.sentLines, contains('MODE #room -b *!*@host.example')); + + controller.dispose(); + }); } diff --git a/test/interactive_message_parser_test.dart b/test/interactive_message_parser_test.dart new file mode 100644 index 0000000..6413502 --- /dev/null +++ b/test/interactive_message_parser_test.dart @@ -0,0 +1,89 @@ +import 'package:androidircx/irc/parser/interactive_message_parser.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('keeps IRC style while exposing links, channels, and nicks', () { + final tokens = parseInteractiveMessageTokens( + 'Hi \u0002Alice\u0002 join #flutter and https://example.com', + knownNicks: const ['Alice'], + ); + + final nick = tokens.firstWhere( + (token) => token.type == InteractiveMessageTokenType.nick, + ); + expect(nick.text, 'Alice'); + expect(nick.value, 'Alice'); + expect(nick.style.bold, isTrue); + + final channel = tokens.firstWhere( + (token) => token.type == InteractiveMessageTokenType.channel, + ); + expect(channel.text, '#flutter'); + expect(channel.value, '#flutter'); + + final link = tokens.firstWhere( + (token) => token.type == InteractiveMessageTokenType.url, + ); + expect(link.text, 'https://example.com'); + expect(link.url, 'https://example.com'); + }); + + test('preserves punctuation around channels and nicks', () { + final tokens = parseInteractiveMessageTokens( + '(@bob), check #ops:', + knownNicks: const ['bob'], + ); + + expect(tokens.map((token) => token.text).join(), '(@bob), check #ops:'); + expect( + tokens.where((token) => token.type == InteractiveMessageTokenType.nick), + hasLength(1), + ); + expect( + tokens.where( + (token) => token.type == InteractiveMessageTokenType.channel, + ), + hasLength(1), + ); + }); + + test('does not split URLs into nick or channel tokens', () { + final tokens = parseInteractiveMessageTokens( + 'Open https://example.com/#alice for alice', + knownNicks: const ['alice'], + ); + + expect( + tokens.where((token) => token.type == InteractiveMessageTokenType.url), + hasLength(1), + ); + expect( + tokens.where( + (token) => token.type == InteractiveMessageTokenType.channel, + ), + isEmpty, + ); + expect( + tokens.where((token) => token.type == InteractiveMessageTokenType.nick), + hasLength(1), + ); + }); + + test('resolves hostmask and userhost tokens to the message context nick', () { + final tokens = parseInteractiveMessageTokens( + 'alice!ident@example.net ident@example.net', + knownNicks: const ['alice'], + contextNick: 'alice', + ); + + final hostmask = tokens.firstWhere( + (token) => token.type == InteractiveMessageTokenType.hostmask, + ); + expect(hostmask.value, 'alice'); + + final userHost = tokens.firstWhere( + (token) => token.type == InteractiveMessageTokenType.userHost, + ); + expect(userHost.value, 'alice'); + }); +} diff --git a/test/link_preview_service_test.dart b/test/link_preview_service_test.dart index 03a760f..6a01b39 100644 --- a/test/link_preview_service_test.dart +++ b/test/link_preview_service_test.dart @@ -4,7 +4,8 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('parseLinkPreview', () { test('parses OpenGraph metadata', () { - const html = '' + const html = + '' '' '' '' @@ -16,6 +17,35 @@ void main() { expect(preview.hasContent, isTrue); }); + test('resolves image, favicon, and site metadata URLs', () { + const html = + '' + '' + '' + '' + ''; + final preview = parseLinkPreview( + html, + 'https://example.com/articles/post', + ); + expect(preview.siteName, 'Example Site'); + expect(preview.imageUrl, 'https://example.com/img/card.png'); + expect(preview.faviconUrl, 'https://cdn.example.com/favicon.png'); + }); + + test('falls back to twitter image and YouTube thumbnails', () { + const twitter = ''; + expect( + parseLinkPreview(twitter, 'https://example.com/post').imageUrl, + 'https://example.com/thumb.jpg', + ); + + expect( + parseLinkPreview('', 'https://youtu.be/abc123').imageUrl, + 'https://img.youtube.com/vi/abc123/hqdefault.jpg', + ); + }); + test('falls back to and decodes entities', () { const html = '<head><title>Just Title & More'; final preview = parseLinkPreview(html, 'https://example.com'); @@ -23,8 +53,7 @@ void main() { }); test('handles content-before-property attribute order', () { - const html = - ''; + const html = ''; expect(parseLinkPreview(html, 'https://x').title, 'Reversed'); }); }); diff --git a/test/message_interaction_widget_test.dart b/test/message_interaction_widget_test.dart new file mode 100644 index 0000000..c210504 --- /dev/null +++ b/test/message_interaction_widget_test.dart @@ -0,0 +1,106 @@ +import 'dart:async'; + +import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/features/chat/application/chat_session_controller.dart'; +import 'package:androidircx/features/chat/presentation/chat_screen.dart'; +import 'package:androidircx/irc/services/irc_service.dart'; +import 'package:androidircx/irc/services/irc_transport.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeTransport implements IrcTransport { + final StreamController _controller = + StreamController.broadcast(); + + @override + Stream get lines => _controller.stream; + + final sentLines = []; + + void emit(String line) => _controller.add(line); + + @override + Future sendLine(String line) async { + sentLines.add(line); + } + + @override + Future close() async { + await _controller.close(); + } +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('message channel and nick tokens run chat actions', ( + tester, + ) async { + final transports = <_FakeTransport>[]; + final service = IrcService( + transportConnector: (_) async { + final t = _FakeTransport(); + transports.add(t); + return t; + }, + ); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + ), + ircService: service, + ); + + await tester.pumpWidget( + MaterialApp(home: ChatScreen(controller: controller)), + ); + await tester.pump(); + final transport = transports.single; + transport.emit(':server 001 AndroidIRCX :Welcome'); + transport.emit(':AndroidIRCX!u@h JOIN #room'); + transport.emit(':server 353 AndroidIRCX = #room :@AndroidIRCX alice bob'); + transport.emit(':server 366 AndroidIRCX #room :End of NAMES'); + transport.emit(':alice!id@host PRIVMSG #room :hello bob join #dart'); + await tester.pumpAndSettle(const Duration(milliseconds: 50)); + final roomTab = controller.tabs.firstWhere((tab) => tab.name == '#room'); + controller.selectTab(roomTab.id); + await tester.pump(); + + expect(controller.activeTab.name, '#room'); + expect( + controller + .messagesForTab(controller.activeTabId) + .any((message) => message.content.contains('#dart')), + isTrue, + ); + + await tester.tap(_richTextContaining('bob')); + await tester.pump(); + expect( + controller.tabs.any( + (tab) => tab.type.name == 'query' && tab.name == 'bob', + ), + isTrue, + ); + + controller.selectTab(roomTab.id); + await tester.pump(); + await tester.tap(_richTextContaining('#dart')); + await tester.pump(); + expect(transport.sentLines, contains('JOIN #dart')); + + controller.dispose(); + }); +} + +Finder _richTextContaining(String text) { + return find.byWidgetPredicate( + (widget) => widget is RichText && widget.text.toPlainText().contains(text), + description: 'RichText containing "$text"', + ); +} diff --git a/test/user_lists_test.dart b/test/user_lists_test.dart index df5b7ed..3ab7797 100644 --- a/test/user_lists_test.dart +++ b/test/user_lists_test.dart @@ -19,10 +19,14 @@ void main() { }); test('wildcard host mask', () { - expect(maskMatches('*!*@*.example.net', 'bob!id@host.example.net'), - isTrue); - expect(maskMatches('*!*@*.example.net', 'bob!id@host.other.org'), - isFalse); + expect( + maskMatches('*!*@*.example.net', 'bob!id@host.example.net'), + isTrue, + ); + expect( + maskMatches('*!*@*.example.net', 'bob!id@host.other.org'), + isFalse, + ); }); test('is case-insensitive', () { @@ -94,12 +98,8 @@ void main() { test('remove by identity', () async { final r = repo(); - await r.add( - const UserListEntry(type: UserListType.autoOp, mask: 'a'), - ); - await r.add( - const UserListEntry(type: UserListType.autoVoice, mask: 'b'), - ); + await r.add(const UserListEntry(type: UserListType.autoOp, mask: 'a')); + await r.add(const UserListEntry(type: UserListType.autoVoice, mask: 'b')); await r.remove(const UserListEntry(type: UserListType.autoOp, mask: 'a')); final loaded = await r.loadAll(); expect(loaded, hasLength(1)); @@ -119,5 +119,24 @@ void main() { expect(decoded.channels, ['#a', '#b']); expect(decoded.network, 'dbase'); }); + + test('json round-trip preserves blacklist action metadata', () { + const e = UserListEntry( + type: UserListType.blacklist, + mask: 'bad!*@evil', + network: 'dbase', + blacklistAction: BlacklistAction.custom, + reason: 'spam', + duration: Duration(minutes: 30), + customRaw: 'GLINE {hostmask} {duration} :{reason}', + ); + final decoded = UserListEntry.fromJson(e.toJson()); + expect(decoded, isNotNull); + expect(decoded!.type, UserListType.blacklist); + expect(decoded.effectiveBlacklistAction, BlacklistAction.custom); + expect(decoded.reason, 'spam'); + expect(decoded.duration, const Duration(minutes: 30)); + expect(decoded.customRaw, 'GLINE {hostmask} {duration} :{reason}'); + }); }); } diff --git a/web/favicon.png b/web/favicon.png index 8aaa46a..e34827b 100644 Binary files a/web/favicon.png and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png index b749bfe..fde254a 100644 Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png index 88cfd48..e7ed8e2 100644 Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png index eb9b4d7..fde254a 100644 Binary files a/web/icons/Icon-maskable-192.png and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png index d69c566..e7ed8e2 100644 Binary files a/web/icons/Icon-maskable-512.png and b/web/icons/Icon-maskable-512.png differ diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index c04e20c..c8f9f22 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ