diff --git a/README.md b/README.md index 2cefa50..f09f4e8 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ existing React Native application. [![Build](https://github.com/AndroidIRCx/AndroidIRCx-Flutter/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/AndroidIRCx/AndroidIRCx-Flutter/actions/workflows/ci.yml) [![GitHub Release](https://img.shields.io/github/v/release/AndroidIRCx/AndroidIRCx-Flutter)](https://github.com/AndroidIRCx/AndroidIRCx-Flutter/releases) [![Downloads](https://img.shields.io/github/downloads/AndroidIRCx/AndroidIRCx-Flutter/total)](https://github.com/AndroidIRCx/AndroidIRCx-Flutter/releases) -[![GitHub License](https://img.shields.io/github/license/AndroidIRCx/AndroidIRCx-Flutter)](https://github.com/AndroidIRCx/AndroidIRCx-Flutter/blob/main/LICENSE) +[![GitHub License](https://img.shields.io/github/license/AndroidIRCx/AndroidIRCx-Flutter)](https://github.com/AndroidIRCx/AndroidIRCx-Flutter/blob/main/LICENSE.md) [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/11929/badge)](https://www.bestpractices.dev/projects/11929) [![CodeQL](https://img.shields.io/github/actions/workflow/status/AndroidIRCx/AndroidIRCx-Flutter/codeql.yml?label=CodeQL&branch=main)](https://github.com/AndroidIRCx/AndroidIRCx-Flutter/security/code-scanning) @@ -17,7 +17,6 @@ existing React Native application. [![GitHub Issues](https://img.shields.io/github/issues/AndroidIRCx/AndroidIRCx-Flutter)](https://github.com/AndroidIRCx/AndroidIRCx-Flutter/issues) [![Last Commit](https://img.shields.io/github/last-commit/AndroidIRCx/AndroidIRCx-Flutter/main)](https://github.com/AndroidIRCx/AndroidIRCx-Flutter/commits/main) -[![Google Play](https://img.shields.io/badge/Google%20Play-Download-green)](https://play.google.com/store/apps/details?id=com.androidircx) ## Current Status diff --git a/lib/core/models/network_config.dart b/lib/core/models/network_config.dart index 9221e30..7d2a863 100644 --- a/lib/core/models/network_config.dart +++ b/lib/core/models/network_config.dart @@ -1,6 +1,7 @@ enum SaslMechanism { plain, scramSha256, + external, } class NetworkConfig { diff --git a/lib/features/chat/application/chat_session_controller.dart b/lib/features/chat/application/chat_session_controller.dart index 129766a..05295b4 100644 --- a/lib/features/chat/application/chat_session_controller.dart +++ b/lib/features/chat/application/chat_session_controller.dart @@ -70,6 +70,8 @@ class ChatSessionController extends ChangeNotifier { Duration? get pendingReconnectDelay => _pendingReconnectDelay; ChatTab get activeTab => _tabs.firstWhere((tab) => tab.id == _activeTabId); String get currentNick => _ircService.currentNick ?? network.nickname; + int get activityCount => _tabs.where((tab) => tab.hasActivity).length; + bool get hasActivity => activityCount > 0; String? get activeChannelTopic => _channelTopics[activeTabId]; String? get activeChannelModes => _channelModes[activeTabId]; String get activeChannelSummary { @@ -313,10 +315,52 @@ class ChatSessionController extends ChangeNotifier { final target = rest.substring(0, space); final text = rest.substring(space + 1).trim(); if (text.isNotEmpty) { + final tabId = _resolveOutgoingMessageTabId(target); + if (!target.startsWith('#')) { + _activeTabId = tabId; + } await _ircService.sendNotice(target: target, text: text); + _appendMessage( + tabId: tabId, + sender: currentNick, + content: text, + isOwn: true, + ); + unawaited(_persistState()); + notifyListeners(); return; } } + case 'nickserv': + if (rest.isNotEmpty) { + await _sendServiceCommand('NickServ', rest); + return; + } + case 'chanserv': + if (rest.isNotEmpty) { + await _sendServiceCommand('ChanServ', rest); + return; + } + case 'hostserv': + if (rest.isNotEmpty) { + await _sendServiceCommand('HostServ', rest); + return; + } + case 'operserv': + if (rest.isNotEmpty) { + await _sendServiceCommand('OperServ', rest); + return; + } + case 'memoserv': + if (rest.isNotEmpty) { + await _sendServiceCommand('MemoServ', rest); + return; + } + case 'botserv': + if (rest.isNotEmpty) { + await _sendServiceCommand('BotServ', rest); + return; + } case 'me': if (rest.isNotEmpty && activeTab.type != ChatTabType.server) { await _ircService.sendAction(target: activeTab.name, text: rest); @@ -357,6 +401,61 @@ class ChatSessionController extends ChangeNotifier { await _ircService.sendNames(rest.split(' ').first); return; } + case 'list': + await _ircService.sendList(rest.isEmpty ? null : rest); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: rest.isEmpty ? 'Requested channel list.' : 'Requested channel list for: $rest', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return; + case 'motd': + await _ircService.sendMotd(); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: 'Requested MOTD.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return; + case 'time': + await _ircService.sendTime(rest.isEmpty ? null : rest); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: rest.isEmpty ? 'Requested server time.' : 'Requested time for: $rest', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return; + case 'version': + await _ircService.sendVersion(rest.isEmpty ? null : rest); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: rest.isEmpty ? 'Requested server version.' : 'Requested version for: $rest', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return; + case 'links': + await _ircService.sendLinks(rest.isEmpty ? null : rest); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: rest.isEmpty ? 'Requested server links.' : 'Requested links for: $rest', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return; case 'invite': if (rest.isNotEmpty && activeTab.type == ChatTabType.channel) { await _ircService.sendInvite( @@ -365,6 +464,73 @@ class ChatSessionController extends ChangeNotifier { ); return; } + case 'ban': + if (rest.isNotEmpty && activeTab.type == ChatTabType.channel) { + await _ircService.sendChannelMode( + channel: activeTab.name, + mode: '+b', + target: rest.split(' ').first, + ); + return; + } + case 'unban': + if (rest.isNotEmpty && activeTab.type == ChatTabType.channel) { + await _ircService.sendChannelMode( + channel: activeTab.name, + mode: '-b', + target: rest.split(' ').first, + ); + return; + } + case 'op': + if (rest.isNotEmpty && activeTab.type == ChatTabType.channel) { + await _ircService.sendChannelMode( + channel: activeTab.name, + mode: '+o', + target: rest.split(' ').first, + ); + return; + } + case 'deop': + if (rest.isNotEmpty && activeTab.type == ChatTabType.channel) { + await _ircService.sendChannelMode( + channel: activeTab.name, + mode: '-o', + target: rest.split(' ').first, + ); + return; + } + case 'voice': + if (rest.isNotEmpty && activeTab.type == ChatTabType.channel) { + await _ircService.sendChannelMode( + channel: activeTab.name, + mode: '+v', + target: rest.split(' ').first, + ); + return; + } + case 'devoice': + if (rest.isNotEmpty && activeTab.type == ChatTabType.channel) { + await _ircService.sendChannelMode( + channel: activeTab.name, + mode: '-v', + target: rest.split(' ').first, + ); + return; + } + case 'banlist': + if (activeTab.type == ChatTabType.channel) { + await _ircService.sendBanList(activeTab.name); + _appendMessage( + tabId: activeTab.id, + sender: '*', + content: 'Requested ban list for ${activeTab.name}.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return; + } case 'kick': if (rest.isNotEmpty && activeTab.type == ChatTabType.channel) { final parts = rest.split(' '); @@ -388,6 +554,31 @@ class ChatSessionController extends ChangeNotifier { await _ircService.sendMode(args); return; } + case 'cap': + await _handleCapCommand(rest); + return; + case 'away': + await _ircService.sendAway(rest.isEmpty ? null : rest); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: rest.isEmpty ? 'Away status cleared.' : 'Away: $rest', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return; + case 'back': + await _ircService.sendAway(); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: 'Away status cleared.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return; case 'quote': case 'raw': if (rest.isNotEmpty) { @@ -416,15 +607,48 @@ class ChatSessionController extends ChangeNotifier { case '002': case '003': case '004': + case '371': case '372': + case '374': case '375': case '376': + case '391': _appendMessage( tabId: _serverTabId(network.id), sender: '*', content: frame.trailing ?? frame.params.join(' '), kind: IrcMessageKind.system, ); + case '351': + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: frame.trailing == null + ? 'Server version: ${frame.params.skip(1).join(' ')}'.trim() + : 'Server version: ${'${frame.params.skip(1).join(' ')} ${frame.trailing!}'.trim()}', + kind: IrcMessageKind.system, + ); + case '364': + if (frame.params.length >= 3) { + final server = frame.params[1]; + final hopCount = frame.params[2]; + final info = frame.trailing ?? ''; + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: info.isEmpty + ? 'Link: $server ($hopCount)' + : 'Link: $server ($hopCount) - $info', + kind: IrcMessageKind.system, + ); + } + case '365': + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: frame.trailing ?? 'End of LINKS.', + kind: IrcMessageKind.system, + ); case '332': if (frame.params.length >= 2 && frame.trailing != null) { final channel = frame.params[1]; @@ -474,6 +698,35 @@ class ChatSessionController extends ChangeNotifier { kind: IrcMessageKind.system, ); } + case '321': + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: 'Channel list started.', + kind: IrcMessageKind.system, + ); + case '322': + if (frame.params.length >= 3) { + final channel = frame.params[1]; + final visibleCount = frame.params[2]; + final topic = frame.trailing ?? ''; + final details = topic.isEmpty + ? '$channel ($visibleCount users)' + : '$channel ($visibleCount users) - $topic'; + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: details, + kind: IrcMessageKind.system, + ); + } + case '323': + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: frame.trailing ?? 'End of channel list.', + kind: IrcMessageKind.system, + ); case '311': _appendWhoisMessage( frame, @@ -503,6 +756,14 @@ class ChatSessionController extends ChangeNotifier { frame, 'WHOIS idle: ${frame.params.length > 2 ? '${frame.params[1]} idle ${frame.params[2]}s' : frame.raw}', ); + case '305': + case '306': + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: frame.trailing ?? frame.raw, + kind: IrcMessageKind.system, + ); case '319': _appendWhoisMessage( frame, @@ -570,6 +831,31 @@ class ChatSessionController extends ChangeNotifier { kind: IrcMessageKind.system, ); } + case '367': + if (frame.params.length >= 3) { + final channel = frame.params[1]; + final mask = frame.params[2]; + final setBy = frame.params.length > 3 ? frame.params[3] : null; + final tab = _ensureChannelTab(channel); + final details = setBy == null ? 'Ban: $mask' : 'Ban: $mask set by $setBy'; + _appendMessage( + tabId: tab.id, + sender: '*', + content: details, + kind: IrcMessageKind.system, + ); + } + case '368': + if (frame.params.length >= 2) { + final channel = frame.params[1]; + final tab = _ensureChannelTab(channel); + _appendMessage( + tabId: tab.id, + sender: '*', + content: frame.trailing ?? 'End of ban list.', + kind: IrcMessageKind.system, + ); + } case 'JOIN': final channel = frame.trailing ?? _firstOrNull(frame.params); if (channel != null) { @@ -620,6 +906,8 @@ class ChatSessionController extends ChangeNotifier { '${frame.senderNick ?? '*'} is now known as ${frame.trailing ?? _firstOrNull(frame.params) ?? '?'}', kind: IrcMessageKind.system, ); + case 'CAP': + _handleCapabilityFrame(frame); case 'NOTICE': _handleNotice(frame); case 'TOPIC': @@ -661,9 +949,11 @@ class ChatSessionController extends ChangeNotifier { return; } - final tabId = target.startsWith('#') - ? _ensureChannelTab(target).id - : _serverTabId(network.id); + final tabId = _resolveMessageTabId( + target: target, + senderNick: frame.senderNick, + preferServerForDirectMessages: false, + ); _appendMessage( tabId: tabId, @@ -680,17 +970,18 @@ class ChatSessionController extends ChangeNotifier { return; } - final isChannel = target.startsWith('#'); - final tab = isChannel - ? _ensureChannelTab(target) - : _ensureQueryTab(frame.senderNick ?? target); + final tabId = _resolveMessageTabId( + target: target, + senderNick: frame.senderNick, + preferServerForDirectMessages: false, + ); _appendMessage( - tabId: tab.id, + tabId: tabId, sender: frame.senderNick ?? target, content: _normalizeContent(content), ); - _markActivityIfInactive(tab.id); + _markActivityIfInactive(tabId); } void _handleTopic(IrcMessageFrame frame) { @@ -775,6 +1066,35 @@ class ChatSessionController extends ChangeNotifier { return tab; } + String _resolveMessageTabId({ + required String target, + required String? senderNick, + required bool preferServerForDirectMessages, + }) { + if (target.startsWith('#')) { + return _ensureChannelTab(target).id; + } + + final normalizedSender = _normalizeServiceNick(senderNick); + if (normalizedSender != null && _isServiceNick(normalizedSender)) { + return _ensureQueryTab(normalizedSender).id; + } + + if (preferServerForDirectMessages) { + return _serverTabId(network.id); + } + + return _ensureQueryTab(senderNick ?? target).id; + } + + String _resolveOutgoingMessageTabId(String target) { + if (target.startsWith('#')) { + return _ensureChannelTab(target).id; + } + + return _ensureQueryTab(target).id; + } + ChatTab? _findTab(String id) { for (final tab in _tabs) { if (tab.id == id) { @@ -864,10 +1184,190 @@ class ChatSessionController extends ChangeNotifier { _markActivityIfInactive(targetTabId); } + Future _sendServiceCommand(String service, String command) async { + final tab = _ensureQueryTab(service); + _activeTabId = tab.id; + await _ircService.sendPrivmsg(target: service, text: command); + _appendMessage( + tabId: tab.id, + sender: currentNick, + content: command, + isOwn: true, + ); + unawaited(_persistState()); + notifyListeners(); + } + + Future sendServiceShortcut(String service, String command) { + return _sendServiceCommand(service, command); + } + + void _handleCapabilityFrame(IrcMessageFrame frame) { + if (frame.params.length < 2) { + return; + } + + final subcommandIndex = frame.params.first == '*' ? 1 : 0; + if (subcommandIndex >= frame.params.length) { + return; + } + + final subcommand = frame.params[subcommandIndex].toUpperCase(); + final details = [ + ...frame.params.skip(subcommandIndex + 1).where((item) => item != '*'), + if ((frame.trailing ?? '').trim().isNotEmpty) frame.trailing!.trim(), + ].join(' ').trim(); + + final message = switch (subcommand) { + 'LS' => 'CAP LS: ${details.isEmpty ? 'no capabilities reported' : details}', + 'ACK' => 'CAP ACK: ${details.isEmpty ? 'no capabilities acknowledged' : details}', + 'NAK' => 'CAP NAK: ${details.isEmpty ? 'request rejected' : details}', + 'NEW' => 'CAP NEW: ${details.isEmpty ? 'no new capabilities reported' : details}', + 'DEL' => 'CAP DEL: ${details.isEmpty ? 'no removed capabilities reported' : details}', + 'LIST' => 'CAP LIST: ${details.isEmpty ? 'no enabled capabilities reported' : details}', + _ => 'CAP $subcommand${details.isEmpty ? '' : ': $details'}', + }; + + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: message, + kind: IrcMessageKind.system, + ); + } + + Future _handleCapCommand(String rest) async { + final trimmed = rest.trim(); + if (trimmed.isEmpty) { + await _ircService.sendCapList(); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: 'Requested CAP LIST.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return; + } + + final parts = trimmed.split(RegExp(r'\s+')); + final subcommand = parts.first.toLowerCase(); + final args = parts.skip(1).join(' ').trim(); + + switch (subcommand) { + case 'status': + final available = _sortedCapabilities(_ircService.availableCapabilities); + final enabled = _sortedCapabilities(_ircService.enabledCapabilities); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: 'Available capabilities: ${available.isEmpty ? 'none' : available.join(', ')}', + kind: IrcMessageKind.system, + ); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: 'Enabled capabilities: ${enabled.isEmpty ? 'none' : enabled.join(', ')}', + kind: IrcMessageKind.system, + ); + break; + case 'ls': + await _ircService.sendCapLs(); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: 'Requested CAP LS 302.', + kind: IrcMessageKind.system, + ); + break; + case 'list': + await _ircService.sendCapList(); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: 'Requested CAP LIST.', + kind: IrcMessageKind.system, + ); + break; + case 'req': + if (args.isEmpty) { + _appendMessage( + tabId: _serverTabId(network.id), + sender: 'error', + content: 'Usage: /cap req ', + kind: IrcMessageKind.system, + ); + } else { + await _ircService.sendCapReq(args); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: 'Requested capabilities: $args', + kind: IrcMessageKind.system, + ); + } + break; + case 'end': + await _ircService.sendCapEnd(); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: 'Ended capability negotiation.', + kind: IrcMessageKind.system, + ); + break; + default: + _appendMessage( + tabId: _serverTabId(network.id), + sender: 'error', + content: 'Usage: /cap ', + kind: IrcMessageKind.system, + ); + break; + } + + unawaited(_persistState()); + notifyListeners(); + } + String _normalizeNickPrefix(String value) { return value.replaceFirst(RegExp(r'^[~&@%+]'), ''); } + List _sortedCapabilities(Set values) { + final list = values.toList(growable: false); + list.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return list; + } + + String? _normalizeServiceNick(String? value) { + if (value == null) { + return null; + } + + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return null; + } + + return trimmed; + } + + bool _isServiceNick(String nick) { + switch (nick.toLowerCase()) { + case 'nickserv': + case 'chanserv': + case 'hostserv': + case 'memoserv': + case 'botserv': + case 'operserv': + return true; + default: + return false; + } + } + void _removeUserFromAllChannels(String? nick) { if (nick == null || nick.isEmpty) { return; diff --git a/lib/features/chat/application/command_service.dart b/lib/features/chat/application/command_service.dart index 8b7e843..d9463f2 100644 --- a/lib/features/chat/application/command_service.dart +++ b/lib/features/chat/application/command_service.dart @@ -51,6 +51,12 @@ class CommandService { 'w': const CommandAlias(alias: 'w', command: '/whois'), 'n': const CommandAlias(alias: 'n', command: '/nick'), 'm': const CommandAlias(alias: 'm', command: '/msg'), + 'ns': const CommandAlias(alias: 'ns', command: '/nickserv'), + 'cs': const CommandAlias(alias: 'cs', command: '/chanserv'), + 'hs': const CommandAlias(alias: 'hs', command: '/hostserv'), + 'os': const CommandAlias(alias: 'os', command: '/operserv'), + 'ms': const CommandAlias(alias: 'ms', command: '/memoserv'), + 'bs': const CommandAlias(alias: 'bs', command: '/botserv'), }; List _history = const []; diff --git a/lib/features/chat/application/session_registry.dart b/lib/features/chat/application/session_registry.dart index 264a079..a51ce66 100644 --- a/lib/features/chat/application/session_registry.dart +++ b/lib/features/chat/application/session_registry.dart @@ -36,6 +36,10 @@ class SessionRegistry extends ChangeNotifier { return _sessions[networkId]?.currentNick; } + int activityCountFor(String networkId) { + return _sessions[networkId]?.activityCount ?? 0; + } + Future closeSession(String networkId) async { final controller = _sessions.remove(networkId); final listener = _listeners.remove(networkId); @@ -51,6 +55,13 @@ class SessionRegistry extends ChangeNotifier { notifyListeners(); } + Future closeAllSessions() async { + final networkIds = _sessions.keys.toList(growable: false); + for (final networkId in networkIds) { + await closeSession(networkId); + } + } + @override void dispose() { for (final entry in _sessions.entries) { diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index 4dec01f..fb16efe 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -5,6 +5,7 @@ import 'package:androidircx/core/models/network_config.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/presentation/join_channel_dialog.dart'; +import 'package:androidircx/irc/parser/irc_formatter.dart'; import 'package:androidircx/features/settings/presentation/settings_screen.dart'; import 'package:flutter/material.dart'; @@ -194,6 +195,12 @@ class _ChatScreenState extends State { ), if ((_controller.activeChannelTopic ?? '').trim().isNotEmpty) _ChannelTopicBar(topic: _controller.activeChannelTopic!.trim()), + if (_controller.activeTab.type == ChatTabType.server) + _ServiceQuickActions( + onRun: (service, command) async { + await _controller.sendServiceShortcut(service, command); + }, + ), Expanded( child: _MessageList(messages: _controller.activeMessages), ), @@ -314,11 +321,11 @@ class _ChannelTopicBar extends StatelessWidget { borderRadius: BorderRadius.circular(14), border: Border.all(color: Colors.black.withValues(alpha: 0.08)), ), - child: Text( + child: _IrcFormattedText( topic, maxLines: 2, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, + baseStyle: Theme.of(context).textTheme.bodySmall, ), ); } @@ -359,6 +366,47 @@ class _CommandHistoryBar extends StatelessWidget { } } +class _ServiceQuickActions extends StatelessWidget { + const _ServiceQuickActions({ + required this.onRun, + }); + + final Future Function(String service, String command) onRun; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + const actions = <(String, String, String)>[ + ('NickServ', 'HELP', 'NickServ HELP'), + ('ChanServ', 'HELP', 'ChanServ HELP'), + ('HostServ', 'HELP', 'HostServ HELP'), + ('MemoServ', 'HELP', 'MemoServ HELP'), + ('BotServ', 'HELP', 'BotServ HELP'), + ('OperServ', 'HELP', 'OperServ HELP'), + ]; + + return SizedBox( + height: 48, + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + scrollDirection: Axis.horizontal, + itemCount: actions.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final action = actions[index]; + return ActionChip( + label: Text( + action.$3, + style: theme.textTheme.labelMedium, + ), + onPressed: () => onRun(action.$1, action.$2), + ); + }, + ), + ); + } +} + class _MessageList extends StatelessWidget { const _MessageList({ required this.messages, @@ -402,7 +450,10 @@ class _MessageList extends StatelessWidget { ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Text(message.content), + child: _IrcFormattedText( + message.content, + baseStyle: Theme.of(context).textTheme.bodyMedium, + ), ), ), ], @@ -413,6 +464,108 @@ class _MessageList extends StatelessWidget { } } +class _IrcFormattedText extends StatelessWidget { + const _IrcFormattedText( + this.text, { + this.baseStyle, + this.maxLines, + this.overflow, + }); + + final String text; + final TextStyle? baseStyle; + final int? maxLines; + final TextOverflow? overflow; + + @override + Widget build(BuildContext context) { + final segments = parseIrcTextWithLinks(text); + if (segments.isEmpty) { + return Text( + text, + style: baseStyle, + maxLines: maxLines, + overflow: overflow, + ); + } + + return Text.rich( + TextSpan( + children: segments + .map( + (segment) => TextSpan( + text: segment.text, + style: _resolveTextStyle(baseStyle, segment), + ), + ) + .toList(growable: false), + ), + style: baseStyle, + maxLines: maxLines, + overflow: overflow, + ); + } + + TextStyle _resolveTextStyle(TextStyle? base, IrcLinkSegment segment) { + final style = segment.style; + var foreground = style.color; + var background = style.background; + + if (style.reverse && foreground != null && background != null) { + final swappedForeground = background; + background = foreground; + foreground = swappedForeground; + } else if (style.reverse && foreground != null) { + background = foreground; + foreground = null; + } else if (style.reverse && background != null) { + foreground = background; + background = null; + } + + var textStyle = base ?? const TextStyle(); + final foregroundHex = foreground == null ? null : getIrcColorHex(foreground); + final backgroundHex = background == null ? null : getIrcColorHex(background); + + if (foregroundHex != null) { + textStyle = textStyle.copyWith(color: _parseHexColor(foregroundHex)); + } + if (backgroundHex != null) { + textStyle = textStyle.copyWith(backgroundColor: _parseHexColor(backgroundHex)); + } + if (style.bold) { + textStyle = textStyle.copyWith(fontWeight: FontWeight.bold); + } + if (style.italic) { + textStyle = textStyle.copyWith(fontStyle: FontStyle.italic); + } + + final decorations = {}; + if (style.underline || segment.isLink) { + decorations.add(TextDecoration.underline); + } + if (style.strikethrough) { + decorations.add(TextDecoration.lineThrough); + } + if (decorations.isNotEmpty) { + textStyle = textStyle.copyWith( + decoration: TextDecoration.combine(decorations.toList(growable: false)), + ); + } + + if (segment.isLink && foregroundHex == null) { + textStyle = textStyle.copyWith(color: const Color(0xFF1565C0)); + } + + return textStyle; + } + + Color _parseHexColor(String value) { + final normalized = value.replaceFirst('#', ''); + return Color(int.parse('FF$normalized', radix: 16)); + } +} + class _ConnectionBanner extends StatelessWidget { const _ConnectionBanner({ required this.controller, diff --git a/lib/features/connections/presentation/network_form_screen.dart b/lib/features/connections/presentation/network_form_screen.dart index 6cd7ac5..c3f469e 100644 --- a/lib/features/connections/presentation/network_form_screen.dart +++ b/lib/features/connections/presentation/network_form_screen.dart @@ -145,17 +145,22 @@ class _NetworkFormScreenState extends State { const SizedBox(height: 16), TextFormField( controller: _saslAccountController, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'SASL account', - helperText: 'Optional. Enables SASL PLAIN when combined with a password.', + helperText: _saslMechanism == SaslMechanism.external + ? 'Optional. Usually not required for EXTERNAL.' + : 'Required for PLAIN and SCRAM-SHA-256.', ), ), const SizedBox(height: 16), TextFormField( controller: _saslPasswordController, obscureText: true, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'SASL password', + helperText: _saslMechanism == SaslMechanism.external + ? 'Leave empty when client certificate auth is used.' + : 'Required for PLAIN and SCRAM-SHA-256.', ), ), const SizedBox(height: 16), @@ -173,6 +178,10 @@ class _NetworkFormScreenState extends State { value: SaslMechanism.scramSha256, child: Text('SCRAM-SHA-256'), ), + DropdownMenuItem( + value: SaslMechanism.external, + child: Text('EXTERNAL'), + ), ], onChanged: (value) { if (value == null) { diff --git a/lib/features/connections/presentation/network_list_screen.dart b/lib/features/connections/presentation/network_list_screen.dart index 9d41924..dad2682 100644 --- a/lib/features/connections/presentation/network_list_screen.dart +++ b/lib/features/connections/presentation/network_list_screen.dart @@ -53,6 +53,7 @@ class NetworkListScreen extends StatelessWidget { registry: sessionRegistry, onOpen: (network) => _openChat(context, network), onClose: sessionRegistry.closeSession, + onCloseAll: sessionRegistry.closeAllSessions, ); } @@ -61,16 +62,27 @@ class NetworkListScreen extends StatelessWidget { sessionRegistry.connectionFor(network.id); final currentNick = sessionRegistry.currentNickFor(network.id); + final activityCount = + sessionRegistry.activityCountFor(network.id); + final hasSession = sessionRegistry.hasSession(network.id); return _NetworkCard( network: network, connection: snapshot, - hasSession: sessionRegistry.hasSession(network.id), + hasSession: hasSession, currentNick: currentNick, + activityCount: activityCount, + statusMessage: snapshot.message, onEdit: () => _openForm(context, initialValue: network), onDelete: () async { await sessionRegistry.closeSession(network.id); await controller.deleteNetwork(network.id); }, + onQuickAction: () => _handleQuickAction( + context, + network: network, + hasSession: hasSession, + phase: snapshot.phase, + ), onConnect: () => _openChat(context, network), ); }, @@ -126,6 +138,27 @@ class NetworkListScreen extends StatelessWidget { ), ); } + + Future _handleQuickAction( + BuildContext context, { + required NetworkConfig network, + required bool hasSession, + required ConnectionPhase phase, + }) async { + if (!hasSession) { + final session = sessionRegistry.obtainSession(network); + await session.start(); + return; + } + + if (phase == ConnectionPhase.connected || phase == ConnectionPhase.connecting) { + await sessionRegistry.closeSession(network.id); + return; + } + + final session = sessionRegistry.obtainSession(network); + await session.start(); + } } class _NetworkCard extends StatelessWidget { @@ -134,8 +167,11 @@ class _NetworkCard extends StatelessWidget { required this.connection, required this.hasSession, required this.currentNick, + required this.activityCount, + required this.statusMessage, required this.onEdit, required this.onDelete, + required this.onQuickAction, required this.onConnect, }); @@ -143,8 +179,11 @@ class _NetworkCard extends StatelessWidget { final ConnectionSnapshot connection; final bool hasSession; final String? currentNick; + final int activityCount; + final String? statusMessage; final VoidCallback onEdit; final VoidCallback onDelete; + final Future Function() onQuickAction; final VoidCallback onConnect; @override @@ -211,6 +250,15 @@ class _NetworkCard extends StatelessWidget { color: theme.colorScheme.primary, ), ), + if (activityCount > 0) ...[ + const SizedBox(height: 2), + Text( + 'Activity: $activityCount tabs', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.tertiary, + ), + ), + ], if ((currentNick ?? '').isNotEmpty) ...[ const SizedBox(height: 2), Text( @@ -218,12 +266,33 @@ class _NetworkCard extends StatelessWidget { style: theme.textTheme.bodySmall, ), ], + const SizedBox(height: 2), + Text( + 'Status: ${_statusPreview(connection.phase, statusMessage)}', + style: theme.textTheme.bodySmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), ], const SizedBox(height: 16), - FilledButton.icon( - onPressed: onConnect, - icon: Icon(hasSession ? Icons.forum_outlined : Icons.wifi_tethering), - label: Text(hasSession ? 'Open session' : 'Connect'), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: onConnect, + icon: Icon(hasSession ? Icons.forum_outlined : Icons.chat_bubble_outline), + label: Text(hasSession ? 'Open session' : 'Open chat'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: onQuickAction, + icon: Icon(_quickActionIcon(connection.phase, hasSession)), + label: Text(_quickActionLabel(connection.phase, hasSession)), + ), + ), + ], ), ], ), @@ -231,6 +300,30 @@ class _NetworkCard extends StatelessWidget { ); } + String _quickActionLabel(ConnectionPhase phase, bool hasSession) { + if (!hasSession) { + return 'Connect'; + } + + if (phase == ConnectionPhase.connected || phase == ConnectionPhase.connecting) { + return 'Disconnect'; + } + + return 'Reconnect'; + } + + IconData _quickActionIcon(ConnectionPhase phase, bool hasSession) { + if (!hasSession) { + return Icons.wifi_tethering; + } + + if (phase == ConnectionPhase.connected || phase == ConnectionPhase.connecting) { + return Icons.link_off; + } + + return Icons.refresh; + } + String _statusLabel(ConnectionPhase phase) { switch (phase) { case ConnectionPhase.idle: @@ -247,6 +340,15 @@ class _NetworkCard extends StatelessWidget { return 'Error'; } } + + String _statusPreview(ConnectionPhase phase, String? message) { + final value = (message ?? '').trim(); + if (value.isNotEmpty) { + return value; + } + + return _statusLabel(phase); + } } class _ActiveSessionsCard extends StatelessWidget { @@ -254,11 +356,13 @@ class _ActiveSessionsCard extends StatelessWidget { required this.registry, required this.onOpen, required this.onClose, + required this.onCloseAll, }); final SessionRegistry registry; final ValueChanged onOpen; final Future Function(String networkId) onClose; + final Future Function() onCloseAll; @override Widget build(BuildContext context) { @@ -274,9 +378,26 @@ class _ActiveSessionsCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Active sessions', - style: theme.textTheme.titleMedium, + Row( + children: [ + Expanded( + child: Text( + 'Active sessions', + style: theme.textTheme.titleMedium, + ), + ), + Text( + '${sessions.length} live', + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: onCloseAll, + child: const Text('Disconnect all'), + ), + ], ), const SizedBox(height: 12), for (final session in sessions) ...[ @@ -288,7 +409,7 @@ class _ActiveSessionsCard extends StatelessWidget { ), title: Text(session.network.name), subtitle: Text( - '${session.network.host}:${session.network.port} • ${_labelFor(session.connection.phase)}', + '${session.network.host}:${session.network.port} • ${_labelFor(session.connection.phase)} • ${session.tabs.length} tabs • ${session.currentNick}${session.activityCount > 0 ? ' • ${session.activityCount} active' : ''}', ), trailing: IconButton( onPressed: () => onClose(session.network.id), diff --git a/lib/irc/parser/irc_formatter.dart b/lib/irc/parser/irc_formatter.dart new file mode 100644 index 0000000..ad85ae9 --- /dev/null +++ b/lib/irc/parser/irc_formatter.dart @@ -0,0 +1,432 @@ +class IrcFormatCodes { + static const int bold = 0x02; + static const int color = 0x03; + static const int reset = 0x0F; + static const int reverse = 0x16; + static const int italic = 0x1D; + static const int strikethrough = 0x1E; + static const int underline = 0x1F; +} + +const Map ircStandardColorMap = { + 0: '#FFFFFF', + 1: '#000000', + 2: '#00007F', + 3: '#009300', + 4: '#FF0000', + 5: '#7F0000', + 6: '#9C009C', + 7: '#FC7F00', + 8: '#FFFF00', + 9: '#00FC00', + 10: '#009393', + 11: '#00FFFF', + 12: '#0000FC', + 13: '#FF00FF', + 14: '#7F7F7F', + 15: '#D2D2D2', +}; + +const Map ircExtendedColorMap = { + 16: '#470000', + 17: '#472100', + 18: '#474700', + 19: '#324700', + 20: '#004700', + 21: '#00472c', + 22: '#004747', + 23: '#002747', + 24: '#000047', + 25: '#2e0047', + 26: '#470047', + 27: '#47002a', + 28: '#740000', + 29: '#743a00', + 30: '#747400', + 31: '#517400', + 32: '#007400', + 33: '#007449', + 34: '#007474', + 35: '#004074', + 36: '#000074', + 37: '#4b0074', + 38: '#740074', + 39: '#740045', + 40: '#b50000', + 41: '#b56300', + 42: '#b5b500', + 43: '#7db500', + 44: '#00b500', + 45: '#00b571', + 46: '#00b5b5', + 47: '#0063b5', + 48: '#0000b5', + 49: '#7500b5', + 50: '#b500b5', + 51: '#b5006b', + 52: '#ff0000', + 53: '#ff8c00', + 54: '#ffff00', + 55: '#b2ff00', + 56: '#00ff00', + 57: '#00ffa0', + 58: '#00ffff', + 59: '#008cff', + 60: '#0000ff', + 61: '#a500ff', + 62: '#ff00ff', + 63: '#ff0098', + 64: '#ff5959', + 65: '#ffb459', + 66: '#ffff71', + 67: '#cfff60', + 68: '#6fff6f', + 69: '#65ffc9', + 70: '#6dffff', + 71: '#59b4ff', + 72: '#5959ff', + 73: '#c459ff', + 74: '#ff66ff', + 75: '#ff59bc', + 76: '#ff9c9c', + 77: '#ffd39c', + 78: '#ffff9c', + 79: '#e2ff9c', + 80: '#9cff9c', + 81: '#9cffdb', + 82: '#9cffff', + 83: '#9cd3ff', + 84: '#9c9cff', + 85: '#dc9cff', + 86: '#ff9cff', + 87: '#ff94d3', + 88: '#000000', + 89: '#131313', + 90: '#282828', + 91: '#363636', + 92: '#4d4d4d', + 93: '#656565', + 94: '#818181', + 95: '#9f9f9f', + 96: '#bcbcbc', + 97: '#e2e2e2', + 98: '#ffffff', +}; + +class IrcFormatStyle { + const IrcFormatStyle({ + this.bold = false, + this.underline = false, + this.italic = false, + this.strikethrough = false, + this.reverse = false, + this.color, + this.background, + }); + + final bool bold; + final bool underline; + final bool italic; + final bool strikethrough; + final bool reverse; + final int? color; + final int? background; + + IrcFormatStyle copyWith({ + bool? bold, + bool? underline, + bool? italic, + bool? strikethrough, + bool? reverse, + int? color, + int? background, + bool clearColor = false, + bool clearBackground = false, + }) { + return IrcFormatStyle( + bold: bold ?? this.bold, + underline: underline ?? this.underline, + italic: italic ?? this.italic, + strikethrough: strikethrough ?? this.strikethrough, + reverse: reverse ?? this.reverse, + color: clearColor ? null : (color ?? this.color), + background: clearBackground ? null : (background ?? this.background), + ); + } +} + +class IrcTextSegment { + const IrcTextSegment({ + required this.text, + required this.style, + }); + + final String text; + final IrcFormatStyle style; +} + +class IrcLinkSegment { + const IrcLinkSegment({ + required this.text, + required this.style, + this.url, + }); + + final String text; + final IrcFormatStyle style; + final String? url; + + bool get isLink => url != null; +} + +final RegExp _ircUrlPattern = RegExp( + r'(https?:\/\/[^\s<>"{}|\\^`\[\]]+|ftp:\/\/[^\s<>"{}|\\^`\[\]]+|www\.[^\s<>"{}|\\^`\[\]]+)', + caseSensitive: false, +); + +String? getIrcColorHex(int code) { + if (code >= 16 && code <= 98) { + return ircExtendedColorMap[code]; + } + return ircStandardColorMap[code]; +} + +List parseIrcText(String text) { + if (text.isEmpty) { + return const []; + } + + final segments = []; + var currentText = ''; + var i = 0; + var currentStyle = const IrcFormatStyle(); + + void flushText() { + if (currentText.isEmpty) { + return; + } + segments.add(IrcTextSegment(text: currentText, style: currentStyle)); + currentText = ''; + } + + while (i < text.length) { + final charCode = text.codeUnitAt(i); + + switch (charCode) { + case IrcFormatCodes.bold: + flushText(); + currentStyle = currentStyle.copyWith(bold: !currentStyle.bold); + i += 1; + continue; + case IrcFormatCodes.color: + flushText(); + final parsed = _parseColorSequence(text, i); + i = parsed.nextIndex; + if (parsed.foreground == null && parsed.background == null) { + currentStyle = currentStyle.copyWith(clearColor: true, clearBackground: true); + } else { + currentStyle = currentStyle.copyWith( + color: parsed.foreground ?? currentStyle.color, + background: parsed.background ?? currentStyle.background, + ); + } + continue; + case IrcFormatCodes.reset: + flushText(); + currentStyle = const IrcFormatStyle(); + i += 1; + continue; + case IrcFormatCodes.underline: + flushText(); + currentStyle = currentStyle.copyWith(underline: !currentStyle.underline); + i += 1; + continue; + case IrcFormatCodes.italic: + flushText(); + currentStyle = currentStyle.copyWith(italic: !currentStyle.italic); + i += 1; + continue; + case IrcFormatCodes.strikethrough: + flushText(); + currentStyle = currentStyle.copyWith(strikethrough: !currentStyle.strikethrough); + i += 1; + continue; + case IrcFormatCodes.reverse: + flushText(); + currentStyle = currentStyle.copyWith(reverse: !currentStyle.reverse); + i += 1; + continue; + default: + currentText += text[i]; + i += 1; + } + } + + flushText(); + return segments; +} + +String stripIrcFormatting(String text) { + if (text.isEmpty) { + return ''; + } + + final buffer = StringBuffer(); + var i = 0; + while (i < text.length) { + final charCode = text.codeUnitAt(i); + switch (charCode) { + case IrcFormatCodes.bold: + case IrcFormatCodes.reset: + case IrcFormatCodes.underline: + case IrcFormatCodes.italic: + case IrcFormatCodes.strikethrough: + case IrcFormatCodes.reverse: + i += 1; + continue; + case IrcFormatCodes.color: + i = _parseColorSequence(text, i).nextIndex; + continue; + default: + buffer.write(text[i]); + i += 1; + } + } + + return buffer.toString(); +} + +String formatIrcDebug(String text) { + if (text.isEmpty) { + return ''; + } + + final buffer = StringBuffer(); + var i = 0; + while (i < text.length) { + final charCode = text.codeUnitAt(i); + switch (charCode) { + case IrcFormatCodes.bold: + buffer.write('[B]'); + i += 1; + case IrcFormatCodes.color: + final parsed = _parseColorSequence(text, i); + final colorBuffer = StringBuffer('[C'); + if (parsed.foreground != null) { + colorBuffer.write(parsed.foreground); + } + if (parsed.background != null) { + colorBuffer.write(',${parsed.background}'); + } + colorBuffer.write(']'); + buffer.write(colorBuffer.toString()); + i = parsed.nextIndex; + case IrcFormatCodes.reset: + buffer.write('[R]'); + i += 1; + case IrcFormatCodes.underline: + buffer.write('[U]'); + i += 1; + case IrcFormatCodes.italic: + buffer.write('[I]'); + i += 1; + case IrcFormatCodes.strikethrough: + buffer.write('[S]'); + i += 1; + case IrcFormatCodes.reverse: + buffer.write('[REV]'); + i += 1; + default: + buffer.write(text[i]); + i += 1; + } + } + return buffer.toString(); +} + +List parseIrcTextWithLinks(String text) { + final output = []; + for (final segment in parseIrcText(text)) { + var lastIndex = 0; + for (final match in _ircUrlPattern.allMatches(segment.text)) { + if (match.start > lastIndex) { + output.add( + IrcLinkSegment( + text: segment.text.substring(lastIndex, match.start), + style: segment.style, + ), + ); + } + final rawUrl = match.group(0)!; + final fullUrl = rawUrl.toLowerCase().startsWith('www.') ? 'https://$rawUrl' : rawUrl; + output.add( + IrcLinkSegment( + text: rawUrl, + style: segment.style, + url: fullUrl, + ), + ); + lastIndex = match.end; + } + if (lastIndex < segment.text.length) { + output.add( + IrcLinkSegment( + text: segment.text.substring(lastIndex), + style: segment.style, + ), + ); + } + if (segment.text.isEmpty) { + output.add(IrcLinkSegment(text: '', style: segment.style)); + } + } + return output; +} + +class _ParsedColorSequence { + const _ParsedColorSequence({ + required this.nextIndex, + this.foreground, + this.background, + }); + + final int nextIndex; + final int? foreground; + final int? background; +} + +_ParsedColorSequence _parseColorSequence(String text, int start) { + var i = start + 1; + int? foreground; + int? background; + + int? readNumber() { + if (i >= text.length || !_isAsciiDigit(text.codeUnitAt(i))) { + return null; + } + + final first = text.codeUnitAt(i) - 48; + if (i + 1 < text.length && _isAsciiDigit(text.codeUnitAt(i + 1))) { + final second = text.codeUnitAt(i + 1) - 48; + i += 2; + return (first * 10) + second; + } + + i += 1; + return first; + } + + foreground = readNumber(); + if (i < text.length && text[i] == ',') { + i += 1; + background = readNumber(); + } + + return _ParsedColorSequence( + nextIndex: i, + foreground: foreground, + background: background, + ); +} + +bool _isAsciiDigit(int codeUnit) => codeUnit >= 48 && codeUnit <= 57; diff --git a/lib/irc/parser/irc_url_parser.dart b/lib/irc/parser/irc_url_parser.dart new file mode 100644 index 0000000..ee5623e --- /dev/null +++ b/lib/irc/parser/irc_url_parser.dart @@ -0,0 +1,195 @@ +import 'package:androidircx/core/models/network_config.dart'; + +class ParsedIrcUrl { + const ParsedIrcUrl({ + required this.protocol, + required this.server, + required this.port, + required this.ssl, + required this.isValid, + this.nick, + this.altNick, + this.realName, + this.ident, + this.password, + this.channel, + this.channelKey, + this.error, + }); + + final String protocol; + final String server; + final int port; + final bool ssl; + final bool isValid; + final String? nick; + final String? altNick; + final String? realName; + final String? ident; + final String? password; + final String? channel; + final String? channelKey; + final String? error; +} + +bool isIrcUrl(String url) { + final trimmed = url.trim().toLowerCase(); + return trimmed.startsWith('irc://') || trimmed.startsWith('ircs://'); +} + +ParsedIrcUrl parseIrcUrl(String url) { + ParsedIrcUrl invalid(String error) => ParsedIrcUrl( + protocol: 'irc', + server: '', + port: 6667, + ssl: false, + isValid: false, + error: error, + ); + + if (url.trim().isEmpty) { + return invalid('URL is empty or invalid'); + } + + final match = RegExp(r'^(irc|ircs):\/\/', caseSensitive: false).firstMatch(url.trim()); + if (match == null) { + return invalid('Invalid IRC URL format. Expected: irc:// or ircs://'); + } + + final protocol = match.group(1)!.toLowerCase(); + final ssl = protocol == 'ircs'; + final defaultPort = ssl ? 6697 : 6667; + var remaining = url.trim().substring(match.group(0)!.length); + + final queryParams = {}; + final queryIndex = remaining.indexOf('?'); + if (queryIndex != -1) { + final query = remaining.substring(queryIndex + 1); + remaining = remaining.substring(0, queryIndex); + for (final entry in Uri.splitQueryString(query).entries) { + queryParams[entry.key.toLowerCase()] = entry.value; + } + } + + String? channel; + String? channelKey; + final slashIndex = remaining.indexOf('/'); + if (slashIndex != -1) { + final channelPart = remaining.substring(slashIndex + 1); + remaining = remaining.substring(0, slashIndex); + if (channelPart.isNotEmpty) { + final parts = channelPart.split(','); + channel = Uri.decodeComponent(parts.first.trim()); + if (channel.isNotEmpty && !channel.startsWith('#')) { + channel = '#$channel'; + } + if (parts.length > 1) { + channelKey = Uri.decodeComponent(parts[1].trim()); + } + } + } + + String? nick; + String? password; + final atIndex = remaining.lastIndexOf('@'); + if (atIndex != -1) { + final authPart = remaining.substring(0, atIndex); + remaining = remaining.substring(atIndex + 1); + final colonIndex = authPart.indexOf(':'); + if (colonIndex != -1) { + nick = Uri.decodeComponent(authPart.substring(0, colonIndex).trim()); + password = Uri.decodeComponent(authPart.substring(colonIndex + 1).trim()); + } else { + nick = Uri.decodeComponent(authPart.trim()); + } + } + + late final String server; + var port = defaultPort; + + if (remaining.startsWith('[')) { + final closeBracket = remaining.indexOf(']'); + if (closeBracket == -1) { + return invalid('Invalid IPv6 address format. Missing closing bracket.'); + } + server = remaining.substring(1, closeBracket); + remaining = remaining.substring(closeBracket + 1); + if (remaining.startsWith(':')) { + final rawPort = remaining.substring(1); + final parsedPort = int.tryParse(rawPort); + if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) { + return invalid('Invalid port: $rawPort. Must be 1-65535.'); + } + port = parsedPort; + } + } else { + final colonIndex = remaining.lastIndexOf(':'); + if (colonIndex != -1) { + server = remaining.substring(0, colonIndex).trim(); + final rawPort = remaining.substring(colonIndex + 1).trim(); + final parsedPort = int.tryParse(rawPort); + if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) { + return invalid('Invalid port: $rawPort. Must be 1-65535.'); + } + port = parsedPort; + } else { + server = remaining.trim(); + } + } + + if (server.isEmpty) { + return invalid('Server hostname is missing'); + } + + return ParsedIrcUrl( + protocol: protocol, + server: server, + port: port, + ssl: ssl, + isValid: true, + nick: queryParams['nick'] ?? nick, + altNick: queryParams['altnick'] ?? queryParams['alt_nick'], + realName: queryParams['realname'] ?? queryParams['real_name'], + ident: queryParams['ident'], + password: password, + channel: channel, + channelKey: channelKey, + ); +} + +String getIrcUrlDisplayName(ParsedIrcUrl parsedUrl) { + if (!parsedUrl.isValid) { + return 'invalid URL'; + } + + final parts = [parsedUrl.server]; + if (parsedUrl.port != (parsedUrl.ssl ? 6697 : 6667)) { + parts.add(':${parsedUrl.port}'); + } + if ((parsedUrl.channel ?? '').isNotEmpty) { + parts.add(' / ${parsedUrl.channel}'); + } + return parts.join(); +} + +NetworkConfig toTemporaryNetworkConfig( + ParsedIrcUrl parsedUrl, { + String defaultNickname = 'AndroidIRCX', + String defaultAltNickname = 'AndroidIRCX_', + String defaultRealName = 'AndroidIRCX', + String defaultUsername = 'androidircx', +}) { + final timestamp = DateTime.now().millisecondsSinceEpoch; + return NetworkConfig( + id: 'temp_${timestamp}_${parsedUrl.server}', + name: parsedUrl.server, + host: parsedUrl.server, + port: parsedUrl.port, + nickname: parsedUrl.nick ?? defaultNickname, + altNickname: parsedUrl.altNick ?? defaultAltNickname, + realName: parsedUrl.realName ?? defaultRealName, + username: parsedUrl.ident ?? defaultUsername, + useTls: parsedUrl.ssl, + password: parsedUrl.password, + ); +} diff --git a/lib/irc/parser/message_content_parser.dart b/lib/irc/parser/message_content_parser.dart new file mode 100644 index 0000000..09a78fa --- /dev/null +++ b/lib/irc/parser/message_content_parser.dart @@ -0,0 +1,275 @@ +class ParsedMessagePart { + const ParsedMessagePart({ + required this.type, + required this.content, + this.url, + this.mediaId, + }); + + final ParsedMessagePartType type; + final String content; + final String? url; + final String? mediaId; +} + +enum ParsedMessagePartType { + text, + url, + image, + media, +} + +final RegExp _mediaTagPattern = RegExp( + r'!enc-media\s+\[([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\]', + caseSensitive: false, +); +final RegExp _urlPattern = RegExp( + r'(https?:\/\/[^\s<>"{}|\\^`\[\]]+|ftp:\/\/[^\s<>"{}|\\^`\[\]]+|www\.[^\s<>"{}|\\^`\[\]]+)', + caseSensitive: false, +); +final RegExp _imagePattern = RegExp( + r'(https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?[^\s<>"{}|\\^`\[\]]*)?)', + caseSensitive: false, +); +final RegExp _videoPattern = RegExp( + r'(https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(mp4|mov|webm|mkv|avi)(\?[^\s<>"{}|\\^`\[\]]*)?)', + caseSensitive: false, +); +final RegExp _audioPattern = RegExp( + r'(https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(mp3|ogg|wav|m4a|flac)(\?[^\s<>"{}|\\^`\[\]]*)?)', + caseSensitive: false, +); +final RegExp _emojiPattern = RegExp( + r'[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{1FA00}-\u{1FA6F}]|[\u{1FA70}-\u{1FAFF}]', + unicode: true, +); + +const List _downloadableExtensions = [ + 'pdf', + 'zip', + 'rar', + '7z', + 'tar', + 'gz', + 'tgz', + 'bz2', + 'xz', + 'doc', + 'docx', + 'xls', + 'xlsx', + 'ppt', + 'pptx', + 'csv', + 'txt', + 'json', + 'xml', + 'apk', + 'ipa', + 'exe', + 'msi', + 'dmg', + 'pkg', + 'iso', + 'psd', + 'ai', + 'sketch', + 'fig', + 'epub', + 'mobi', +]; + +class ExtractedMediaTag { + const ExtractedMediaTag({ + required this.tag, + required this.mediaId, + }); + + final String tag; + final String mediaId; +} + +bool isImageUrl(String url) { + final lowerUrl = url.toLowerCase(); + const imageExtensions = [ + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', + '.svg', + '.bmp', + '.ico', + ]; + return imageExtensions.any(lowerUrl.contains) || _imagePattern.hasMatch(url); +} + +bool isVideoUrl(String url) { + final lowerUrl = url.toLowerCase(); + const videoExtensions = ['.mp4', '.mov', '.webm', '.mkv', '.avi']; + return videoExtensions.any(lowerUrl.contains) || _videoPattern.hasMatch(url); +} + +bool isAudioUrl(String url) { + final lowerUrl = url.toLowerCase(); + const audioExtensions = ['.mp3', '.ogg', '.wav', '.m4a', '.flac']; + return audioExtensions.any(lowerUrl.contains) || _audioPattern.hasMatch(url); +} + +String? getUrlExtension(String url) { + try { + final normalized = url.contains('://') ? url : 'https://$url'; + final parsed = Uri.parse(normalized); + final nonEmptySegments = + parsed.pathSegments.where((segment) => segment.isNotEmpty).toList(growable: false); + final lastSegment = nonEmptySegments.isEmpty ? null : nonEmptySegments.last; + if (lastSegment == null || !lastSegment.contains('.')) { + return null; + } + final ext = lastSegment.split('.').last.toLowerCase(); + const nonFileExtensions = [ + 'html', + 'htm', + 'php', + 'asp', + 'aspx', + 'jsp', + 'cfm', + ]; + return nonFileExtensions.contains(ext) ? null : ext; + } catch (_) { + return null; + } +} + +bool isDownloadableFileUrl(String url) { + final ext = getUrlExtension(url); + if (ext == null) { + return false; + } + if (isImageUrl(url) || isVideoUrl(url) || isAudioUrl(url)) { + return false; + } + return _downloadableExtensions.contains(ext) || RegExp(r'^[a-z0-9]{2,5}$').hasMatch(ext); +} + +List extractUrls(String text) => + _urlPattern.allMatches(text).map((match) => match.group(0)!).toList(growable: false); + +List extractImageUrls(String text) => + _imagePattern.allMatches(text).map((match) => match.group(0)!).toList(growable: false); + +List extractEmojis(String text) => + _emojiPattern.allMatches(text).map((match) => match.group(0)!).toList(growable: false); + +List extractMediaTags(String text) { + return _mediaTagPattern + .allMatches(text) + .map( + (match) => ExtractedMediaTag( + tag: match.group(0)!, + mediaId: match.group(1)!, + ), + ) + .toList(growable: false); +} + +bool hasMediaTags(String text) => _mediaTagPattern.hasMatch(text); + +List parseMessageContent(String text) { + if (text.isEmpty) { + return const []; + } + + final matches = <_IndexedMatch>[]; + + for (final match in _mediaTagPattern.allMatches(text)) { + matches.add( + _IndexedMatch( + index: match.start, + content: match.group(0)!, + type: ParsedMessagePartType.media, + mediaId: match.group(1), + ), + ); + } + + for (final match in _imagePattern.allMatches(text)) { + matches.add( + _IndexedMatch( + index: match.start, + content: match.group(0)!, + type: ParsedMessagePartType.image, + ), + ); + } + + for (final match in _urlPattern.allMatches(text)) { + final content = match.group(0)!; + final alreadyCaptured = + matches.any((item) => item.index == match.start && item.content == content); + if (alreadyCaptured) { + continue; + } + matches.add( + _IndexedMatch( + index: match.start, + content: content, + type: isImageUrl(content) + ? ParsedMessagePartType.image + : ParsedMessagePartType.url, + ), + ); + } + + matches.sort((a, b) => a.index.compareTo(b.index)); + + final parts = []; + var lastIndex = 0; + for (final match in matches) { + if (match.index > lastIndex) { + parts.add( + ParsedMessagePart( + type: ParsedMessagePartType.text, + content: text.substring(lastIndex, match.index), + ), + ); + } + + parts.add( + ParsedMessagePart( + type: match.type, + content: match.content, + url: match.type == ParsedMessagePartType.media ? null : match.content, + mediaId: match.mediaId, + ), + ); + + lastIndex = match.index + match.content.length; + } + + if (lastIndex < text.length) { + parts.add( + ParsedMessagePart( + type: ParsedMessagePartType.text, + content: text.substring(lastIndex), + ), + ); + } + + return parts; +} + +class _IndexedMatch { + const _IndexedMatch({ + required this.index, + required this.content, + required this.type, + this.mediaId, + }); + + final int index; + final String content; + final ParsedMessagePartType type; + final String? mediaId; +} diff --git a/lib/irc/parser/mirc_preset_parser.dart b/lib/irc/parser/mirc_preset_parser.dart new file mode 100644 index 0000000..8cdc50e --- /dev/null +++ b/lib/irc/parser/mirc_preset_parser.dart @@ -0,0 +1,126 @@ +import 'dart:convert'; + +class MircPresetEntry { + const MircPresetEntry({ + required this.id, + required this.raw, + this.enabled, + }); + + final String id; + final String raw; + final bool? enabled; +} + +const Map _cp1252Map = { + 0x80: 0x20AC, + 0x82: 0x201A, + 0x83: 0x0192, + 0x84: 0x201E, + 0x85: 0x2026, + 0x86: 0x2020, + 0x87: 0x2021, + 0x88: 0x02C6, + 0x89: 0x2030, + 0x8A: 0x0160, + 0x8B: 0x2039, + 0x8C: 0x0152, + 0x8E: 0x017D, + 0x91: 0x2018, + 0x92: 0x2019, + 0x93: 0x201C, + 0x94: 0x201D, + 0x95: 0x2022, + 0x96: 0x2013, + 0x97: 0x2014, + 0x98: 0x02DC, + 0x99: 0x2122, + 0x9A: 0x0161, + 0x9B: 0x203A, + 0x9C: 0x0153, + 0x9E: 0x017E, + 0x9F: 0x0178, +}; + +final RegExp _lineSplit = RegExp(r'\r\n|\n|\r'); + +String decodeMircPresetBase64(String base64Value) { + final sanitized = base64Value.replaceAll(RegExp(r'\s+'), ''); + final bytes = base64.decode(sanitized); + try { + return utf8.decode(bytes); + } on FormatException { + final buffer = StringBuffer(); + for (final byte in bytes) { + if (byte >= 0x80 && byte <= 0x9F) { + buffer.writeCharCode(_cp1252Map[byte] ?? byte); + } else { + buffer.writeCharCode(byte); + } + } + return buffer.toString(); + } +} + +List splitPresetLines(String raw) { + return raw + .split(_lineSplit) + .map((line) => line.trim()) + .where((line) => line.isNotEmpty) + .toList(growable: false); +} + +List parseGenericPresets(String raw) { + final lines = splitPresetLines(raw); + return List.generate( + lines.length, + (index) => MircPresetEntry(id: 'preset-${index + 1}', raw: lines[index]), + growable: false, + ); +} + +List parseNickCompletionPresets(String raw) { + final lines = splitPresetLines(raw); + return List.generate(lines.length, (index) { + final line = lines[index]; + final match = RegExp(r'(\s+|\x08)(on|off)$', caseSensitive: false).firstMatch(line); + if (match == null) { + return MircPresetEntry(id: 'nick-${index + 1}', raw: line); + } + + final enabled = match.group(2)!.toLowerCase() == 'on'; + final separator = match.group(1)!; + final rawValue = separator == '\x08' + ? line.substring(0, match.start + 1) + : line.substring(0, match.start).trim(); + return MircPresetEntry( + id: 'nick-${index + 1}', + raw: rawValue, + enabled: enabled, + ); + }, growable: false); +} + +List parseIrcapDecorationEti(String raw) { + final results = []; + final seen = {}; + final lines = raw.split(_lineSplit).where((line) => line.isNotEmpty); + + for (final line in lines) { + final fields = line.split('\x08'); + if (fields.length < 9) { + continue; + } + final prefix = fields[fields.length - 3]; + final suffix = fields[fields.length - 2]; + final style = '$prefix\x08$suffix'.replaceAll('\x00', ''); + if (style.trim().isEmpty && !style.contains('\x08')) { + continue; + } + if (seen.add(style)) { + results.add(style); + } + } + + return List.unmodifiable(results); +} diff --git a/lib/irc/services/irc_service.dart b/lib/irc/services/irc_service.dart index 856ec74..7d0d66e 100644 --- a/lib/irc/services/irc_service.dart +++ b/lib/irc/services/irc_service.dart @@ -183,6 +183,30 @@ class IrcService { await sendRaw('NAMES $channel'); } + Future sendList([String? filter]) async { + final value = (filter ?? '').trim(); + await sendRaw(value.isEmpty ? 'LIST' : 'LIST $value'); + } + + Future sendMotd() async { + await sendRaw('MOTD'); + } + + Future sendTime([String? server]) async { + final value = (server ?? '').trim(); + await sendRaw(value.isEmpty ? 'TIME' : 'TIME $value'); + } + + Future sendVersion([String? server]) async { + final value = (server ?? '').trim(); + await sendRaw(value.isEmpty ? 'VERSION' : 'VERSION $value'); + } + + Future sendLinks([String? mask]) async { + final value = (mask ?? '').trim(); + await sendRaw(value.isEmpty ? 'LINKS' : 'LINKS $value'); + } + Future sendInvite({ required String nick, required String channel, @@ -199,6 +223,18 @@ class IrcService { await sendRaw('KICK $channel $nick$suffix'); } + Future sendChannelMode({ + required String channel, + required String mode, + required String target, + }) async { + await sendRaw('MODE $channel $mode $target'); + } + + Future sendBanList(String channel) async { + await sendRaw('MODE $channel +b'); + } + Future sendTopic({ required String channel, String? topic, @@ -215,6 +251,32 @@ class IrcService { await sendRaw('MODE $args'); } + Future sendCapLs() async { + await sendRaw('CAP LS 302'); + } + + Future sendCapList() async { + await sendRaw('CAP LIST'); + } + + Future sendCapReq(String capabilities) async { + await sendRaw('CAP REQ :$capabilities'); + } + + Future sendCapEnd() async { + await sendRaw('CAP END'); + } + + Future sendAway([String? message]) async { + final value = (message ?? '').trim(); + if (value.isEmpty) { + await sendRaw('AWAY'); + return; + } + + await sendRaw('AWAY :$value'); + } + Future sendAction({ required String target, required String text, @@ -328,18 +390,24 @@ class IrcService { _saslInProgress = true; final mechanism = _network?.saslMechanism ?? SaslMechanism.plain; _activeSaslMechanism = mechanism; - if (mechanism == SaslMechanism.scramSha256) { - final network = _network; - if (network != null) { - _scramSession = ScramSha256Session( - username: network.saslAccount!, - password: network.saslPassword!, - nonceGenerator: _scramNonceGenerator, - ); - } - unawaited(sendRaw('AUTHENTICATE SCRAM-SHA-256')); - } else { - unawaited(sendRaw('AUTHENTICATE PLAIN')); + switch (mechanism) { + case SaslMechanism.scramSha256: + final network = _network; + if (network != null) { + _scramSession = ScramSha256Session( + username: network.saslAccount!, + password: network.saslPassword!, + nonceGenerator: _scramNonceGenerator, + ); + } + unawaited(sendRaw('AUTHENTICATE SCRAM-SHA-256')); + break; + case SaslMechanism.external: + unawaited(sendRaw('AUTHENTICATE EXTERNAL')); + break; + case SaslMechanism.plain: + unawaited(sendRaw('AUTHENTICATE PLAIN')); + break; } } else { unawaited(_endCapNegotiation()); @@ -389,6 +457,13 @@ class IrcService { return; } + if (mechanism == SaslMechanism.external) { + if (payload == '+') { + unawaited(sendRaw('AUTHENTICATE +')); + } + return; + } + if (payload != '+') { return; } @@ -490,8 +565,14 @@ class IrcService { return false; } - return (network.saslAccount ?? '').isNotEmpty && - (network.saslPassword ?? '').isNotEmpty; + switch (network.saslMechanism) { + case SaslMechanism.external: + return true; + case SaslMechanism.plain: + case SaslMechanism.scramSha256: + return (network.saslAccount ?? '').isNotEmpty && + (network.saslPassword ?? '').isNotEmpty; + } } void _handleTransportDone() { diff --git a/test/chat_session_controller_test.dart b/test/chat_session_controller_test.dart index e6d7b29..b48fa6a 100644 --- a/test/chat_session_controller_test.dart +++ b/test/chat_session_controller_test.dart @@ -2,6 +2,7 @@ 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/join_channel_dialog.dart'; import 'package:androidircx/irc/services/irc_service.dart'; import 'package:androidircx/irc/services/irc_transport.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -74,4 +75,522 @@ void main() { controller.dispose(); }); + + test('sends IRC service commands through private messages', () 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', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + ); + + await controller.start(); + await controller.handleComposerSubmit('/ns identify secret'); + await controller.handleComposerSubmit('/cs op #androidircx AndroidIRCX'); + + expect( + transport.sentLines, + contains('PRIVMSG NickServ :identify secret'), + ); + expect( + transport.sentLines, + contains('PRIVMSG ChanServ :op #androidircx AndroidIRCX'), + ); + expect( + controller.tabs.any( + (tab) => tab.type.name == 'query' && tab.name == 'NickServ', + ), + isTrue, + ); + await controller.handleComposerSubmit('/ms send AndroidIRCX hello'); + await controller.handleComposerSubmit('/bs botlist'); + + expect( + transport.sentLines, + contains('PRIVMSG MemoServ :send AndroidIRCX hello'), + ); + expect( + transport.sentLines, + contains('PRIVMSG BotServ :botlist'), + ); + expect(controller.activeTab.name, 'BotServ'); + expect( + controller.activeMessages.any( + (message) => message.sender == 'AndroidIRCX' && message.content == 'botlist', + ), + isTrue, + ); + + controller.dispose(); + }); + + test('routes incoming IRC service notices and messages into service tabs', () 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', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + ); + + await controller.start(); + transport.emit(':NickServ!service@services NOTICE AndroidIRCX :This nickname is registered.'); + transport.emit(':MemoServ!service@services PRIVMSG AndroidIRCX :You have 2 new memos.'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect( + controller.tabs.any( + (tab) => tab.type.name == 'query' && tab.name == 'NickServ', + ), + isTrue, + ); + expect( + controller.tabs.any( + (tab) => tab.type.name == 'query' && tab.name == 'MemoServ', + ), + isTrue, + ); + + controller.selectTab( + controller.tabs.firstWhere((tab) => tab.type.name == 'query' && tab.name == 'NickServ').id, + ); + expect( + controller.activeMessages.any( + (message) => message.sender == 'NickServ' && message.content.contains('registered'), + ), + isTrue, + ); + + controller.selectTab( + controller.tabs.firstWhere((tab) => tab.type.name == 'query' && tab.name == 'MemoServ').id, + ); + expect( + controller.activeMessages.any( + (message) => message.sender == 'MemoServ' && message.content.contains('2 new memos'), + ), + isTrue, + ); + + controller.dispose(); + }); + + test('tracks outgoing notice commands in the matching tab', () 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', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + ); + + await controller.start(); + await controller.handleComposerSubmit('/notice NickServ STATUS AndroidIRCX'); + + expect( + transport.sentLines, + contains('NOTICE NickServ :STATUS AndroidIRCX'), + ); + expect(controller.activeTab.name, 'NickServ'); + expect( + controller.activeMessages.any( + (message) => + message.isOwn && + message.sender == 'AndroidIRCX' && + message.content == 'STATUS AndroidIRCX', + ), + isTrue, + ); + + controller.dispose(); + }); + + test('handles CAP commands from the composer', () 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', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + ); + + await controller.start(); + await controller.handleComposerSubmit('/cap ls'); + await controller.handleComposerSubmit('/cap req message-tags echo-message'); + await controller.handleComposerSubmit('/cap end'); + + expect(transport.sentLines, contains('CAP LS 302')); + expect(transport.sentLines, contains('CAP REQ :message-tags echo-message')); + expect(transport.sentLines, contains('CAP END')); + expect( + controller.activeMessages.any( + (message) => message.content.contains('Requested capabilities: message-tags echo-message'), + ), + isTrue, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('Ended capability negotiation'), + ), + isTrue, + ); + + controller.dispose(); + }); + + test('shows capability status and CAP frame updates in server messages', () 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', + altNickname: 'AndroidIRCX_', + saslAccount: 'alice', + saslPassword: 'secret', + ), + ircService: service, + ); + + await controller.start(); + transport.emit(':server CAP * LS :multi-prefix sasl message-tags'); + await Future.delayed(Duration.zero); + transport.emit(':server CAP * ACK :sasl message-tags'); + await Future.delayed(Duration.zero); + await controller.handleComposerSubmit('/cap status'); + + expect( + controller.activeMessages.any( + (message) => message.content.contains('CAP LS: multi-prefix sasl message-tags'), + ), + isTrue, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('CAP ACK: sasl message-tags'), + ), + isTrue, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('Available capabilities:'), + ), + isTrue, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('Enabled capabilities:'), + ), + isTrue, + ); + + controller.dispose(); + }); + + test('handles away and back commands from the composer', () 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', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + ); + + await controller.start(); + await controller.handleComposerSubmit('/away Grabbing coffee'); + await controller.handleComposerSubmit('/back'); + + expect(transport.sentLines, contains('AWAY :Grabbing coffee')); + expect(transport.sentLines, contains('AWAY')); + expect( + controller.activeMessages.any( + (message) => message.content.contains('Away: Grabbing coffee'), + ), + isTrue, + ); + expect( + controller.activeMessages.where((message) => message.content == 'Away status cleared.'), + isNotEmpty, + ); + + controller.dispose(); + }); + + test('handles list command and channel list numerics', () 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', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + ); + + await controller.start(); + await controller.handleComposerSubmit('/list #android*'); + transport.emit(':server 321 AndroidIRCX Channel :Users Name'); + transport.emit(':server 322 AndroidIRCX #androidircx 42 :AndroidIRCx official channel'); + transport.emit(':server 323 AndroidIRCX :End of /LIST'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(transport.sentLines, contains('LIST #android*')); + expect( + controller.activeMessages.any( + (message) => message.content.contains('Requested channel list for: #android*'), + ), + isTrue, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('Channel list started.'), + ), + isTrue, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('#androidircx (42 users) - AndroidIRCx official channel'), + ), + isTrue, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('End of /LIST'), + ), + isTrue, + ); + + controller.dispose(); + }); + + test('handles server info commands and numerics', () 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', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + ); + + await controller.start(); + await controller.handleComposerSubmit('/motd'); + await controller.handleComposerSubmit('/time'); + await controller.handleComposerSubmit('/version irc.example.test'); + await controller.handleComposerSubmit('/links *.example.test'); + + transport.emit(':server 371 AndroidIRCX :- Welcome to the network'); + transport.emit(':server 374 AndroidIRCX :End of /INFO list'); + transport.emit(':server 391 AndroidIRCX irc.example.test :2026-03-16 20:15:00'); + transport.emit(':server 351 AndroidIRCX ircd-seven-1.1 example.test :server version info'); + transport.emit(':server 364 AndroidIRCX hub.example.test 1 :Example hub'); + transport.emit(':server 365 AndroidIRCX :End of /LINKS list'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(transport.sentLines, contains('MOTD')); + expect(transport.sentLines, contains('TIME')); + expect(transport.sentLines, contains('VERSION irc.example.test')); + expect(transport.sentLines, contains('LINKS *.example.test')); + + expect( + controller.activeMessages.any( + (message) => message.content.contains('Requested MOTD.'), + ), + isTrue, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('Welcome to the network'), + ), + isTrue, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('2026-03-16 20:15:00'), + ), + isTrue, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('Server version:'), + ), + isTrue, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('Link: hub.example.test (1) - Example hub'), + ), + isTrue, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('End of /LINKS list'), + ), + isTrue, + ); + + controller.dispose(); + }); + + test('handles channel admin commands and ban list numerics', () 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', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + ); + + await controller.start(); + await controller.joinChannel(const JoinChannelRequest(channel: '#androidircx')); + await controller.handleComposerSubmit('/op Alice'); + await controller.handleComposerSubmit('/deop Alice'); + await controller.handleComposerSubmit('/voice Bob'); + await controller.handleComposerSubmit('/devoice Bob'); + await controller.handleComposerSubmit('/ban bad!*@*'); + await controller.handleComposerSubmit('/unban bad!*@*'); + await controller.handleComposerSubmit('/banlist'); + + transport.emit(':server 367 AndroidIRCX #androidircx bad!*@* ChanOp 1710000000'); + transport.emit(':server 368 AndroidIRCX #androidircx :End of channel ban list'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(transport.sentLines, contains('MODE #androidircx +o Alice')); + expect(transport.sentLines, contains('MODE #androidircx -o Alice')); + expect(transport.sentLines, contains('MODE #androidircx +v Bob')); + expect(transport.sentLines, contains('MODE #androidircx -v Bob')); + expect(transport.sentLines, contains('MODE #androidircx +b bad!*@*')); + expect(transport.sentLines, contains('MODE #androidircx -b bad!*@*')); + expect(transport.sentLines, contains('MODE #androidircx +b')); + + expect( + controller.activeMessages.any( + (message) => message.content.contains('Requested ban list for #androidircx'), + ), + isTrue, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('Ban: bad!*@* set by ChanOp'), + ), + isTrue, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('End of channel ban list'), + ), + isTrue, + ); + + controller.dispose(); + }); + + test('routes away numerics into server messages', () 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', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + ); + + await controller.start(); + transport.emit(':server 306 AndroidIRCX :You have been marked as being away'); + transport.emit(':server 305 AndroidIRCX :You are no longer marked as being away'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect( + controller.activeMessages.any( + (message) => message.content.contains('marked as being away'), + ), + isTrue, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('no longer marked as being away'), + ), + isTrue, + ); + + controller.dispose(); + }); } diff --git a/test/command_service_test.dart b/test/command_service_test.dart index a62ae10..4d3937d 100644 --- a/test/command_service_test.dart +++ b/test/command_service_test.dart @@ -12,6 +12,10 @@ void main() { expect(service.normalizeCommand('/j #flutter'), '/join #flutter'); expect(service.normalizeCommand('/w nick'), '/whois nick'); + expect(service.normalizeCommand('/ns identify secret'), '/nickserv identify secret'); + expect(service.normalizeCommand('/cs op #flutter nick'), '/chanserv op #flutter nick'); + expect(service.normalizeCommand('/ms send nick hello'), '/memoserv send nick hello'); + expect(service.normalizeCommand('/bs botlist'), '/botserv botlist'); expect(service.normalizeCommand('hello'), 'hello'); }); diff --git a/test/irc_formatter_test.dart b/test/irc_formatter_test.dart new file mode 100644 index 0000000..31a9296 --- /dev/null +++ b/test/irc_formatter_test.dart @@ -0,0 +1,42 @@ +import 'package:androidircx/irc/parser/irc_formatter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('parses IRC formatting into styled segments', () { + final segments = parseIrcText('Hello \u0002bold\u0002 and \u001ditalic\u001d'); + + expect(segments, hasLength(4)); + expect(segments[0].text, 'Hello '); + expect(segments[1].text, 'bold'); + expect(segments[1].style.bold, isTrue); + expect(segments[2].text, ' and '); + expect(segments[3].text, 'italic'); + expect(segments[3].style.italic, isTrue); + }); + + test('strips IRC formatting codes', () { + expect( + stripIrcFormatting('\u000304,01Red on black\u000f plain'), + 'Red on black plain', + ); + }); + + test('formats IRC debug markers', () { + expect( + formatIrcDebug('\u0002Bold\u000f'), + '[B]Bold[R]', + ); + }); + + test('parses IRC formatted links', () { + final segments = parseIrcTextWithLinks( + 'See \u0002www.androidircx.com\u000f now', + ); + + expect(segments.any((segment) => segment.isLink), isTrue); + final link = segments.firstWhere((segment) => segment.isLink); + expect(link.text, 'www.androidircx.com'); + expect(link.url, 'https://www.androidircx.com'); + expect(link.style.bold, isTrue); + }); +} diff --git a/test/irc_service_sasl_test.dart b/test/irc_service_sasl_test.dart index a5a53db..14ccc2e 100644 --- a/test/irc_service_sasl_test.dart +++ b/test/irc_service_sasl_test.dart @@ -166,6 +166,41 @@ void main() { service.dispose(); }); + test('starts EXTERNAL authentication when configured', () async { + final transport = _FakeTransport(); + final service = IrcService( + transportConnector: (_) async => transport, + ); + + await service.connect( + const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + saslMechanism: SaslMechanism.external, + ), + ); + + transport.emit(':server CAP * LS :multi-prefix sasl'); + await Future.delayed(Duration.zero); + transport.emit(':server CAP * ACK :sasl'); + await Future.delayed(Duration.zero); + + expect(transport.sentLines, contains('AUTHENTICATE EXTERNAL')); + + transport.emit('AUTHENTICATE +'); + await Future.delayed(Duration.zero); + expect(transport.sentLines, contains('AUTHENTICATE +')); + + transport.emit(':server 903 AndroidIRCX :SASL authentication successful'); + await Future.delayed(Duration.zero); + expect(transport.sentLines, contains('CAP END')); + + service.dispose(); + }); + test('tracks CAP NEW and DEL updates after registration', () async { final transport = _FakeTransport(); final service = IrcService( diff --git a/test/irc_url_parser_test.dart b/test/irc_url_parser_test.dart new file mode 100644 index 0000000..b223782 --- /dev/null +++ b/test/irc_url_parser_test.dart @@ -0,0 +1,34 @@ +import 'package:androidircx/irc/parser/irc_url_parser.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('parses ircs url with query overrides', () { + final parsed = parseIrcUrl( + 'ircs://nick:secret@irc.example.com:6697/androidircx?nick=AndroidIRCX&altNick=AndroidIRCX_&ident=androidircx', + ); + + expect(parsed.isValid, isTrue); + expect(parsed.ssl, isTrue); + expect(parsed.server, 'irc.example.com'); + expect(parsed.port, 6697); + expect(parsed.nick, 'AndroidIRCX'); + expect(parsed.altNick, 'AndroidIRCX_'); + expect(parsed.ident, 'androidircx'); + expect(parsed.channel, '#androidircx'); + }); + + test('rejects invalid port', () { + final parsed = parseIrcUrl('irc://irc.example.com:99999/test'); + expect(parsed.isValid, isFalse); + expect(parsed.error, contains('Invalid port')); + }); + + test('creates temporary network config', () { + final parsed = parseIrcUrl('irc://irc.example.com/flutter'); + final network = toTemporaryNetworkConfig(parsed); + + expect(network.host, 'irc.example.com'); + expect(network.port, 6667); + expect(network.nickname, 'AndroidIRCX'); + }); +} diff --git a/test/message_content_parser_test.dart b/test/message_content_parser_test.dart new file mode 100644 index 0000000..f4ca3e5 --- /dev/null +++ b/test/message_content_parser_test.dart @@ -0,0 +1,35 @@ +import 'package:androidircx/irc/parser/message_content_parser.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('detects media and url parts in a message', () { + final parts = parseMessageContent( + 'Look https://example.com/a.png !enc-media [123e4567-e89b-12d3-a456-426614174000]', + ); + + expect(parts.any((part) => part.type == ParsedMessagePartType.image), isTrue); + expect(parts.any((part) => part.type == ParsedMessagePartType.media), isTrue); + }); + + test('extracts urls and emojis', () { + expect( + extractUrls('Visit www.androidircx.com and https://example.com'), + hasLength(2), + ); + expect(extractEmojis('Hi 😀 IRC'), contains('😀')); + }); + + test('classifies downloadable file urls', () { + expect(isDownloadableFileUrl('https://example.com/manual.pdf'), isTrue); + expect(isDownloadableFileUrl('https://example.com/photo.jpg'), isFalse); + }); + + test('extracts media tags', () { + final tags = extractMediaTags( + 'file !enc-media [123e4567-e89b-12d3-a456-426614174000]', + ); + + expect(tags, hasLength(1)); + expect(tags.first.mediaId, '123e4567-e89b-12d3-a456-426614174000'); + }); +} diff --git a/test/mirc_preset_parser_test.dart b/test/mirc_preset_parser_test.dart new file mode 100644 index 0000000..b43375b --- /dev/null +++ b/test/mirc_preset_parser_test.dart @@ -0,0 +1,31 @@ +import 'dart:convert'; + +import 'package:androidircx/irc/parser/mirc_preset_parser.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('decodes utf8 mirc preset base64', () { + final decoded = decodeMircPresetBase64(base64.encode(utf8.encode('line1\nline2'))); + expect(decoded, 'line1\nline2'); + }); + + test('parses generic presets', () { + final presets = parseGenericPresets('one\ntwo'); + expect(presets, hasLength(2)); + expect(presets.first.raw, 'one'); + }); + + test('parses nick completion presets with on off suffix', () { + final presets = parseNickCompletionPresets('nick1 on\nnick2 off'); + expect(presets.first.enabled, isTrue); + expect(presets.last.enabled, isFalse); + }); + + test('parses ircap decoration eti values', () { + final parsed = parseIrcapDecorationEti( + 'a\x08b\x08c\x08d\x08e\x08f\x08prefix\x08suffix\x08z', + ); + + expect(parsed, contains('prefix\x08suffix')); + }); +} diff --git a/test/session_registry_test.dart b/test/session_registry_test.dart index c3e6a1c..0c9da8b 100644 --- a/test/session_registry_test.dart +++ b/test/session_registry_test.dart @@ -47,4 +47,71 @@ void main() { registry.dispose(); }); + + test('exposes current nick for existing session', () { + final registry = SessionRegistry(); + const network = NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.dbase.in.rs', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ); + + registry.obtainSession(network); + + expect(registry.currentNickFor(network.id), 'AndroidIRCX'); + + registry.dispose(); + }); + + test('closeAllSessions clears all tracked sessions', () async { + final registry = SessionRegistry(); + const dbase = NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.dbase.in.rs', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ); + const libera = NetworkConfig( + id: 'libera', + name: 'Libera', + host: 'irc.libera.chat', + port: 6697, + nickname: 'AndroidIRCX2', + altNickname: 'AndroidIRCX2_', + ); + + registry.obtainSession(dbase); + registry.obtainSession(libera); + + await registry.closeAllSessions(); + + expect(registry.sessions, isEmpty); + expect(registry.hasSession(dbase.id), isFalse); + expect(registry.hasSession(libera.id), isFalse); + + registry.dispose(); + }); + + test('reports zero activity count for idle session', () { + final registry = SessionRegistry(); + const network = NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.dbase.in.rs', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ); + + registry.obtainSession(network); + + expect(registry.activityCountFor(network.id), 0); + + registry.dispose(); + }); } diff --git a/test/storage_repositories_test.dart b/test/storage_repositories_test.dart index b1b87ad..3259d8b 100644 --- a/test/storage_repositories_test.dart +++ b/test/storage_repositories_test.dart @@ -49,6 +49,27 @@ void main() { expect(saved.saslMechanism, SaslMechanism.scramSha256); }); + test('network repository persists EXTERNAL SASL mechanism', () async { + final repository = SharedPrefsNetworkRepository(); + + await repository.saveNetwork( + const NetworkConfig( + id: 'certnet', + name: 'CertNet', + host: 'irc.cert.net', + port: 6697, + nickname: 'tester', + altNickname: 'tester_', + saslMechanism: SaslMechanism.external, + ), + ); + + final saved = (await repository.loadNetworks()) + .firstWhere((item) => item.id == 'certnet'); + + expect(saved.saslMechanism, SaslMechanism.external); + }); + test('settings repository saves and loads showRawEvents', () async { final repository = SharedPrefsSettingsRepository(); diff --git a/test/widget_test.dart b/test/widget_test.dart index f9165aa..0c8080e 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,13 +1,34 @@ +import 'dart:async'; + import 'package:androidircx/app/app.dart'; import 'package:androidircx/core/models/network_config.dart'; import 'package:androidircx/core/storage/in_memory_network_repository.dart'; +import 'package:androidircx/features/chat/application/chat_session_controller.dart'; import 'package:androidircx/features/chat/application/session_registry.dart'; +import 'package:androidircx/features/chat/presentation/chat_screen.dart'; import 'package:androidircx/features/connections/application/network_list_controller.dart'; import 'package:androidircx/features/connections/presentation/network_list_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; + + @override + Future close() async { + await _controller.close(); + } + + @override + Future sendLine(String line) async {} +} + void main() { testWidgets('shows seeded network on bootstrap', (tester) async { SharedPreferences.setMockInitialValues({}); @@ -53,11 +74,53 @@ void main() { await tester.pump(); expect(find.text('Active sessions'), findsOneWidget); + expect(find.text('1 live'), findsOneWidget); + expect(find.text('Disconnect all'), findsOneWidget); expect(find.text('Auto connect enabled'), findsOneWidget); expect(find.text('Open session'), findsOneWidget); + expect(find.text('Reconnect'), findsOneWidget); expect(find.text('Active nick: AndroidIRCX'), findsOneWidget); + expect(find.text('Status: Idle'), findsOneWidget); + expect(find.textContaining('Activity:'), findsNothing); registry.dispose(); controller.dispose(); }); + + testWidgets('shows IRC services quick actions on the server tab', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + const network = NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.dbase.in.rs', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ); + final transport = _FakeTransport(); + final controller = ChatSessionController( + network: network, + ircService: IrcService( + transportConnector: (_) async => transport, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: ChatScreen(controller: controller), + ), + ); + await tester.pump(); + + expect(find.text('NickServ HELP'), findsOneWidget); + expect(find.text('ChanServ HELP'), findsOneWidget); + expect(find.text('MemoServ HELP'), findsOneWidget); + await tester.tap(find.text('NickServ HELP')); + await tester.pump(); + expect(find.text('NickServ'), findsWidgets); + + controller.dispose(); + }); }