diff --git a/.github/workflows/release-android.yml b/.github/workflows/release-android.yml new file mode 100644 index 0000000..a6bb62d --- /dev/null +++ b/.github/workflows/release-android.yml @@ -0,0 +1,56 @@ +name: Release Android + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-android: + name: Build Android APK + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Build release APK + run: flutter build apk --release + + - name: Prepare APK artifact + shell: bash + run: | + mkdir -p release-assets + cp build/app/outputs/flutter-apk/app-release.apk release-assets/AndroidIRCx-Flutter-android.apk + + - name: Upload workflow artifact + uses: actions/upload-artifact@v4 + with: + name: android-release-apk + path: release-assets/AndroidIRCx-Flutter-android.apk + + - name: Attach APK to GitHub release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + generate_release_notes: true + files: release-assets/AndroidIRCx-Flutter-android.apk diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml new file mode 100644 index 0000000..5c20f88 --- /dev/null +++ b/.github/workflows/release-windows.yml @@ -0,0 +1,59 @@ +name: Release Windows + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-windows: + name: Build Windows Package + runs-on: windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Enable Windows desktop + run: flutter config --enable-windows-desktop + + - name: Build Windows release + run: flutter build windows --release + + - name: Package Windows release + shell: pwsh + run: | + $artifactDir = "release-assets" + New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null + $source = "build/windows/x64/runner/Release" + $zipPath = Join-Path $artifactDir "AndroidIRCx-Flutter-windows.zip" + if (Test-Path $zipPath) { + Remove-Item $zipPath -Force + } + Compress-Archive -Path "$source/*" -DestinationPath $zipPath + + - name: Upload workflow artifact + uses: actions/upload-artifact@v4 + with: + name: windows-release-zip + path: release-assets/AndroidIRCx-Flutter-windows.zip + + - name: Attach Windows package to GitHub release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + generate_release_notes: true + files: release-assets/AndroidIRCx-Flutter-windows.zip diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..4dcfde9 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,4 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: + - shared_preferences: true \ No newline at end of file diff --git a/lib/core/models/app_settings.dart b/lib/core/models/app_settings.dart index cef70c1..8a288f9 100644 --- a/lib/core/models/app_settings.dart +++ b/lib/core/models/app_settings.dart @@ -1,27 +1,49 @@ +enum NoticeRoutingMode { server, active, notice, private } + class AppSettings { const AppSettings({ this.showRawEvents = true, + this.noticeRouting = NoticeRoutingMode.server, + this.showHeaderSearchButton = true, + this.showAttachmentPreviews = true, }); final bool showRawEvents; + final NoticeRoutingMode noticeRouting; + final bool showHeaderSearchButton; + final bool showAttachmentPreviews; AppSettings copyWith({ bool? showRawEvents, + NoticeRoutingMode? noticeRouting, + bool? showHeaderSearchButton, + bool? showAttachmentPreviews, }) { return AppSettings( showRawEvents: showRawEvents ?? this.showRawEvents, + noticeRouting: noticeRouting ?? this.noticeRouting, + showHeaderSearchButton: showHeaderSearchButton ?? this.showHeaderSearchButton, + showAttachmentPreviews: showAttachmentPreviews ?? this.showAttachmentPreviews, ); } Map toJson() { return { 'showRawEvents': showRawEvents, + 'noticeRouting': noticeRouting.name, + 'showHeaderSearchButton': showHeaderSearchButton, + 'showAttachmentPreviews': showAttachmentPreviews, }; } factory AppSettings.fromJson(Map json) { return AppSettings( showRawEvents: (json['showRawEvents'] as bool?) ?? true, + noticeRouting: json['noticeRouting'] is String + ? NoticeRoutingMode.values.byName(json['noticeRouting']! as String) + : NoticeRoutingMode.server, + showHeaderSearchButton: (json['showHeaderSearchButton'] as bool?) ?? true, + showAttachmentPreviews: (json['showAttachmentPreviews'] as bool?) ?? true, ); } } diff --git a/lib/core/models/chat_tab.dart b/lib/core/models/chat_tab.dart index 1e02e2c..422c1a2 100644 --- a/lib/core/models/chat_tab.dart +++ b/lib/core/models/chat_tab.dart @@ -1,4 +1,4 @@ -enum ChatTabType { server, channel, query, notice } +enum ChatTabType { server, channel, query, notice, dcc } class ChatTab { const ChatTab({ diff --git a/lib/core/models/dcc_session.dart b/lib/core/models/dcc_session.dart new file mode 100644 index 0000000..59402dd --- /dev/null +++ b/lib/core/models/dcc_session.dart @@ -0,0 +1,71 @@ +enum DccSessionType { chat, send, unknown } + +enum DccSessionStatus { pending, offering, connecting, connected, closed, failed } + +class DccSession { + const DccSession({ + required this.id, + required this.tabId, + required this.peerNick, + required this.type, + required this.status, + required this.direction, + this.filename, + this.host, + this.port, + this.size, + this.token, + this.filePath, + this.bytesTransferred = 0, + this.error, + }); + + final String id; + final String tabId; + final String peerNick; + final DccSessionType type; + final DccSessionStatus status; + final String direction; + final String? filename; + final String? host; + final int? port; + final int? size; + final String? token; + final String? filePath; + final int bytesTransferred; + final String? error; + + DccSession copyWith({ + String? id, + String? tabId, + String? peerNick, + DccSessionType? type, + DccSessionStatus? status, + String? direction, + String? filename, + String? host, + int? port, + int? size, + String? token, + String? filePath, + int? bytesTransferred, + String? error, + }) { + return DccSession( + id: id ?? this.id, + tabId: tabId ?? this.tabId, + peerNick: peerNick ?? this.peerNick, + type: type ?? this.type, + status: status ?? this.status, + direction: direction ?? this.direction, + filename: filename ?? this.filename, + host: host ?? this.host, + port: port ?? this.port, + size: size ?? this.size, + token: token ?? this.token, + filePath: filePath ?? this.filePath, + bytesTransferred: bytesTransferred ?? this.bytesTransferred, + error: error ?? this.error, + ); + } +} diff --git a/lib/core/models/irc_message.dart b/lib/core/models/irc_message.dart index 268d54c..1cee4c7 100644 --- a/lib/core/models/irc_message.dart +++ b/lib/core/models/irc_message.dart @@ -7,6 +7,8 @@ class IrcMessage { required this.sender, required this.content, required this.timestamp, + this.tags = const {}, + this.isPlayback = false, this.isOwn = false, this.kind = IrcMessageKind.chat, }); @@ -16,6 +18,8 @@ class IrcMessage { final String sender; final String content; final DateTime timestamp; + final Map tags; + final bool isPlayback; final bool isOwn; final IrcMessageKind kind; @@ -26,6 +30,8 @@ class IrcMessage { 'sender': sender, 'content': content, 'timestamp': timestamp.toIso8601String(), + 'tags': tags, + 'isPlayback': isPlayback, 'isOwn': isOwn, 'kind': kind.name, }; @@ -38,6 +44,8 @@ class IrcMessage { sender: json['sender']! as String, content: json['content']! as String, timestamp: DateTime.parse(json['timestamp']! as String), + tags: Map.from((json['tags'] as Map?) ?? const {}), + isPlayback: (json['isPlayback'] as bool?) ?? false, isOwn: (json['isOwn'] as bool?) ?? false, kind: IrcMessageKind.values.byName( (json['kind'] as String?) ?? IrcMessageKind.chat.name, diff --git a/lib/dcc/services/dcc_file_store.dart b/lib/dcc/services/dcc_file_store.dart new file mode 100644 index 0000000..a737682 --- /dev/null +++ b/lib/dcc/services/dcc_file_store.dart @@ -0,0 +1,21 @@ +import 'dcc_file_store_stub.dart' if (dart.library.io) 'dcc_file_store_io.dart'; + +abstract class DccFileSink { + void add(List bytes); + + Future flush(); + + Future close(); +} + +typedef DccTempFile = ({String path, DccFileSink sink}); +typedef DccSourceFile = ({ + String path, + String fileName, + int size, + Future> Function() readAllBytes, +}); + +Future createDccTempFile(String fileName) => createPlatformDccTempFile(fileName); + +Future openDccSourceFile(String path) => openPlatformDccSourceFile(path); diff --git a/lib/dcc/services/dcc_file_store_io.dart b/lib/dcc/services/dcc_file_store_io.dart new file mode 100644 index 0000000..460f59d --- /dev/null +++ b/lib/dcc/services/dcc_file_store_io.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'dcc_file_store.dart'; + +class _IoDccFileSink implements DccFileSink { + _IoDccFileSink(this._sink); + + final IOSink _sink; + + @override + void add(List bytes) { + _sink.add(bytes); + } + + @override + Future close() => _sink.close(); + + @override + Future flush() => _sink.flush(); +} + +Future createPlatformDccTempFile(String fileName) async { + final sanitized = fileName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'); + final path = '${Directory.systemTemp.path}/$sanitized'; + final file = File(path); + final sink = file.openWrite(mode: FileMode.writeOnly); + return (path: path, sink: _IoDccFileSink(sink)); +} + +Future openPlatformDccSourceFile(String path) async { + final file = File(path); + final exists = await file.exists(); + if (!exists) { + throw FileSystemException('DCC source file does not exist.', path); + } + + final stat = await file.stat(); + return ( + path: file.path, + fileName: file.uri.pathSegments.isEmpty ? file.path : file.uri.pathSegments.last, + size: stat.size, + readAllBytes: file.readAsBytes, + ); +} diff --git a/lib/dcc/services/dcc_file_store_stub.dart b/lib/dcc/services/dcc_file_store_stub.dart new file mode 100644 index 0000000..66fc83f --- /dev/null +++ b/lib/dcc/services/dcc_file_store_stub.dart @@ -0,0 +1,9 @@ +import 'dcc_file_store.dart'; + +Future createPlatformDccTempFile(String fileName) async { + throw UnsupportedError('DCC file storage is only supported on IO platforms.'); +} + +Future openPlatformDccSourceFile(String path) async { + throw UnsupportedError('Outgoing DCC SEND is only supported on IO platforms.'); +} diff --git a/lib/dcc/services/dcc_service.dart b/lib/dcc/services/dcc_service.dart new file mode 100644 index 0000000..c23c5e5 --- /dev/null +++ b/lib/dcc/services/dcc_service.dart @@ -0,0 +1,341 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:androidircx/core/models/dcc_session.dart'; + +import 'dcc_file_store.dart'; +import 'dcc_socket_backend.dart'; + +typedef DccMessageEvent = ({String tabId, String sender, String content, bool isOwn}); + +class DccService { + DccService({ + DccSocketBackend? backend, + }) : _backend = backend ?? createDccSocketBackend(); + + final DccSocketBackend _backend; + final Map _sessions = {}; + final Map _connections = {}; + final Map _servers = {}; + final Map>> _subscriptions = + >>{}; + final StreamController _sessionController = + StreamController.broadcast(sync: true); + final StreamController _messageController = + StreamController.broadcast(sync: true); + + Stream get sessions => _sessionController.stream; + Stream get messages => _messageController.stream; + DccSession? sessionForTab(String tabId) => _sessions[tabId]; + + void _emitSession(DccSession session) { + if (!_sessionController.isClosed) { + _sessionController.add(session); + } + } + + void _emitMessage(DccMessageEvent event) { + if (!_messageController.isClosed) { + _messageController.add(event); + } + } + + void registerSession(DccSession session) { + _sessions[session.tabId] = session; + _emitSession(session); + } + + Future accept(DccSession session) async { + switch (session.type) { + case DccSessionType.chat: + await _acceptChat(session); + case DccSessionType.send: + await _acceptSend(session); + case DccSessionType.unknown: + final failed = session.copyWith( + status: DccSessionStatus.failed, + error: 'Unsupported DCC offer.', + ); + _sessions[session.tabId] = failed; + _emitSession(failed); + } + } + + Future close(DccSession session) async { + await _subscriptions.remove(session.tabId)?.cancel(); + await _connections.remove(session.tabId)?.close(); + await _servers.remove(session.tabId)?.close(); + final closed = session.copyWith(status: DccSessionStatus.closed); + _sessions[session.tabId] = closed; + _emitSession(closed); + } + + Future sendChatMessage({ + required DccSession session, + required String sender, + required String text, + }) async { + final connection = _connections[session.tabId]; + if (connection == null || session.status != DccSessionStatus.connected) { + return false; + } + + await connection.sendBytes(utf8.encode('$text\n')); + _emitMessage(( + tabId: session.tabId, + sender: sender, + content: text, + isOwn: true, + )); + return true; + } + + Future startOutgoingChat({ + required String peerNick, + required void Function(String ctcpOffer) onOfferReady, + required String tabId, + }) async { + final server = await _backend.bindEphemeral(); + final session = DccSession( + id: 'dcc-${DateTime.now().microsecondsSinceEpoch}', + tabId: tabId, + peerNick: peerNick, + type: DccSessionType.chat, + status: DccSessionStatus.offering, + direction: 'outgoing', + host: server.address, + port: server.port, + ); + _sessions[tabId] = session; + _servers[tabId] = server; + _emitSession(session); + + final ipValue = _ipToInt(server.address); + final payloadHost = ipValue == null ? server.address : ipValue.toString(); + onOfferReady('\u0001DCC CHAT chat $payloadHost ${server.port}\u0001'); + + unawaited( + server.connections.first.then((connection) async { + _connections[tabId] = connection; + final connected = session.copyWith(status: DccSessionStatus.connected); + _sessions[tabId] = connected; + _emitSession(connected); + _subscriptions[tabId] = connection.bytes.listen( + (data) => _emitMessage(( + tabId: tabId, + sender: peerNick, + content: utf8.decode(data).trim(), + isOwn: false, + )), + onDone: () { + final closed = connected.copyWith(status: DccSessionStatus.closed); + _sessions[tabId] = closed; + _emitSession(closed); + }, + onError: (Object error, StackTrace stackTrace) { + final failed = connected.copyWith( + status: DccSessionStatus.failed, + error: error.toString(), + ); + _sessions[tabId] = failed; + _emitSession(failed); + }, + ); + }), + ); + + return session; + } + + Future startOutgoingSend({ + required String peerNick, + required String filePath, + required void Function(String ctcpOffer) onOfferReady, + required String tabId, + }) async { + final sourceFile = await openDccSourceFile(filePath); + final server = await _backend.bindEphemeral(); + final session = DccSession( + id: 'dcc-${DateTime.now().microsecondsSinceEpoch}', + tabId: tabId, + peerNick: peerNick, + type: DccSessionType.send, + status: DccSessionStatus.offering, + direction: 'outgoing', + filename: sourceFile.fileName, + host: server.address, + port: server.port, + size: sourceFile.size, + filePath: sourceFile.path, + ); + _sessions[tabId] = session; + _servers[tabId] = server; + _emitSession(session); + + final ipValue = _ipToInt(server.address); + final payloadHost = ipValue == null ? server.address : ipValue.toString(); + onOfferReady( + '\u0001DCC SEND "${sourceFile.fileName}" $payloadHost ${server.port} ${sourceFile.size}\u0001', + ); + + unawaited( + server.connections.first.then((connection) async { + _connections[tabId] = connection; + final connected = session.copyWith(status: DccSessionStatus.connected); + _sessions[tabId] = connected; + _emitSession(connected); + try { + final payload = await sourceFile.readAllBytes(); + await connection.sendBytes(payload); + final completed = connected.copyWith( + status: DccSessionStatus.closed, + bytesTransferred: payload.length, + ); + _sessions[tabId] = completed; + _emitSession(completed); + await connection.close(); + } catch (error) { + final failed = connected.copyWith( + status: DccSessionStatus.failed, + error: error.toString(), + ); + _sessions[tabId] = failed; + _emitSession(failed); + } + }), + ); + + return session; + } + + Future dispose() async { + for (final subscription in _subscriptions.values) { + await subscription.cancel(); + } + for (final connection in _connections.values) { + await connection.close(); + } + for (final server in _servers.values) { + await server.close(); + } + await _sessionController.close(); + await _messageController.close(); + } + + Future _acceptChat(DccSession session) async { + final connecting = session.copyWith(status: DccSessionStatus.connecting); + _sessions[session.tabId] = connecting; + _emitSession(connecting); + try { + final connection = await _backend.connect( + host: session.host ?? '', + port: session.port ?? 0, + ); + _connections[session.tabId] = connection; + final connected = connecting.copyWith(status: DccSessionStatus.connected); + _sessions[session.tabId] = connected; + _emitSession(connected); + _subscriptions[session.tabId] = connection.bytes.listen( + (data) => _emitMessage(( + tabId: session.tabId, + sender: session.peerNick, + content: utf8.decode(data).trim(), + isOwn: false, + )), + onDone: () { + final closed = connected.copyWith(status: DccSessionStatus.closed); + _sessions[session.tabId] = closed; + _emitSession(closed); + }, + onError: (Object error, StackTrace stackTrace) { + final failed = connected.copyWith( + status: DccSessionStatus.failed, + error: error.toString(), + ); + _sessions[session.tabId] = failed; + _emitSession(failed); + }, + ); + } catch (error) { + final failed = connecting.copyWith( + status: DccSessionStatus.failed, + error: error.toString(), + ); + _sessions[session.tabId] = failed; + _emitSession(failed); + } + } + + Future _acceptSend(DccSession session) async { + final connecting = session.copyWith(status: DccSessionStatus.connecting); + _sessions[session.tabId] = connecting; + _emitSession(connecting); + DccFileSink? sink; + try { + final connection = await _backend.connect( + host: session.host ?? '', + port: session.port ?? 0, + ); + _connections[session.tabId] = connection; + final fileName = session.filename ?? 'dcc-download.bin'; + final tempFile = await createDccTempFile(fileName); + sink = tempFile.sink; + final connected = connecting.copyWith( + status: DccSessionStatus.connected, + filePath: tempFile.path, + ); + _sessions[session.tabId] = connected; + _emitSession(connected); + var transferred = 0; + _subscriptions[session.tabId] = connection.bytes.listen( + (data) async { + sink!.add(data); + transferred += data.length; + final updated = connected.copyWith(bytesTransferred: transferred); + _sessions[session.tabId] = updated; + _emitSession(updated); + final ack = ByteData(4)..setUint32(0, transferred, Endian.big); + await connection.sendBytes(ack.buffer.asUint8List()); + }, + onDone: () async { + await sink?.flush(); + await sink?.close(); + final completed = connected.copyWith( + status: DccSessionStatus.closed, + bytesTransferred: transferred, + ); + _sessions[session.tabId] = completed; + _emitSession(completed); + }, + onError: (Object error, StackTrace stackTrace) async { + await sink?.close(); + final failed = connected.copyWith( + status: DccSessionStatus.failed, + error: error.toString(), + bytesTransferred: transferred, + ); + _sessions[session.tabId] = failed; + _emitSession(failed); + }, + ); + } catch (error) { + await sink?.close(); + final failed = connecting.copyWith( + status: DccSessionStatus.failed, + error: error.toString(), + ); + _sessions[session.tabId] = failed; + _emitSession(failed); + } + } + + int? _ipToInt(String ip) { + final parts = ip.split('.').map(int.tryParse).toList(growable: false); + if (parts.length != 4 || parts.any((part) => part == null)) { + return null; + } + + return ((parts[0]! << 24) >>> 0) + (parts[1]! << 16) + (parts[2]! << 8) + parts[3]!; + } +} diff --git a/lib/dcc/services/dcc_socket_backend.dart b/lib/dcc/services/dcc_socket_backend.dart new file mode 100644 index 0000000..a9910f6 --- /dev/null +++ b/lib/dcc/services/dcc_socket_backend.dart @@ -0,0 +1,31 @@ +import 'dcc_socket_backend_stub.dart' + if (dart.library.io) 'dcc_socket_backend_io.dart'; + +abstract class DccSocketConnection { + Stream> get bytes; + + Future sendBytes(List data); + + Future close(); +} + +abstract class DccSocketServer { + Stream get connections; + + int get port; + + String get address; + + Future close(); +} + +abstract class DccSocketBackend { + Future connect({ + required String host, + required int port, + }); + + Future bindEphemeral(); +} + +DccSocketBackend createDccSocketBackend() => createPlatformDccSocketBackend(); diff --git a/lib/dcc/services/dcc_socket_backend_io.dart b/lib/dcc/services/dcc_socket_backend_io.dart new file mode 100644 index 0000000..57ebe7c --- /dev/null +++ b/lib/dcc/services/dcc_socket_backend_io.dart @@ -0,0 +1,64 @@ +import 'dart:async'; +import 'dart:io'; + +import 'dcc_socket_backend.dart'; + +class _IoDccSocketConnection implements DccSocketConnection { + _IoDccSocketConnection(this._socket); + + final Socket _socket; + + @override + Stream> get bytes => _socket; + + @override + Future close() async { + await _socket.close(); + } + + @override + Future sendBytes(List data) async { + _socket.add(data); + await _socket.flush(); + } +} + +class _IoDccSocketServer implements DccSocketServer { + _IoDccSocketServer(this._server); + + final ServerSocket _server; + + @override + String get address => _server.address.address; + + @override + Stream get connections => + _server.map((socket) => _IoDccSocketConnection(socket)); + + @override + int get port => _server.port; + + @override + Future close() async { + await _server.close(); + } +} + +class _IoDccSocketBackend implements DccSocketBackend { + @override + Future bindEphemeral() async { + final server = await ServerSocket.bind(InternetAddress.anyIPv4, 0); + return _IoDccSocketServer(server); + } + + @override + Future connect({ + required String host, + required int port, + }) async { + final socket = await Socket.connect(host, port); + return _IoDccSocketConnection(socket); + } +} + +DccSocketBackend createPlatformDccSocketBackend() => _IoDccSocketBackend(); diff --git a/lib/dcc/services/dcc_socket_backend_stub.dart b/lib/dcc/services/dcc_socket_backend_stub.dart new file mode 100644 index 0000000..226a9ee --- /dev/null +++ b/lib/dcc/services/dcc_socket_backend_stub.dart @@ -0,0 +1,18 @@ +import 'dcc_socket_backend.dart'; + +class _UnsupportedDccSocketBackend implements DccSocketBackend { + @override + Future bindEphemeral() { + throw UnsupportedError('DCC sockets are only supported on IO platforms.'); + } + + @override + Future connect({ + required String host, + required int port, + }) { + throw UnsupportedError('DCC sockets are only supported on IO platforms.'); + } +} + +DccSocketBackend createPlatformDccSocketBackend() => _UnsupportedDccSocketBackend(); diff --git a/lib/features/chat/application/chat_session_controller.dart b/lib/features/chat/application/chat_session_controller.dart index 05295b4..8632f50 100644 --- a/lib/features/chat/application/chat_session_controller.dart +++ b/lib/features/chat/application/chat_session_controller.dart @@ -2,26 +2,39 @@ import 'dart:async'; import 'package:androidircx/core/models/chat_tab.dart'; import 'package:androidircx/core/models/connection_state.dart'; +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/core/models/app_settings.dart'; +import 'package:androidircx/dcc/services/dcc_service.dart'; import 'package:androidircx/features/chat/application/command_service.dart'; import 'package:androidircx/core/storage/settings_repository.dart'; import 'package:androidircx/core/storage/shared_prefs_settings_repository.dart'; import 'package:androidircx/features/chat/data/chat_session_persistence.dart'; import 'package:androidircx/features/chat/presentation/join_channel_dialog.dart'; import 'package:androidircx/irc/models/irc_message_frame.dart'; +import 'package:androidircx/irc/parser/ctcp.dart'; +import 'package:androidircx/irc/parser/dcc_parser.dart'; import 'package:androidircx/irc/services/irc_service.dart'; import 'package:flutter/foundation.dart'; class ChatSessionController extends ChangeNotifier { + 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'; + static const _ctcpSourceReply = 'https://github.com/AndroidIRCx/AndroidIRCx-Flutter'; + static const _ctcpFingerReply = 'AndroidIRCx Flutter'; + ChatSessionController({ required this.network, IrcService? ircService, + DccService? dccService, ChatSessionPersistence? persistence, SettingsRepository? settingsRepository, CommandService? commandService, }) : _ircService = ircService ?? IrcService(), + _dccService = dccService ?? DccService(), _persistence = persistence ?? ChatSessionPersistence(), _settingsRepository = settingsRepository ?? SharedPrefsSettingsRepository(), @@ -39,6 +52,7 @@ class ChatSessionController extends ChangeNotifier { final NetworkConfig network; final IrcService _ircService; + final DccService _dccService; final ChatSessionPersistence _persistence; final SettingsRepository _settingsRepository; final CommandService _commandService; @@ -46,6 +60,13 @@ class ChatSessionController extends ChangeNotifier { final Map> _channelUsers = {}; final Map _channelTopics = {}; final Map _channelModes = {}; + final Map _activeBatches = {}; + final Set _autoHistoryRequestedChannels = {}; + final Map _readMarkers = {}; + final Map>> _messageReactions = {}; + final Map> _typingUsersByTab = {}; + final Map> _multilineBuffers = {}; + final Map _dccSessions = {}; final List> _subscriptions = []; Timer? _reconnectTimer; @@ -70,10 +91,20 @@ class ChatSessionController extends ChangeNotifier { Duration? get pendingReconnectDelay => _pendingReconnectDelay; ChatTab get activeTab => _tabs.firstWhere((tab) => tab.id == _activeTabId); String get currentNick => _ircService.currentNick ?? network.nickname; + bool get canRequestServerHistory => + activeTab.type != ChatTabType.server && _ircService.supportsChatHistory; + bool get canRequestOlderServerHistory => + canRequestServerHistory && _oldestMsgIdForTab(_activeTabId) != null; + bool get canRequestNewerServerHistory => + canRequestServerHistory && _latestMsgIdForTab(_activeTabId) != null; int get activityCount => _tabs.where((tab) => tab.hasActivity).length; bool get hasActivity => activityCount > 0; String? get activeChannelTopic => _channelTopics[activeTabId]; String? get activeChannelModes => _channelModes[activeTabId]; + DateTime? get activeReadMarker => _readMarkers[activeTabId]; + List get activeTypingUsers => + List.unmodifiable((_typingUsersByTab[activeTabId] ?? const {}).toList()..sort()); + DccSession? get activeDccSession => _dccSessions[activeTabId]; String get activeChannelSummary { if (activeTab.type != ChatTabType.channel) { return ''; @@ -97,14 +128,47 @@ class ChatSessionController extends ChangeNotifier { return List.unmodifiable(sorted); } List get activeMessages { - final source = _messages[_activeTabId] ?? const []; - if (_settings.showRawEvents) { - return List.unmodifiable(source); + return messagesForTab(_activeTabId); + } + + IrcMessage? messageByMsgId(String tabId, String msgid) { + final normalized = msgid.trim(); + if (normalized.isEmpty) { + return null; } - return List.unmodifiable( - source.where((message) => message.kind != IrcMessageKind.raw), - ); + final messages = _messages[tabId]; + if (messages == null) { + return null; + } + + for (final message in messages) { + if (message.tags['msgid'] == normalized) { + return message; + } + } + + return null; + } + + Map reactionsForMessage(IrcMessage message) { + final msgid = (message.tags['msgid'] ?? '').trim(); + if (msgid.isEmpty) { + return const {}; + } + + final reactions = _messageReactions[msgid]; + if (reactions == null || reactions.isEmpty) { + return const {}; + } + + final summary = {}; + final entries = reactions.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); + for (final entry in entries) { + summary[entry.key] = entry.value.length; + } + return summary; } Future start() async { @@ -132,6 +196,34 @@ class ChatSessionController extends ChangeNotifier { unawaited(_persistState()); notifyListeners(); })); + _subscriptions.add(_ircService.labeledResponses.listen((event) { + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: + 'Labeled response matched: ${event.command} [${event.label}]', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + })); + _subscriptions.add(_dccService.sessions.listen((session) { + final previous = _dccSessions[session.tabId]; + _dccSessions[session.tabId] = session; + _appendDccStatusMessage(previous: previous, next: session); + unawaited(_persistState()); + notifyListeners(); + })); + _subscriptions.add(_dccService.messages.listen((event) { + _appendMessage( + tabId: event.tabId, + sender: event.sender, + content: event.content, + isOwn: event.isOwn, + ); + unawaited(_persistState()); + notifyListeners(); + })); } _manualDisconnectRequested = false; @@ -142,6 +234,7 @@ class ChatSessionController extends ChangeNotifier { void selectTab(String tabId) { _activeTabId = tabId; _setTabActivity(tabId, false); + unawaited(_sendReadMarkerForTab(tabId)); unawaited(_persistState()); notifyListeners(); } @@ -152,10 +245,16 @@ class ChatSessionController extends ChangeNotifier { return; } + final dccSession = _dccSessions[tabId]; + if (dccSession != null) { + unawaited(_dccService.close(dccSession)); + } + _tabs = _tabs.where((item) => item.id != tabId).toList(growable: false); _messages.remove(tabId); _channelUsers.remove(tabId); _channelTopics.remove(tabId); + _dccSessions.remove(tabId); if (_activeTabId == tabId) { _activeTabId = _serverTabId(network.id); @@ -166,7 +265,7 @@ class ChatSessionController extends ChangeNotifier { notifyListeners(); } - Future handleComposerSubmit(String input) async { + Future handleComposerSubmit(String input, {String? replyTo}) async { final text = _commandService.normalizeCommand(input.trim()); if (text.isEmpty) { return; @@ -184,15 +283,417 @@ class ChatSessionController extends ChangeNotifier { return; } - await _ircService.sendPrivmsg(target: activeTab.name, text: text); + if (activeTab.type == ChatTabType.dcc) { + await _handleDccComposerSubmit(text); + return; + } + + final normalizedReply = (replyTo ?? '').trim(); + await _ircService.sendPrivmsg( + target: activeTab.name, + text: text, + replyTo: normalizedReply.isEmpty ? null : normalizedReply, + ); + if (!_ircService.enabledCapabilities.contains('echo-message')) { + _appendMessage( + tabId: activeTab.id, + sender: _ircService.currentNick ?? network.nickname, + content: text, + tags: { + if (normalizedReply.isNotEmpty) 'draft/reply': normalizedReply, + }, + isOwn: true, + ); + } + unawaited(_ircService.sendTyping(target: activeTab.name, status: 'done')); + unawaited(_persistState()); + notifyListeners(); + } + + Future acceptActiveDccSession() async { + final session = activeDccSession; + if (session == null) { + return; + } + await _dccService.accept(session); + final latest = _dccService.sessionForTab(session.tabId); + if (latest != null) { + _dccSessions[session.tabId] = latest; + } + _appendMessage( + tabId: session.tabId, + sender: '*', + content: session.type == DccSessionType.chat + ? 'DCC CHAT accept requested.' + : 'DCC SEND accept requested.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + } + + Future declineActiveDccSession() async { + final session = activeDccSession; + if (session == null) { + return; + } + await _dccService.close(session); + final latest = _dccService.sessionForTab(session.tabId); + if (latest != null) { + _dccSessions[session.tabId] = latest; + } + _appendMessage( + tabId: session.tabId, + sender: '*', + content: 'DCC session declined.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + } + + Future closeActiveDccSession() async { + final session = activeDccSession; + if (session == null) { + return; + } + await _dccService.close(session); + final latest = _dccService.sessionForTab(session.tabId); + if (latest != null) { + _dccSessions[session.tabId] = latest; + } + _appendMessage( + tabId: session.tabId, + sender: '*', + content: 'DCC session closed.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + } + + Future _handleDccComposerSubmit(String text) async { + final session = activeDccSession; + if (session == null) { + return; + } + + if (session.type != DccSessionType.chat) { + _appendMessage( + tabId: session.tabId, + sender: 'error', + content: 'DCC SEND tabs do not support chat messages.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return; + } + + if (session.status != DccSessionStatus.connected) { + _appendMessage( + tabId: session.tabId, + sender: 'error', + content: 'DCC CHAT is not connected yet.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return; + } + + await _dccService.sendChatMessage( + session: session, + sender: currentNick, + text: text, + ); + unawaited(_persistState()); + notifyListeners(); + } + + Future _startOutgoingDccChat(String nick) async { + final sessionId = 'outgoing-chat-${DateTime.now().microsecondsSinceEpoch}'; + final tab = _ensureDccTab( + sessionId: sessionId, + name: 'DCC CHAT $nick', + ); + _activeTabId = tab.id; + try { + await _dccService.startOutgoingChat( + peerNick: nick, + tabId: tab.id, + onOfferReady: (ctcpOffer) { + unawaited(_ircService.sendRaw('PRIVMSG $nick :$ctcpOffer')); + }, + ); + _appendMessage( + tabId: tab.id, + sender: '*', + content: 'Offering DCC CHAT to $nick.', + kind: IrcMessageKind.system, + ); + } catch (error) { + _appendMessage( + tabId: tab.id, + sender: 'error', + content: 'Unable to start DCC CHAT offer: $error', + kind: IrcMessageKind.system, + ); + } + unawaited(_persistState()); + notifyListeners(); + } + + Future _startOutgoingDccSend({ + required String nick, + required String filePath, + }) async { + final sessionId = 'outgoing-send-${DateTime.now().microsecondsSinceEpoch}'; + final tab = _ensureDccTab( + sessionId: sessionId, + name: 'DCC SEND $nick', + ); + _activeTabId = tab.id; + try { + final session = await _dccService.startOutgoingSend( + peerNick: nick, + filePath: filePath, + tabId: tab.id, + onOfferReady: (ctcpOffer) { + unawaited(_ircService.sendRaw('PRIVMSG $nick :$ctcpOffer')); + }, + ); + _appendMessage( + tabId: tab.id, + sender: '*', + content: + 'Offering DCC SEND to $nick: ${session.filename ?? filePath} (${session.size ?? 0} bytes).', + kind: IrcMessageKind.system, + ); + } catch (error) { + _appendMessage( + tabId: tab.id, + sender: 'error', + content: 'Unable to start DCC SEND offer: $error', + kind: IrcMessageKind.system, + ); + } + unawaited(_persistState()); + notifyListeners(); + } + + List messagesForTab( + String tabId, { + String query = '', + Set? kinds, + }) { + final source = _messages[tabId] ?? const []; + final normalizedQuery = query.trim().toLowerCase(); + final effectiveKinds = kinds ?? {}; + return List.unmodifiable( + source.where((message) { + if (!_settings.showRawEvents && message.kind == IrcMessageKind.raw) { + return false; + } + if (effectiveKinds.isNotEmpty && !effectiveKinds.contains(message.kind)) { + return false; + } + if (normalizedQuery.isEmpty) { + return true; + } + + return message.sender.toLowerCase().contains(normalizedQuery) || + message.content.toLowerCase().contains(normalizedQuery); + }), + ); + } + + String exportTabHistory( + String tabId, { + String query = '', + Set? kinds, + }) { + final messages = messagesForTab(tabId, query: query, kinds: kinds); + return messages + .map((message) { + final stamp = message.timestamp.toIso8601String(); + final tags = message.tags.isEmpty + ? '' + : ' [tags: ${message.tags.entries.map((e) => e.value == null ? e.key : '${e.key}=${e.value}').join(', ')}]'; + return '[$stamp] <${message.sender}> ${message.content}$tags'; + }) + .join('\n'); + } + + Future requestRecentHistory({int limit = 50}) async { + if (activeTab.type == ChatTabType.server) { + _appendMessage( + tabId: _serverTabId(network.id), + sender: 'error', + content: 'Open a channel or query tab to request CHATHISTORY.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return false; + } + + final normalizedLimit = limit.clamp(1, 200); + final success = await _ircService.sendChatHistory( + target: activeTab.name, + subcommand: 'LATEST', + reference: '*', + limit: normalizedLimit, + ); + _appendMessage( + tabId: _serverTabId(network.id), + sender: success ? '*' : 'error', + content: success + ? 'Requested recent history for ${activeTab.name} ($normalizedLimit messages).' + : 'CHATHISTORY is not supported by this server.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return success; + } + + Future requestOlderHistory({int limit = 50}) async { + if (activeTab.type == ChatTabType.server) { + _appendMessage( + tabId: _serverTabId(network.id), + sender: 'error', + content: 'Open a channel or query tab to request CHATHISTORY.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return false; + } + + final reference = _oldestMsgIdForTab(_activeTabId); + if (reference == null) { + _appendMessage( + tabId: _serverTabId(network.id), + sender: 'error', + content: 'No history anchor is available yet for ${activeTab.name}.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return false; + } + + final normalizedLimit = limit.clamp(1, 200); + final success = await _ircService.sendChatHistory( + target: activeTab.name, + subcommand: 'BEFORE', + reference: reference, + limit: normalizedLimit, + ); + _appendMessage( + tabId: _serverTabId(network.id), + sender: success ? '*' : 'error', + content: success + ? 'Requested older history for ${activeTab.name} before $reference ($normalizedLimit messages).' + : 'CHATHISTORY is not supported by this server.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return success; + } + + Future requestNewerHistory({int limit = 50}) async { + if (activeTab.type == ChatTabType.server) { + _appendMessage( + tabId: _serverTabId(network.id), + sender: 'error', + content: 'Open a channel or query tab to request CHATHISTORY.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return false; + } + + final reference = _latestMsgIdForTab(_activeTabId); + if (reference == null) { + _appendMessage( + tabId: _serverTabId(network.id), + sender: 'error', + content: 'No recent history anchor is available yet for ${activeTab.name}.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return false; + } + + final normalizedLimit = limit.clamp(1, 200); + final success = await _ircService.sendChatHistory( + target: activeTab.name, + subcommand: 'AFTER', + reference: reference, + limit: normalizedLimit, + ); + _appendMessage( + tabId: _serverTabId(network.id), + sender: success ? '*' : 'error', + content: success + ? 'Requested newer history for ${activeTab.name} after $reference ($normalizedLimit messages).' + : 'CHATHISTORY is not supported by this server.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return success; + } + + Future requestAroundLatestHistory({int limit = 50}) async { + if (activeTab.type == ChatTabType.server) { + _appendMessage( + tabId: _serverTabId(network.id), + sender: 'error', + content: 'Open a channel or query tab to request CHATHISTORY.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return false; + } + + final reference = _latestMsgIdForTab(_activeTabId); + if (reference == null) { + _appendMessage( + tabId: _serverTabId(network.id), + sender: 'error', + content: 'No recent history anchor is available yet for ${activeTab.name}.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return false; + } + + final normalizedLimit = limit.clamp(1, 200); + final success = await _ircService.sendChatHistory( + target: activeTab.name, + subcommand: 'AROUND', + reference: reference, + limit: normalizedLimit, + ); _appendMessage( - tabId: activeTab.id, - sender: _ircService.currentNick ?? network.nickname, - content: text, - isOwn: true, + tabId: _serverTabId(network.id), + sender: success ? '*' : 'error', + content: success + ? 'Requested surrounding history for ${activeTab.name} around $reference ($normalizedLimit messages).' + : 'CHATHISTORY is not supported by this server.', + kind: IrcMessageKind.system, ); unawaited(_persistState()); notifyListeners(); + return success; } Future joinChannel(JoinChannelRequest request) async { @@ -225,26 +726,85 @@ class ChatSessionController extends ChangeNotifier { notifyListeners(); } - void _handleConnectionLifecycle(ConnectionSnapshot snapshot) { - if (snapshot.phase == ConnectionPhase.connected) { - _reconnectAttempt = 0; - _pendingReconnectDelay = null; - _manualDisconnectRequested = false; - _cancelReconnect(); + Future updateTypingState(String text) async { + if (activeTab.type == ChatTabType.server) { return; } - if (_manualDisconnectRequested) { - return; + final trimmed = text.trim(); + final status = trimmed.isEmpty ? 'done' : 'active'; + await _ircService.sendTyping(target: activeTab.name, status: status); + } + + Future redactMessage(IrcMessage message) async { + final msgid = (message.tags['msgid'] ?? '').trim(); + if (msgid.isEmpty) { + return false; } - if (snapshot.phase == ConnectionPhase.error || - snapshot.phase == ConnectionPhase.disconnected) { - _scheduleReconnect(); + final target = _targetForTabId(message.tabId); + if (target == null) { + return false; } - } - void _scheduleReconnect() { + final success = await _ircService.redactMessage(target: target, msgid: msgid); + if (!success) { + _appendMessage( + tabId: _serverTabId(network.id), + sender: 'error', + content: 'MESSAGE-REDACTION is not supported by this server.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return false; + } + + _replaceMessageByMsgId( + tabId: message.tabId, + msgid: msgid, + transform: (existing) => IrcMessage( + id: existing.id, + tabId: existing.tabId, + sender: existing.sender, + content: '[message deleted]', + timestamp: existing.timestamp, + tags: { + ...existing.tags, + 'redacted': 'true', + }, + isPlayback: existing.isPlayback, + isOwn: existing.isOwn, + kind: existing.kind, + ), + ); + unawaited(_persistState()); + notifyListeners(); + return true; + } + + void _handleConnectionLifecycle(ConnectionSnapshot snapshot) { + if (snapshot.phase == ConnectionPhase.connected) { + _autoHistoryRequestedChannels.clear(); + _reconnectAttempt = 0; + _pendingReconnectDelay = null; + _manualDisconnectRequested = false; + _cancelReconnect(); + return; + } + + if (_manualDisconnectRequested) { + return; + } + + if (snapshot.phase == ConnectionPhase.error || + snapshot.phase == ConnectionPhase.disconnected) { + _autoHistoryRequestedChannels.clear(); + _scheduleReconnect(); + } + } + + void _scheduleReconnect() { if (_reconnectTimer?.isActive ?? false) { return; } @@ -297,13 +857,18 @@ class ChatSessionController extends ChangeNotifier { final text = rest.substring(space + 1).trim(); if (text.isNotEmpty) { final tab = _ensureQueryTab(target); - await _ircService.sendPrivmsg(target: target, text: text); - _appendMessage( - tabId: tab.id, - sender: _ircService.currentNick ?? network.nickname, - content: text, - isOwn: true, + await _ircService.sendPrivmsg( + target: target, + text: text, ); + if (!_ircService.enabledCapabilities.contains('echo-message')) { + _appendMessage( + tabId: tab.id, + sender: _ircService.currentNick ?? network.nickname, + content: text, + isOwn: true, + ); + } unawaited(_persistState()); notifyListeners(); return; @@ -320,12 +885,14 @@ class ChatSessionController extends ChangeNotifier { _activeTabId = tabId; } await _ircService.sendNotice(target: target, text: text); - _appendMessage( - tabId: tabId, - sender: currentNick, - content: text, - isOwn: true, - ); + if (!_ircService.enabledCapabilities.contains('echo-message')) { + _appendMessage( + tabId: tabId, + sender: currentNick, + content: text, + isOwn: true, + ); + } unawaited(_persistState()); notifyListeners(); return; @@ -364,21 +931,84 @@ class ChatSessionController extends ChangeNotifier { case 'me': if (rest.isNotEmpty && activeTab.type != ChatTabType.server) { await _ircService.sendAction(target: activeTab.name, text: rest); - _appendMessage( - tabId: activeTab.id, - sender: _ircService.currentNick ?? network.nickname, - content: '• $rest', - isOwn: true, + if (!_ircService.enabledCapabilities.contains('echo-message')) { + _appendMessage( + tabId: activeTab.id, + sender: _ircService.currentNick ?? network.nickname, + content: '• $rest', + isOwn: true, + ); + } + unawaited(_persistState()); + notifyListeners(); + return; + } + case 'ctcp': + final segments = rest.split(RegExp(r'\s+')); + if (segments.length >= 2) { + final target = segments.first; + final ctcpCommand = segments[1].toUpperCase(); + final args = segments.length > 2 ? segments.skip(2).join(' ') : null; + await _ircService.sendCtcpRequest( + target: target, + command: ctcpCommand, + args: args, ); + final tabId = _resolveOutgoingMessageTabId(target); + if (!target.startsWith('#')) { + _activeTabId = tabId; + } + if (!_ircService.enabledCapabilities.contains('echo-message')) { + _appendMessage( + tabId: tabId, + sender: currentNick, + content: _formatOutgoingCtcpMessage(ctcpCommand, args), + isOwn: true, + kind: IrcMessageKind.system, + ); + } unawaited(_persistState()); notifyListeners(); return; } + case 'dccchat': + if (rest.isNotEmpty) { + final nick = rest.split(RegExp(r'\s+')).first.trim(); + if (nick.isNotEmpty) { + await _startOutgoingDccChat(nick); + return; + } + } + case 'dccsend': + final separator = rest.indexOf(' '); + if (separator != -1) { + final nick = rest.substring(0, separator).trim(); + final filePath = rest.substring(separator + 1).trim(); + if (nick.isNotEmpty && filePath.isNotEmpty) { + await _startOutgoingDccSend(nick: nick, filePath: filePath); + return; + } + } case 'nick': if (rest.isNotEmpty) { await _ircService.sendRaw('NICK $rest'); return; } + case 'setname': + if (rest.isNotEmpty) { + final success = await _ircService.sendSetName(rest); + _appendMessage( + tabId: _serverTabId(network.id), + sender: success ? '*' : 'error', + content: success + ? 'Requested realname change to: $rest' + : 'SETNAME command is not supported by this server.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return; + } case 'whois': if (rest.isNotEmpty) { await _ircService.sendWhois(rest.split(' ').first); @@ -412,6 +1042,36 @@ class ChatSessionController extends ChangeNotifier { unawaited(_persistState()); notifyListeners(); return; + case 'chathistory': + if (activeTab.type == ChatTabType.server) { + _appendMessage( + tabId: _serverTabId(network.id), + sender: 'error', + content: 'Usage: open a channel or query tab, then use /chathistory [limit]', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return; + } + final request = _parseChatHistoryRequest(rest); + final success = await _ircService.sendChatHistory( + target: activeTab.name, + subcommand: request.subcommand, + reference: request.reference, + limit: request.limit, + ); + _appendMessage( + tabId: _serverTabId(network.id), + sender: success ? '*' : 'error', + content: success + ? 'Requested CHATHISTORY ${request.subcommand} for ${activeTab.name} (${request.reference}, ${request.limit} messages).' + : 'CHATHISTORY is not supported by this server.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return; case 'motd': await _ircService.sendMotd(); _appendMessage( @@ -456,6 +1116,37 @@ class ChatSessionController extends ChangeNotifier { unawaited(_persistState()); notifyListeners(); return; + case 'ison': + final nicks = rest.split(RegExp(r'\s+')).where((nick) => nick.trim().isNotEmpty).toList(growable: false); + if (nicks.isNotEmpty) { + await _ircService.sendIson(nicks); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: 'Requested ISON for ${nicks.join(', ')}.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return; + } + case 'userhost': + final nicks = rest.split(RegExp(r'\s+')).where((nick) => nick.trim().isNotEmpty).toList(growable: false); + if (nicks.isNotEmpty) { + await _ircService.sendUserhost(nicks); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: 'Requested USERHOST for ${nicks.join(', ')}.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return; + } + case 'monitor': + await _handleMonitorCommand(rest); + return; case 'invite': if (rest.isNotEmpty && activeTab.type == ChatTabType.channel) { await _ircService.sendInvite( @@ -698,6 +1389,34 @@ class ChatSessionController extends ChangeNotifier { kind: IrcMessageKind.system, ); } + case '328': + if (frame.params.length >= 2 && frame.trailing != null) { + final channel = frame.params[1]; + final tab = _ensureChannelTab(channel); + _appendMessage( + tabId: tab.id, + sender: '*', + content: 'Channel URL: ${frame.trailing!}', + kind: IrcMessageKind.system, + ); + } + case '329': + if (frame.params.length >= 3) { + final channel = frame.params[1]; + final createdAt = int.tryParse(frame.params[2]); + final tab = _ensureChannelTab(channel); + final createdText = createdAt == null + ? frame.params[2] + : DateTime.fromMillisecondsSinceEpoch(createdAt * 1000, isUtc: true) + .toLocal() + .toString(); + _appendMessage( + tabId: tab.id, + sender: '*', + content: 'Channel created: $createdText', + kind: IrcMessageKind.system, + ); + } case '321': _appendMessage( tabId: _serverTabId(network.id), @@ -727,6 +1446,24 @@ class ChatSessionController extends ChangeNotifier { content: frame.trailing ?? 'End of channel list.', kind: IrcMessageKind.system, ); + case '302': + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: frame.trailing == null || frame.trailing!.trim().isEmpty + ? 'USERHOST: no users returned.' + : 'USERHOST: ${frame.trailing!}', + kind: IrcMessageKind.system, + ); + case '303': + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: frame.trailing == null || frame.trailing!.trim().isEmpty + ? 'ISON: nobody online.' + : 'ISON online: ${frame.trailing!}', + kind: IrcMessageKind.system, + ); case '311': _appendWhoisMessage( frame, @@ -751,6 +1488,24 @@ class ChatSessionController extends ChangeNotifier { frame, 'WHOIS server: ${frame.params.length > 2 ? '${frame.params[1]} on ${frame.params[2]} ${frame.trailing ?? ''}'.trim() : frame.raw}', ); + case '301': + _appendWhoisMessage( + frame, + frame.trailing == null + ? frame.raw + : 'WHOIS away: ${frame.params.length > 1 ? frame.params[1] : ''} ${frame.trailing!}'.trim(), + ); + case '307': + case '313': + case '330': + case '338': + case '378': + case '379': + case '671': + _appendWhoisMessage( + frame, + frame.trailing ?? frame.params.skip(1).join(' '), + ); case '317': _appendWhoisMessage( frame, @@ -830,6 +1585,7 @@ class ChatSessionController extends ChangeNotifier { content: frame.trailing ?? 'Nick list complete.', kind: IrcMessageKind.system, ); + unawaited(_requestAutoHistoryOnJoin(channel)); } case '367': if (frame.params.length >= 3) { @@ -845,6 +1601,57 @@ class ChatSessionController extends ChangeNotifier { kind: IrcMessageKind.system, ); } + case '730': + case '731': + final isOnline = frame.command == '730'; + final rawTargets = frame.trailing ?? (frame.params.length > 1 ? frame.params[1] : ''); + final nicknames = rawTargets + .split(',') + .map((entry) => entry.trim()) + .where((entry) => entry.isNotEmpty) + .toList(growable: false); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: nicknames.isEmpty + ? (isOnline ? 'MONITOR online update.' : 'MONITOR offline update.') + : 'MONITOR ${isOnline ? 'online' : 'offline'}: ${nicknames.join(', ')}', + kind: IrcMessageKind.system, + ); + case '732': + final entries = (frame.trailing ?? (frame.params.length > 1 ? frame.params[1] : '')).trim(); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: entries.isEmpty ? 'MONITOR list is empty.' : 'MONITOR list: $entries', + kind: IrcMessageKind.system, + ); + case '733': + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: frame.trailing ?? 'End of MONITOR list.', + kind: IrcMessageKind.system, + ); + case '734': + _appendMessage( + tabId: _serverTabId(network.id), + sender: 'error', + content: frame.trailing ?? 'MONITOR list is full.', + kind: IrcMessageKind.system, + ); + case '341': + if (frame.params.length >= 3) { + final nick = frame.params[1]; + final channel = frame.params[2]; + final tab = _ensureChannelTab(channel); + _appendMessage( + tabId: tab.id, + sender: '*', + content: 'Invitation sent to $nick for $channel.', + kind: IrcMessageKind.system, + ); + } case '368': if (frame.params.length >= 2) { final channel = frame.params[1]; @@ -856,6 +1663,18 @@ class ChatSessionController extends ChangeNotifier { kind: IrcMessageKind.system, ); } + case 'INVITE': + if (frame.trailing != null) { + final channel = frame.trailing!; + final inviter = frame.senderNick ?? '*'; + final tab = _ensureChannelTab(channel); + _appendMessage( + tabId: tab.id, + sender: '*', + content: '$inviter invited you to $channel', + kind: IrcMessageKind.system, + ); + } case 'JOIN': final channel = frame.trailing ?? _firstOrNull(frame.params); if (channel != null) { @@ -876,12 +1695,33 @@ class ChatSessionController extends ChangeNotifier { final channel = _firstOrNull(frame.params); if (channel != null) { final tab = _ensureChannelTab(channel); - _channelUsers.putIfAbsent(tab.id, () => {}).remove(frame.senderNick ?? ''); + final partingNick = frame.senderNick ?? ''; + _channelUsers.putIfAbsent(tab.id, () => {}).remove(partingNick); + if (_isSelfNick(partingNick)) { + _autoHistoryRequestedChannels.remove(channel.toLowerCase()); + } + _appendMessage( + tabId: tab.id, + sender: '*', + content: + '$partingNick left $channel${frame.trailing == null ? '' : ' (${frame.trailing})'}', + kind: IrcMessageKind.system, + ); + } + case 'KICK': + if (frame.params.length >= 2) { + final channel = frame.params[0]; + final kickedNick = frame.params[1]; + final tab = _ensureChannelTab(channel); + _channelUsers.putIfAbsent(tab.id, () => {}).remove(kickedNick); + if (_isSelfNick(kickedNick)) { + _autoHistoryRequestedChannels.remove(channel.toLowerCase()); + } _appendMessage( tabId: tab.id, sender: '*', content: - '${frame.senderNick ?? '*'} left $channel${frame.trailing == null ? '' : ' (${frame.trailing})'}', + '$kickedNick was kicked from $channel by ${frame.senderNick ?? '*'}${frame.trailing == null ? '' : ' (${frame.trailing})'}', kind: IrcMessageKind.system, ); } @@ -908,6 +1748,22 @@ class ChatSessionController extends ChangeNotifier { ); case 'CAP': _handleCapabilityFrame(frame); + case 'ACCOUNT': + _handleAccount(frame); + case 'AWAY': + _handleAway(frame); + case 'CHGHOST': + _handleChgHost(frame); + case 'SETNAME': + _handleSetName(frame); + case 'MARKREAD': + _handleMarkRead(frame); + case 'REDACT': + _handleRedact(frame); + case 'BATCH': + _handleBatch(frame); + case 'TAGMSG': + _handleTagmsg(frame); case 'NOTICE': _handleNotice(frame); case 'TOPIC': @@ -917,6 +1773,13 @@ class ChatSessionController extends ChangeNotifier { case 'PRIVMSG': _handlePrivmsg(frame); case '401': + case '471': + case '473': + case '474': + case '475': + case '476': + case '477': + case '482': case '403': case '442': case '421': @@ -938,96 +1801,659 @@ class ChatSessionController extends ChangeNotifier { break; } - unawaited(_persistState()); - notifyListeners(); + unawaited(_persistState()); + notifyListeners(); + } + + void _handleNotice(IrcMessageFrame frame) { + final target = _firstOrNull(frame.params); + final content = frame.trailing; + if (target == null || content == null) { + return; + } + + final ctcp = parseCtcp(content); + if (ctcp.isCtcp && ctcp.command != null) { + _handleCtcpReply(frame, ctcp); + return; + } + + final tabId = _resolveNoticeTabId(target: target, senderNick: frame.senderNick); + + _appendMessage( + tabId: tabId, + sender: frame.senderNick ?? 'notice', + content: content, + timestamp: _timestampForFrame(frame), + tags: frame.tags, + isPlayback: _isPlaybackBatch(frame.tags['batch']), + isOwn: _isSelfEcho(frame.senderNick), + ); + _markActivityIfInactive(tabId); + } + + void _handlePrivmsg(IrcMessageFrame frame) { + final target = _firstOrNull(frame.params); + final content = frame.trailing; + if (target == null || content == null) { + return; + } + + final intentTag = frame.tags['draft/intent']?.toUpperCase(); + if (intentTag == 'ACTION') { + final tabId = _resolveMessageTabId( + target: target, + senderNick: frame.senderNick, + preferServerForDirectMessages: false, + ); + _appendMessage( + tabId: tabId, + sender: frame.senderNick ?? target, + content: '• $content', + timestamp: _timestampForFrame(frame), + tags: frame.tags, + isPlayback: _isPlaybackBatch(frame.tags['batch']), + isOwn: _isSelfEcho(frame.senderNick), + ); + _markActivityIfInactive(tabId); + _incrementBatchCount(frame.tags['batch']); + return; + } + + final ctcp = parseCtcp(content); + if (ctcp.isCtcp && ctcp.command != null) { + if (ctcp.command == 'ACTION') { + final tabId = _resolveMessageTabId( + target: target, + senderNick: frame.senderNick, + preferServerForDirectMessages: false, + ); + _appendMessage( + tabId: tabId, + sender: frame.senderNick ?? target, + content: '• ${ctcp.args ?? ''}'.trimRight(), + timestamp: _timestampForFrame(frame), + tags: frame.tags, + isPlayback: _isPlaybackBatch(frame.tags['batch']), + isOwn: _isSelfEcho(frame.senderNick), + ); + _markActivityIfInactive(tabId); + _incrementBatchCount(frame.tags['batch']); + return; + } + + _handleCtcpRequest(frame, ctcp); + return; + } + + final tabId = _resolveMessageTabId( + target: target, + senderNick: frame.senderNick, + preferServerForDirectMessages: false, + ); + final assembledContent = _assembleMultilineContent( + frame: frame, + tabId: tabId, + content: _normalizeContent(content), + ); + if (assembledContent == null) { + return; + } + + _appendMessage( + tabId: tabId, + sender: frame.senderNick ?? target, + content: assembledContent, + timestamp: _timestampForFrame(frame), + tags: frame.tags, + isPlayback: _isPlaybackBatch(frame.tags['batch']), + isOwn: _isSelfEcho(frame.senderNick), + ); + _markActivityIfInactive(tabId); + _incrementBatchCount(frame.tags['batch']); + } + + void _handleTagmsg(IrcMessageFrame frame) { + final target = _firstOrNull(frame.params); + if (target == null) { + return; + } + + final tabId = _resolveNoticeTabId(target: target, senderNick: frame.senderNick); + final reactTag = frame.tags['+draft/react'] ?? frame.tags['+react']; + final typingTag = frame.tags['+typing'] ?? frame.tags['+draft/typing']; + + if (reactTag != null && reactTag.contains(';')) { + final separator = reactTag.indexOf(';'); + final referencedMsgid = reactTag.substring(0, separator).trim(); + final emoji = reactTag.substring(separator + 1).trim(); + if (referencedMsgid.isNotEmpty && emoji.isNotEmpty) { + _recordReaction(referencedMsgid, emoji, frame.senderNick ?? 'unknown'); + } + } + + if (typingTag != null && (frame.senderNick ?? '').trim().isNotEmpty) { + _updateTypingState( + tabId: tabId, + nick: frame.senderNick!, + state: typingTag, + ); + } + + _appendMessage( + tabId: tabId, + sender: '*', + content: + 'TAGMSG from ${frame.senderNick ?? target}${frame.tags.isEmpty ? '' : ' (${frame.tags.keys.join(', ')})'}', + timestamp: _timestampForFrame(frame), + tags: frame.tags, + isPlayback: _isPlaybackBatch(frame.tags['batch']), + kind: IrcMessageKind.system, + ); + _markActivityIfInactive(tabId); + _incrementBatchCount(frame.tags['batch']); + } + + void _handleTopic(IrcMessageFrame frame) { + final channel = _firstOrNull(frame.params); + final topic = frame.trailing; + if (channel == null || topic == null) { + return; + } + + final tab = _ensureChannelTab(channel); + _channelTopics[tab.id] = topic; + _appendMessage( + tabId: tab.id, + sender: '*', + content: '${frame.senderNick ?? '*'} changed the topic to: $topic', + kind: IrcMessageKind.system, + ); + _markActivityIfInactive(tab.id); + } + + void _handleMode(IrcMessageFrame frame) { + if (frame.params.length < 2) { + return; + } + + final target = frame.params.first; + final modeText = [...frame.params.skip(1), if (frame.trailing != null) frame.trailing!].join(' '); + final tabId = target.startsWith('#') + ? _ensureChannelTab(target).id + : _serverTabId(network.id); + _appendMessage( + tabId: tabId, + sender: '*', + content: '${frame.senderNick ?? '*'} set mode $modeText on $target', + kind: IrcMessageKind.system, + ); + _markActivityIfInactive(tabId); + } + + void _handleAccount(IrcMessageFrame frame) { + final nick = frame.senderNick ?? 'unknown'; + final account = _firstOrNull(frame.params) ?? '*'; + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: account == '*' + ? '$nick logged out' + : '$nick logged in as $account', + kind: IrcMessageKind.system, + ); + } + + void _handleAway(IrcMessageFrame frame) { + final nick = frame.senderNick ?? 'unknown'; + final awayMessage = frame.trailing ?? _firstOrNull(frame.params) ?? ''; + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: awayMessage.isEmpty + ? '$nick is no longer away' + : '$nick is now away: $awayMessage', + kind: IrcMessageKind.system, + ); + } + + void _handleChgHost(IrcMessageFrame frame) { + final nick = frame.senderNick ?? 'unknown'; + final newHost = frame.params.length > 1 ? frame.params[1] : frame.trailing ?? ''; + if (newHost.isEmpty) { + return; + } + + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: '$nick changed host to $newHost', + kind: IrcMessageKind.system, + ); + } + + void _handleSetName(IrcMessageFrame frame) { + final nick = frame.senderNick ?? 'unknown'; + final newRealName = frame.trailing ?? _firstOrNull(frame.params) ?? ''; + if (newRealName.isEmpty) { + return; + } + + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: '$nick changed realname to: $newRealName', + kind: IrcMessageKind.system, + ); + } + + void _handleMarkRead(IrcMessageFrame frame) { + final target = _firstOrNull(frame.params); + if (target == null) { + return; + } + + final timestampParam = frame.params.length > 1 ? frame.params[1] : ''; + final match = RegExp(r'timestamp=(\d+)').firstMatch(timestampParam); + final markerTimestamp = match == null + ? DateTime.now() + : DateTime.fromMillisecondsSinceEpoch(int.parse(match.group(1)!)); + final tabId = _targetToTabId(target); + if (tabId == null) { + return; + } + + _readMarkers[tabId] = markerTimestamp; + _appendMessage( + tabId: tabId, + sender: '*', + content: + '${frame.senderNick ?? 'Someone'} marked $target as read at ${markerTimestamp.toLocal().toIso8601String()}', + kind: IrcMessageKind.system, + ); + } + + void _handleRedact(IrcMessageFrame frame) { + if (frame.params.length < 2) { + return; + } + + final target = frame.params[0]; + final msgid = frame.params[1].trim(); + if (msgid.isEmpty) { + return; + } + + final tabId = _targetToTabId(target); + if (tabId == null) { + return; + } + + final replaced = _replaceMessageByMsgId( + tabId: tabId, + msgid: msgid, + transform: (existing) => IrcMessage( + id: existing.id, + tabId: existing.tabId, + sender: existing.sender, + content: '[message deleted]', + timestamp: existing.timestamp, + tags: { + ...existing.tags, + 'redacted': 'true', + }, + isPlayback: existing.isPlayback, + isOwn: existing.isOwn, + kind: existing.kind, + ), + ); + + _appendMessage( + tabId: tabId, + sender: '*', + content: + '${frame.senderNick ?? 'Someone'} deleted a message${replaced ? '' : ' ($msgid)'}', + kind: IrcMessageKind.system, + ); + _markActivityIfInactive(tabId); + } + + String? _assembleMultilineContent({ + required IrcMessageFrame frame, + required String tabId, + required String content, + }) { + final concatTag = frame.tags['draft/multiline-concat']; + if (concatTag == null) { + return content; + } + + final sender = frame.senderNick ?? ''; + final target = _firstOrNull(frame.params) ?? ''; + final key = '${sender.toLowerCase()}|${target.toLowerCase()}|$tabId'; + final buffer = _multilineBuffers.putIfAbsent(key, () => []); + buffer.add(content); + final isLast = concatTag.isEmpty; + if (!isLast) { + return null; + } + + _multilineBuffers.remove(key); + return buffer.join('\n'); + } + + void _recordReaction(String msgid, String emoji, String nick) { + final emojiUsers = _messageReactions.putIfAbsent(msgid, () => >{}); + emojiUsers.putIfAbsent(emoji, () => {}).add(nick); + } + + void _updateTypingState({ + required String tabId, + required String nick, + required String state, + }) { + final normalizedState = state.trim().toLowerCase(); + final users = _typingUsersByTab.putIfAbsent(tabId, () => {}); + if (normalizedState == 'active' || normalizedState == 'composing') { + users.add(nick); + } else { + users.remove(nick); + } + if (users.isEmpty) { + _typingUsersByTab.remove(tabId); + } + } + + String _normalizeContent(String content) { + const actionPrefix = '\u0001ACTION '; + if (content.startsWith(actionPrefix) && content.endsWith('\u0001')) { + return '• ${content.substring(actionPrefix.length, content.length - 1)}'; + } + + return content; + } + + Future _requestAutoHistoryOnJoin(String channel, {int limit = 50}) async { + final normalizedChannel = channel.trim(); + if (normalizedChannel.isEmpty || !_ircService.supportsChatHistory) { + return; + } + + final key = normalizedChannel.toLowerCase(); + if (!_autoHistoryRequestedChannels.add(key)) { + return; + } + + final success = await _ircService.sendChatHistory( + target: normalizedChannel, + subcommand: 'LATEST', + reference: '*', + limit: limit, + ); + if (!success) { + _autoHistoryRequestedChannels.remove(key); + } + } + + Future _sendReadMarkerForTab(String tabId) async { + final target = _targetForTabId(tabId); + if (target == null || !_ircService.supportsReadMarker) { + return; + } + + final latestTimestamp = _messages[tabId] + ?.map((message) => message.timestamp.millisecondsSinceEpoch) + .fold(null, (current, value) => current == null || value > current ? value : current); + + final success = await _ircService.sendReadMarker( + target: target, + timestampMillis: latestTimestamp, + ); + if (success) { + _readMarkers[tabId] = DateTime.fromMillisecondsSinceEpoch( + latestTimestamp ?? DateTime.now().millisecondsSinceEpoch, + ); + } + } + + void _handleBatch(IrcMessageFrame frame) { + if (frame.params.isEmpty) { + return; + } + + final batchToken = frame.params.first; + if (batchToken.startsWith('+')) { + final ref = batchToken.substring(1); + final type = frame.params.length > 1 ? frame.params[1] : 'unknown'; + _activeBatches[ref] = (type: type, messageCount: 0); + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: 'BATCH start: $type${frame.params.length > 2 ? ' ${frame.params.skip(2).join(' ')}' : ''}', + timestamp: _timestampForFrame(frame), + tags: frame.tags, + kind: IrcMessageKind.system, + ); + return; + } + + if (batchToken.startsWith('-')) { + final ref = batchToken.substring(1); + final batch = _activeBatches.remove(ref); + final type = batch?.type ?? 'unknown'; + final summary = switch (type) { + 'chathistory' || 'history' || 'znc.in/playback' => + 'Playback batch completed: ${batch?.messageCount ?? 0} messages', + 'netsplit' => 'Netsplit batch completed: ${batch?.messageCount ?? 0} events', + 'netjoin' => 'Netjoin batch completed: ${batch?.messageCount ?? 0} events', + _ => 'BATCH end: $type (${batch?.messageCount ?? 0} messages)', + }; + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: summary, + timestamp: _timestampForFrame(frame), + tags: frame.tags, + kind: IrcMessageKind.system, + ); + } } - void _handleNotice(IrcMessageFrame frame) { + void _handleCtcpRequest(IrcMessageFrame frame, CtcpMessage ctcp) { final target = _firstOrNull(frame.params); - final content = frame.trailing; - if (target == null || content == null) { + final senderNick = frame.senderNick; + if (target == null || senderNick == null || ctcp.command == null) { return; } final tabId = _resolveMessageTabId( target: target, - senderNick: frame.senderNick, + senderNick: senderNick, preferServerForDirectMessages: false, ); - + final command = ctcp.command!; + if (command == 'DCC') { + final sessionTabId = _registerIncomingDccOffer( + senderNick: senderNick, + args: ctcp.args, + ); + _appendMessage( + tabId: sessionTabId, + sender: '*', + content: _formatIncomingCtcpRequest(senderNick, command, ctcp.args), + kind: IrcMessageKind.system, + ); + _activeTabId = sessionTabId; + _markActivityIfInactive(sessionTabId); + notifyListeners(); + return; + } _appendMessage( tabId: tabId, - sender: frame.senderNick ?? 'notice', - content: content, + sender: '*', + content: _formatIncomingCtcpRequest(senderNick, command, ctcp.args), + kind: IrcMessageKind.system, ); _markActivityIfInactive(tabId); + unawaited(_respondToCtcpRequest(senderNick, command, ctcp.args)); } - void _handlePrivmsg(IrcMessageFrame frame) { + String _registerIncomingDccOffer({ + required String senderNick, + required String? args, + }) { + final offer = parseDccOffer('DCC ${args ?? ''}'); + final now = DateTime.now().millisecondsSinceEpoch; + final sessionId = '${senderNick.toLowerCase()}-$now'; + final type = switch (offer?.command) { + 'CHAT' => DccSessionType.chat, + 'SEND' => DccSessionType.send, + _ => DccSessionType.unknown, + }; + final tabName = switch (type) { + DccSessionType.chat => 'DCC CHAT $senderNick', + DccSessionType.send => 'DCC SEND ${offer?.filename ?? senderNick}', + DccSessionType.unknown => 'DCC $senderNick', + }; + final tab = _ensureDccTab(sessionId: sessionId, name: tabName); + final session = DccSession( + id: sessionId, + tabId: tab.id, + peerNick: senderNick, + type: type, + status: DccSessionStatus.pending, + direction: 'incoming', + filename: offer?.filename, + host: offer?.host, + port: offer?.port, + size: offer?.size, + token: offer?.token, + ); + _dccService.registerSession(session); + _dccSessions[tab.id] = session; + return tab.id; + } + + void _handleCtcpReply(IrcMessageFrame frame, CtcpMessage ctcp) { final target = _firstOrNull(frame.params); - final content = frame.trailing; - if (target == null || content == null) { + final senderNick = frame.senderNick; + if (target == null || senderNick == null || ctcp.command == null) { return; } final tabId = _resolveMessageTabId( target: target, - senderNick: frame.senderNick, + senderNick: senderNick, preferServerForDirectMessages: false, ); - _appendMessage( tabId: tabId, - sender: frame.senderNick ?? target, - content: _normalizeContent(content), + sender: '*', + content: _formatIncomingCtcpReply(senderNick, ctcp.command!, ctcp.args), + kind: IrcMessageKind.system, ); _markActivityIfInactive(tabId); } - void _handleTopic(IrcMessageFrame frame) { - final channel = _firstOrNull(frame.params); - final topic = frame.trailing; - if (channel == null || topic == null) { - return; + Future _respondToCtcpRequest(String from, String command, String? args) async { + switch (command) { + case 'VERSION': + await _ircService.sendCtcpReply( + target: from, + command: 'VERSION', + args: _ctcpVersionReply, + ); + case 'TIME': + await _ircService.sendCtcpReply( + target: from, + command: 'TIME', + args: DateTime.now().toUtc().toIso8601String(), + ); + case 'PING': + await _ircService.sendCtcpReply( + target: from, + command: 'PING', + args: (args ?? '').trim().isEmpty + ? DateTime.now().millisecondsSinceEpoch.toString() + : args, + ); + case 'CLIENTINFO': + await _ircService.sendCtcpReply( + target: from, + command: 'CLIENTINFO', + args: _ctcpClientInfoReply, + ); + case 'USERINFO': + await _ircService.sendCtcpReply( + target: from, + command: 'USERINFO', + args: _ctcpUserInfoReply, + ); + case 'SOURCE': + await _ircService.sendCtcpReply( + target: from, + command: 'SOURCE', + args: _ctcpSourceReply, + ); + case 'FINGER': + await _ircService.sendCtcpReply( + target: from, + command: 'FINGER', + args: _ctcpFingerReply, + ); + case 'DCC': + case 'XDCC': + case 'TDCC': + case 'RDCC': + case 'ACTION': + return; + default: + return; } + } - final tab = _ensureChannelTab(channel); - _channelTopics[tab.id] = topic; - _appendMessage( - tabId: tab.id, - sender: '*', - content: '${frame.senderNick ?? '*'} changed the topic to: $topic', - kind: IrcMessageKind.system, - ); - _markActivityIfInactive(tab.id); + String _formatOutgoingCtcpMessage(String command, String? args) { + final suffix = (args ?? '').trim(); + if (suffix.isEmpty) { + return 'Sent CTCP $command'; + } + + return 'Sent CTCP $command: $suffix'; } - void _handleMode(IrcMessageFrame frame) { - if (frame.params.length < 2) { - return; + String _formatIncomingCtcpRequest(String from, String command, String? args) { + if (command == 'DCC') { + final offer = parseDccOffer('DCC ${args ?? ''}'); + if (offer != null) { + return switch (offer.command) { + 'CHAT' => 'DCC CHAT request from $from: ${offer.host ?? '?'}:${offer.port ?? 0}', + 'SEND' => 'DCC SEND offer from $from: ${offer.filename ?? 'file'} (${offer.size ?? 0} bytes) ${offer.host ?? '?'}:${offer.port ?? 0}', + _ => 'CTCP DCC request from $from: ${args ?? ''}', + }; + } + } + if (command == 'XDCC' || command == 'TDCC' || command == 'RDCC') { + final suffix = (args ?? '').trim(); + return suffix.isEmpty + ? 'CTCP $command request from $from' + : 'CTCP $command request from $from: $suffix'; } - final target = frame.params.first; - final modeText = [...frame.params.skip(1), if (frame.trailing != null) frame.trailing!].join(' '); - final tabId = target.startsWith('#') - ? _ensureChannelTab(target).id - : _serverTabId(network.id); - _appendMessage( - tabId: tabId, - sender: '*', - content: '${frame.senderNick ?? '*'} set mode $modeText on $target', - kind: IrcMessageKind.system, - ); - _markActivityIfInactive(tabId); + final suffix = (args ?? '').trim(); + if (suffix.isEmpty) { + return 'CTCP $command request from $from'; + } + + return 'CTCP $command request from $from: $suffix'; } - String _normalizeContent(String content) { - const actionPrefix = '\u0001ACTION '; - if (content.startsWith(actionPrefix) && content.endsWith('\u0001')) { - return '• ${content.substring(actionPrefix.length, content.length - 1)}'; + String _formatIncomingCtcpReply(String from, String command, String? args) { + final suffix = (args ?? '').trim(); + if (suffix.isEmpty) { + return 'CTCP $command reply from $from'; } - return content; + return 'CTCP $command reply from $from: $suffix'; } ChatTab _ensureChannelTab(String channel) { @@ -1066,6 +2492,91 @@ class ChatSessionController extends ChangeNotifier { return tab; } + ChatTab _ensureNoticeTab() { + final existing = _findTab(_noticeTabId(network.id)); + if (existing != null) { + return existing; + } + + final tab = ChatTab( + id: _noticeTabId(network.id), + name: 'Notices', + type: ChatTabType.notice, + networkId: network.id, + ); + _tabs = [..._tabs, tab]; + _messages.putIfAbsent(tab.id, () => []); + return tab; + } + + ChatTab _ensureDccTab({ + required String sessionId, + required String name, + }) { + final tabId = _dccTabId(network.id, sessionId); + final existing = _findTab(tabId); + if (existing != null) { + return existing; + } + + final tab = ChatTab( + id: tabId, + name: name, + type: ChatTabType.dcc, + networkId: network.id, + ); + _tabs = [..._tabs, tab]; + _messages.putIfAbsent(tab.id, () => []); + return tab; + } + + void _appendDccStatusMessage({ + required DccSession? previous, + required DccSession next, + }) { + if (previous?.status == next.status) { + if (previous?.bytesTransferred == next.bytesTransferred) { + return; + } + if (next.type != DccSessionType.send || next.bytesTransferred <= 0) { + return; + } + } + + final content = switch (next.status) { + DccSessionStatus.offering => next.type == DccSessionType.chat + ? 'DCC CHAT offer created for ${next.peerNick}.' + : 'DCC SEND offer created for ${next.peerNick}.', + DccSessionStatus.connecting => next.type == DccSessionType.chat + ? 'Connecting DCC CHAT session...' + : 'Connecting DCC SEND transfer...', + DccSessionStatus.connected => next.type == DccSessionType.chat + ? 'DCC CHAT connected.' + : next.direction == 'outgoing' + ? 'DCC SEND transfer started for ${next.filename ?? 'file'}.' + : 'Receiving ${next.filename ?? 'file'} to ${next.filePath ?? 'temporary storage'}.', + DccSessionStatus.closed => next.type == DccSessionType.chat + ? 'DCC CHAT session closed.' + : next.direction == 'outgoing' + ? 'DCC SEND finished (${next.bytesTransferred} bytes sent).' + : 'DCC SEND finished (${next.bytesTransferred} bytes saved to ${next.filePath ?? 'temporary storage'}).', + DccSessionStatus.failed => + 'DCC ${next.type == DccSessionType.chat ? 'CHAT' : 'SEND'} failed: ${next.error ?? 'unknown error'}', + DccSessionStatus.pending => null, + }; + + if (content == null) { + return; + } + + _appendMessage( + tabId: next.tabId, + sender: '*', + content: content, + kind: IrcMessageKind.system, + ); + } + String _resolveMessageTabId({ required String target, required String? senderNick, @@ -1075,6 +2586,11 @@ class ChatSessionController extends ChangeNotifier { return _ensureChannelTab(target).id; } + if (_isSelfEcho(senderNick) && + target != (_ircService.currentNick ?? network.nickname)) { + return _ensureQueryTab(target).id; + } + final normalizedSender = _normalizeServiceNick(senderNick); if (normalizedSender != null && _isServiceNick(normalizedSender)) { return _ensureQueryTab(normalizedSender).id; @@ -1095,6 +2611,29 @@ class ChatSessionController extends ChangeNotifier { return _ensureQueryTab(target).id; } + String _resolveNoticeTabId({ + required String target, + required String? senderNick, + }) { + switch (_settings.noticeRouting) { + case NoticeRoutingMode.server: + return _resolveMessageTabId( + target: target, + senderNick: senderNick, + preferServerForDirectMessages: false, + ); + case NoticeRoutingMode.active: + return activeTab.id; + case NoticeRoutingMode.notice: + return _ensureNoticeTab().id; + case NoticeRoutingMode.private: + if (senderNick != null && senderNick.trim().isNotEmpty) { + return _ensureQueryTab(senderNick).id; + } + return _ensureNoticeTab().id; + } + } + ChatTab? _findTab(String id) { for (final tab in _tabs) { if (tab.id == id) { @@ -1109,17 +2648,26 @@ class ChatSessionController extends ChangeNotifier { required String tabId, required String sender, required String content, + DateTime? timestamp, + Map tags = const {}, + bool isPlayback = false, bool isOwn = false, IrcMessageKind kind = IrcMessageKind.chat, }) { final list = _messages.putIfAbsent(tabId, () => []); + final msgid = tags['msgid']; + if (msgid != null && list.any((item) => item.tags['msgid'] == msgid)) { + return; + } list.add( IrcMessage( id: '${DateTime.now().microsecondsSinceEpoch}-${list.length}', tabId: tabId, sender: sender, content: content, - timestamp: DateTime.now(), + timestamp: timestamp ?? DateTime.now(), + tags: Map.unmodifiable(tags), + isPlayback: isPlayback, isOwn: isOwn, kind: kind, ), @@ -1188,12 +2736,14 @@ class ChatSessionController extends ChangeNotifier { final tab = _ensureQueryTab(service); _activeTabId = tab.id; await _ircService.sendPrivmsg(target: service, text: command); - _appendMessage( - tabId: tab.id, - sender: currentNick, - content: command, - isOwn: true, - ); + if (!_ircService.enabledCapabilities.contains('echo-message')) { + _appendMessage( + tabId: tab.id, + sender: currentNick, + content: command, + isOwn: true, + ); + } unawaited(_persistState()); notifyListeners(); } @@ -1331,6 +2881,36 @@ class ChatSessionController extends ChangeNotifier { notifyListeners(); } + Future _handleMonitorCommand(String rest) async { + final segments = rest.split(RegExp(r'\s+')).where((part) => part.trim().isNotEmpty).toList(growable: false); + if (segments.isEmpty) { + _appendMessage( + tabId: _serverTabId(network.id), + sender: 'error', + content: 'Usage: /monitor <+|-|c|l|s> [nick[,nick...]]', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + return; + } + + final subcommand = segments.first.toUpperCase(); + final nicknameParts = segments.length > 1 + ? segments.skip(1).join(' ').split(RegExp(r'[\s,]+')).where((nick) => nick.trim().isNotEmpty).toList(growable: false) + : const []; + await _ircService.sendMonitor(subcommand: subcommand, nicknames: nicknameParts); + final detail = nicknameParts.isEmpty ? subcommand : '$subcommand ${nicknameParts.join(', ')}'; + _appendMessage( + tabId: _serverTabId(network.id), + sender: '*', + content: 'Requested MONITOR $detail.', + kind: IrcMessageKind.system, + ); + unawaited(_persistState()); + notifyListeners(); + } + String _normalizeNickPrefix(String value) { return value.replaceFirst(RegExp(r'^[~&@%+]'), ''); } @@ -1368,6 +2948,164 @@ class ChatSessionController extends ChangeNotifier { } } + bool _isSelfEcho(String? senderNick) { + final sender = senderNick?.trim(); + if (sender == null || sender.isEmpty) { + return false; + } + + return sender.toLowerCase() == + (_ircService.currentNick ?? network.nickname).toLowerCase(); + } + + DateTime? _timestampForFrame(IrcMessageFrame frame) { + final timeTag = frame.tags['time']; + if (timeTag == null || timeTag.trim().isEmpty) { + return null; + } + + return DateTime.tryParse(timeTag); + } + + bool _isPlaybackBatch(String? batchTag) { + final batchId = (batchTag ?? '').trim(); + if (batchId.isEmpty) { + return false; + } + + final batch = _activeBatches[batchId]; + if (batch == null) { + return false; + } + + return switch (batch.type) { + 'chathistory' || 'history' || 'znc.in/playback' => true, + _ => false, + }; + } + + ({String subcommand, String reference, int limit}) _parseChatHistoryRequest( + String rest, + ) { + final parts = rest + .trim() + .split(RegExp(r'\s+')) + .where((item) => item.isNotEmpty) + .toList(growable: false); + if (parts.isEmpty) { + return (subcommand: 'LATEST', reference: '*', limit: 50); + } + + final keyword = parts.first.toUpperCase(); + if (keyword == 'LATEST') { + final limit = parts.length > 1 ? int.tryParse(parts[1]) ?? 50 : 50; + return (subcommand: 'LATEST', reference: '*', limit: limit.clamp(1, 200)); + } + + if (keyword == 'BEFORE' || keyword == 'AFTER' || keyword == 'AROUND') { + final fallbackReference = _latestMsgIdForTab(activeTab.id) ?? '*'; + final second = parts.length > 1 ? parts[1] : null; + final third = parts.length > 2 ? parts[2] : null; + final secondAsLimit = second == null ? null : int.tryParse(second); + final reference = second == null || secondAsLimit != null + ? fallbackReference + : second; + final limit = third != null + ? int.tryParse(third) ?? 50 + : secondAsLimit ?? 50; + return ( + subcommand: keyword, + reference: reference, + limit: limit.clamp(1, 200), + ); + } + + final limit = int.tryParse(parts.first) ?? 50; + return (subcommand: 'LATEST', reference: '*', limit: limit.clamp(1, 200)); + } + + String? _latestMsgIdForTab(String tabId) { + final messages = _messages[tabId]; + if (messages == null) { + return null; + } + + for (final message in messages.reversed) { + final msgid = message.tags['msgid']; + if (msgid != null && msgid.trim().isNotEmpty) { + return msgid; + } + } + + return null; + } + + String? _oldestMsgIdForTab(String tabId) { + final messages = _messages[tabId]; + if (messages == null) { + return null; + } + + for (final message in messages) { + final msgid = message.tags['msgid']; + if (msgid != null && msgid.trim().isNotEmpty) { + return msgid; + } + } + + return null; + } + + bool _replaceMessageByMsgId({ + required String tabId, + required String msgid, + required IrcMessage Function(IrcMessage existing) transform, + }) { + final messages = _messages[tabId]; + if (messages == null) { + return false; + } + + final index = messages.indexWhere((message) => message.tags['msgid'] == msgid); + if (index == -1) { + return false; + } + + messages[index] = transform(messages[index]); + return true; + } + + String? _targetForTabId(String tabId) { + final tab = _findTab(tabId); + if (tab == null || tab.type == ChatTabType.server) { + return null; + } + + return tab.name; + } + + String? _targetToTabId(String target) { + final trimmed = target.trim(); + if (trimmed.isEmpty) { + return null; + } + + if (trimmed.startsWith('#')) { + return _ensureChannelTab(trimmed).id; + } + + return _ensureQueryTab(trimmed).id; + } + + bool _isSelfNick(String nick) { + final normalized = nick.trim().toLowerCase(); + if (normalized.isEmpty) { + return false; + } + + return normalized == (_ircService.currentNick ?? network.nickname).trim().toLowerCase(); + } + void _removeUserFromAllChannels(String? nick) { if (nick == null || nick.isEmpty) { return; @@ -1398,6 +3136,23 @@ class ChatSessionController extends ChangeNotifier { _setTabActivity(tabId, true); } + void _incrementBatchCount(String? batchTag) { + final batchId = (batchTag ?? '').trim(); + if (batchId.isEmpty) { + return; + } + + final batch = _activeBatches[batchId]; + if (batch == null) { + return; + } + + _activeBatches[batchId] = ( + type: batch.type, + messageCount: batch.messageCount + 1, + ); + } + void _setTabActivity(String tabId, bool hasActivity) { _tabs = _tabs .map( @@ -1412,11 +3167,14 @@ class ChatSessionController extends ChangeNotifier { for (final subscription in _subscriptions) { subscription.cancel(); } + _dccService.dispose(); _ircService.dispose(); super.dispose(); } } String _serverTabId(String networkId) => 'server::$networkId'; +String _noticeTabId(String networkId) => 'notice::$networkId'; String _channelTabId(String networkId, String name) => 'channel::$networkId::$name'; String _queryTabId(String networkId, String nick) => 'query::$networkId::$nick'; +String _dccTabId(String networkId, String sessionId) => 'dcc::$networkId::$sessionId'; diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index fb16efe..b22f076 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -1,13 +1,18 @@ import 'package:androidircx/core/models/chat_tab.dart'; import 'package:androidircx/core/models/connection_state.dart'; +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/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/irc/parser/message_content_parser.dart'; import 'package:androidircx/features/settings/presentation/settings_screen.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; class ChatScreen extends StatefulWidget { const ChatScreen({ @@ -23,6 +28,10 @@ class ChatScreen extends StatefulWidget { class _ChatScreenState extends State { final TextEditingController _composerController = TextEditingController(); + final TextEditingController _messageSearchController = TextEditingController(); + bool _messageSearchVisible = false; + _HistoryKindFilter _messageSearchFilter = _HistoryKindFilter.all; + IrcMessage? _pendingReplyMessage; ChatSessionController get _controller => widget.controller; @@ -35,6 +44,7 @@ class _ChatScreenState extends State { @override void dispose() { _composerController.dispose(); + _messageSearchController.dispose(); super.dispose(); } @@ -43,6 +53,13 @@ class _ChatScreenState extends State { return AnimatedBuilder( animation: _controller, builder: (context, _) { + final visibleMessages = _messageSearchVisible + ? _controller.messagesForTab( + _controller.activeTabId, + query: _messageSearchController.text, + kinds: _messageSearchFilter.kinds, + ) + : _controller.activeMessages; return Scaffold( appBar: AppBar( title: Column( @@ -53,7 +70,10 @@ class _ChatScreenState extends State { _controller.activeTab.type == ChatTabType.channel && _controller.activeChannelSummary.isNotEmpty ? _controller.activeChannelSummary - : _statusText(_controller.connection), + : _controller.activeTab.type == ChatTabType.dcc && + _controller.activeDccSession != null + ? _dccSummary(_controller.activeDccSession!) + : _statusText(_controller.connection), style: Theme.of(context).textTheme.bodySmall, ), ], @@ -69,6 +89,17 @@ class _ChatScreenState extends State { ); }, ), + 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', + ), IconButton( onPressed: _showJoinDialog, icon: const Icon(Icons.tag), @@ -193,8 +224,25 @@ class _ChatScreenState extends State { 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 { @@ -202,13 +250,35 @@ class _ChatScreenState extends State { }, ), Expanded( - child: _MessageList(messages: _controller.activeMessages), + 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, + ), ), 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), Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 16), @@ -220,10 +290,15 @@ class _ChatScreenState extends State { minLines: 1, maxLines: 4, textInputAction: TextInputAction.send, + onChanged: _controller.updateTypingState, onSubmitted: (_) => _submit(), decoration: InputDecoration( 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}', ), ), @@ -260,7 +335,9 @@ class _ChatScreenState extends State { void _submit() { final text = _composerController.text; _composerController.clear(); - _controller.handleComposerSubmit(text); + final replyTo = _pendingReplyMessage?.tags['msgid']; + setState(() => _pendingReplyMessage = null); + _controller.handleComposerSubmit(text, replyTo: replyTo); } Future _openSettings() async { @@ -272,6 +349,41 @@ class _ChatScreenState extends State { await _controller.reloadSettings(); } + Future _openHistoryTools() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _HistoryToolsSheet(controller: _controller), + ); + } + + void _toggleMessageSearch() { + setState(() { + if (_messageSearchVisible) { + _messageSearchVisible = false; + _messageSearchController.clear(); + _messageSearchFilter = _HistoryKindFilter.all; + } else { + _messageSearchVisible = true; + } + }); + } + + void _insertIntoComposer(String text) { + final existing = _composerController.text; + final next = existing.isEmpty ? text : '$existing $text'; + setState(() { + _composerController.text = next; + _composerController.selection = TextSelection.collapsed(offset: next.length); + }); + } + + void _setPendingReply(IrcMessage message) { + setState(() { + _pendingReplyMessage = message.tags['msgid'] == null ? null : message; + }); + } + String _statusText(ConnectionSnapshot snapshot) { switch (snapshot.phase) { case ConnectionPhase.idle: @@ -299,10 +411,542 @@ class _ChatScreenState extends State { return Icons.alternate_email; case ChatTabType.notice: return Icons.info_outline; + case ChatTabType.dcc: + return Icons.swap_horiz; + } + } + + String _dccSummary(DccSession session) { + final status = session.status.name; + return switch (session.type) { + DccSessionType.chat => + '${session.direction} chat • ${session.host ?? '?'}:${session.port ?? 0} • $status', + DccSessionType.send => + '${session.direction} file • ${session.filename ?? 'file'} • ${session.size ?? 0} B • $status', + DccSessionType.unknown => '${session.direction} DCC • $status', + }; + } +} + +class _InlineMessageSearchBar extends StatelessWidget { + const _InlineMessageSearchBar({ + required this.controller, + required this.filter, + required this.resultCount, + required this.onFilterChanged, + required this.onChanged, + required this.onClose, + }); + + final TextEditingController controller; + final _HistoryKindFilter filter; + final int resultCount; + final ValueChanged<_HistoryKindFilter> onFilterChanged; + final ValueChanged onChanged; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: controller, + onChanged: onChanged, + decoration: const InputDecoration( + labelText: 'Search current tab', + prefixIcon: Icon(Icons.search), + isDense: true, + ), + ), + ), + IconButton( + onPressed: onClose, + icon: const Icon(Icons.close), + tooltip: 'Close message search', + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Wrap( + spacing: 8, + runSpacing: 8, + children: _HistoryKindFilter.values + .map( + (item) => ChoiceChip( + label: Text(item.label), + selected: filter == item, + onSelected: (_) => onFilterChanged(item), + ), + ) + .toList(growable: false), + ), + ), + const SizedBox(width: 12), + Text( + '$resultCount matches', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ], + ), + ), + ); + } +} + +class _PendingReplyBar extends StatelessWidget { + const _PendingReplyBar({ + required this.message, + required this.onCancel, + }); + + final IrcMessage message; + final VoidCallback onCancel; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Row( + children: [ + const Icon(Icons.reply, size: 18), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Replying to ${message.sender}', + style: Theme.of(context).textTheme.labelMedium, + ), + Text( + stripIrcFormatting(message.content), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + IconButton( + onPressed: onCancel, + icon: const Icon(Icons.close), + tooltip: 'Cancel reply', + ), + ], + ), + ); + } +} + +class _TypingIndicator extends StatelessWidget { + const _TypingIndicator({required this.users}); + + final List users; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final label = switch (users.length) { + 0 => '', + 1 => '${users.first} is typing…', + 2 => '${users.first} and ${users.last} are typing…', + _ => '${users.first}, ${users[1]} and ${users.length - 2} more are typing…', + }; + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(12, 6, 12, 0), + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ); + } +} + +class _DccSessionBanner extends StatelessWidget { + const _DccSessionBanner({ + required this.session, + required this.onAccept, + required this.onDecline, + required this.onClose, + }); + + final DccSession session; + final Future Function() onAccept; + final Future Function() onDecline; + final Future Function() onClose; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final subtitle = switch (session.type) { + DccSessionType.chat => + 'Peer: ${session.peerNick} • ${session.host ?? '?'}:${session.port ?? 0} • ${session.status.name}', + DccSessionType.send => + 'File: ${session.filename ?? 'file'} • ${session.size ?? 0} B • ${session.status.name}', + DccSessionType.unknown => 'Peer: ${session.peerNick} • ${session.status.name}', + }; + + return Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(12, 10, 12, 0), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.type == DccSessionType.chat ? 'DCC CHAT session' : 'DCC transfer session', + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 4), + Text(subtitle, style: theme.textTheme.bodySmall), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (session.status == DccSessionStatus.pending) + FilledButton.tonal(onPressed: onAccept, child: const Text('Accept')), + if (session.status == DccSessionStatus.pending) + FilledButton.tonal(onPressed: onDecline, child: const Text('Decline')), + if (session.status != DccSessionStatus.closed) + FilledButton.tonal(onPressed: onClose, child: const Text('Close')), + ], + ), + ], + ), + ); + } +} + +class _ReplyPreview extends StatelessWidget { + const _ReplyPreview({ + required this.referenced, + required this.replyId, + }); + + final IrcMessage? referenced; + final String replyId; + + @override + Widget build(BuildContext context) { + final title = referenced == null ? 'Reply' : 'Reply to ${referenced!.sender}'; + final body = referenced == null + ? 'Referenced message: $replyId' + : stripIrcFormatting(referenced!.content); + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.black.withValues(alpha: 0.06)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(height: 2), + Text( + body, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } +} + +class _HistoryToolsSheet extends StatefulWidget { + const _HistoryToolsSheet({ + required this.controller, + }); + + final ChatSessionController controller; + + @override + State<_HistoryToolsSheet> createState() => _HistoryToolsSheetState(); +} + +class _HistoryToolsSheetState extends State<_HistoryToolsSheet> { + final TextEditingController _searchController = TextEditingController(); + _HistoryKindFilter _filter = _HistoryKindFilter.all; + + ChatSessionController get _controller => widget.controller; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final messages = _controller.messagesForTab( + _controller.activeTabId, + query: _searchController.text, + kinds: _filter.kinds, + ); + final exportText = _controller.exportTabHistory( + _controller.activeTabId, + query: _searchController.text, + kinds: _filter.kinds, + ); + + return SafeArea( + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'History tools', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + TextField( + controller: _searchController, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Search current tab history', + prefixIcon: Icon(Icons.search), + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: _HistoryKindFilter.values + .map( + (filter) => ChoiceChip( + label: Text(filter.label), + selected: _filter == filter, + onSelected: (_) => setState(() => _filter = filter), + ), + ) + .toList(growable: false), + ), + const SizedBox(height: 12), + if (_controller.activeTab.type != ChatTabType.server) ...[ + Text( + 'Server playback', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.tonal( + onPressed: _controller.canRequestServerHistory + ? () => _requestRecentHistory(context, 25) + : null, + child: const Text('Recent 25'), + ), + FilledButton.tonal( + onPressed: _controller.canRequestServerHistory + ? () => _requestRecentHistory(context, 100) + : null, + child: const Text('Recent 100'), + ), + OutlinedButton( + onPressed: _controller.canRequestOlderServerHistory + ? () => _requestOlderHistory(context, 50) + : null, + child: const Text('Older 50'), + ), + OutlinedButton( + onPressed: _controller.canRequestNewerServerHistory + ? () => _requestNewerHistory(context, 50) + : null, + child: const Text('Newer 50'), + ), + OutlinedButton( + onPressed: _controller.canRequestNewerServerHistory + ? () => _requestAroundHistory(context, 50) + : null, + child: const Text('Around latest'), + ), + ], + ), + const SizedBox(height: 12), + ], + Text( + '${messages.length} matching messages', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240), + child: messages.isEmpty + ? const Center(child: Text('No history matches this filter.')) + : ListView.builder( + shrinkWrap: true, + itemCount: messages.length, + itemBuilder: (context, index) { + final message = messages[messages.length - 1 - index]; + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text(message.sender), + subtitle: Text( + message.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + }, + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: exportText.isEmpty + ? null + : () async { + await Clipboard.setData(ClipboardData(text: exportText)); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('History copied to clipboard.')), + ); + }, + icon: const Icon(Icons.copy_all), + label: const Text('Copy export'), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Future _requestRecentHistory(BuildContext context, int limit) async { + final success = await _controller.requestRecentHistory(limit: limit); + if (!mounted || !context.mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + success + ? 'Requested recent history ($limit messages).' + : 'Unable to request server history.', + ), + ), + ); + setState(() {}); + } + + Future _requestOlderHistory(BuildContext context, int limit) async { + final success = await _controller.requestOlderHistory(limit: limit); + if (!mounted || !context.mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + success + ? 'Requested older history ($limit messages).' + : 'Unable to request older history.', + ), + ), + ); + setState(() {}); + } + + Future _requestNewerHistory(BuildContext context, int limit) async { + final success = await _controller.requestNewerHistory(limit: limit); + if (!mounted || !context.mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + success + ? 'Requested newer history ($limit messages).' + : 'Unable to request newer history.', + ), + ), + ); + setState(() {}); + } + + Future _requestAroundHistory(BuildContext context, int limit) async { + final success = await _controller.requestAroundLatestHistory(limit: limit); + if (!mounted || !context.mounted) { + return; } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + success + ? 'Requested surrounding history ($limit messages).' + : 'Unable to request surrounding history.', + ), + ), + ); + setState(() {}); } } +enum _HistoryKindFilter { + all('All', {}), + chat('Chat', {IrcMessageKind.chat}), + system('System', {IrcMessageKind.system}), + raw('Raw', {IrcMessageKind.raw}); + + const _HistoryKindFilter(this.label, this.kinds); + + final String label; + final Set kinds; +} + class _ChannelTopicBar extends StatelessWidget { const _ChannelTopicBar({ required this.topic, @@ -410,9 +1054,23 @@ class _ServiceQuickActions extends StatelessWidget { class _MessageList extends StatelessWidget { const _MessageList({ required this.messages, + required this.showAttachmentPreviews, + required this.resolveReplyTarget, + required this.resolveReactions, + required this.onRedactMessage, + required this.onQuoteMessage, + required this.onReplyWithNick, + required this.onReplyToMessage, }); final List messages; + final bool showAttachmentPreviews; + final IrcMessage? Function(String replyId) resolveReplyTarget; + final Map Function(IrcMessage message) resolveReactions; + final Future Function(IrcMessage message) onRedactMessage; + final ValueChanged onQuoteMessage; + final ValueChanged onReplyWithNick; + final ValueChanged onReplyToMessage; @override Widget build(BuildContext context) { @@ -429,30 +1087,92 @@ class _MessageList extends StatelessWidget { itemBuilder: (context, index) { final message = messages[messages.length - 1 - index]; final align = message.isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start; - final bubbleColor = message.isOwn - ? Theme.of(context).colorScheme.primaryContainer - : Colors.white; + final isRedacted = message.tags['redacted'] == 'true'; + final reactions = resolveReactions(message); + final bubbleColor = switch (message.kind) { + IrcMessageKind.system => const Color(0xFFF6F8F1), + IrcMessageKind.raw => const Color(0xFFF7F7FA), + IrcMessageKind.chat => message.isOwn + ? Theme.of(context).colorScheme.primaryContainer + : Colors.white, + }; return Padding( padding: const EdgeInsets.only(bottom: 10), child: Column( crossAxisAlignment: align, children: [ - Text( - message.sender, - style: Theme.of(context).textTheme.labelMedium, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + message.sender, + style: Theme.of(context).textTheme.labelMedium, + ), + if (message.isPlayback) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'History', + style: Theme.of(context).textTheme.labelSmall, + ), + ), + ], + ], ), const SizedBox(height: 3), - DecoratedBox( - decoration: BoxDecoration( - color: bubbleColor, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: Colors.black.withValues(alpha: 0.06)), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: _IrcFormattedText( - message.content, - baseStyle: Theme.of(context).textTheme.bodyMedium, + 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: Colors.black.withValues(alpha: 0.06)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + 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(), + ), + _IrcFormattedText( + message.content, + baseStyle: isRedacted + ? Theme.of(context).textTheme.bodyMedium?.copyWith( + fontStyle: FontStyle.italic, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ) + : Theme.of(context).textTheme.bodyMedium, + ), + if (showAttachmentPreviews && !isRedacted) + _MessageAttachments(content: message.content), + 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), + ), + ], + ], + ), ), ), ), @@ -462,6 +1182,132 @@ class _MessageList extends StatelessWidget { }, ); } + + Future _showMessageActions(BuildContext context, IrcMessage message) async { + final urls = extractUrls(stripIrcFormatting(message.content)); + final canRedact = (message.tags['msgid'] ?? '').trim().isNotEmpty; + await showModalBottomSheet( + context: context, + builder: (context) { + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + ), + child: ListView( + shrinkWrap: true, + children: [ + ListTile( + leading: const Icon(Icons.copy), + title: const Text('Copy clean text'), + onTap: () async { + await Clipboard.setData( + ClipboardData(text: stripIrcFormatting(message.content)), + ); + if (!context.mounted) { + return; + } + Navigator.of(context).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.code), + title: const Text('Copy raw text'), + onTap: () async { + await Clipboard.setData(ClipboardData(text: message.content)); + if (!context.mounted) { + return; + } + Navigator.of(context).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.badge_outlined), + title: const Text('Copy sender'), + onTap: () async { + await Clipboard.setData(ClipboardData(text: message.sender)); + if (!context.mounted) { + return; + } + Navigator.of(context).pop(); + }, + ), + if (urls.isNotEmpty) + ListTile( + leading: const Icon(Icons.link), + title: const Text('Copy first link'), + onTap: () async { + await Clipboard.setData(ClipboardData(text: urls.first)); + if (!context.mounted) { + return; + } + Navigator.of(context).pop(); + }, + ), + if (urls.isNotEmpty) + ListTile( + leading: const Icon(Icons.open_in_new), + title: const Text('Open first link'), + onTap: () async { + await _openExternalUrl(urls.first); + if (!context.mounted) { + return; + } + Navigator.of(context).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.subdirectory_arrow_right), + title: const Text('Reply to message'), + onTap: () { + onReplyToMessage(message); + if (!context.mounted) { + return; + } + Navigator.of(context).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.format_quote), + title: const Text('Quote in composer'), + onTap: () { + onQuoteMessage(message); + if (!context.mounted) { + return; + } + Navigator.of(context).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.reply), + title: const Text('Reply with nick'), + onTap: () { + onReplyWithNick(message); + if (!context.mounted) { + return; + } + Navigator.of(context).pop(); + }, + ), + if (canRedact) + ListTile( + leading: const Icon(Icons.delete_outline), + title: const Text('Delete message'), + onTap: () async { + await onRedactMessage(message); + if (!context.mounted) { + return; + } + Navigator.of(context).pop(); + }, + ), + ], + ), + ), + ); + }, + ); + } } class _IrcFormattedText extends StatelessWidget { @@ -496,6 +1342,9 @@ class _IrcFormattedText extends StatelessWidget { (segment) => TextSpan( text: segment.text, style: _resolveTextStyle(baseStyle, segment), + recognizer: segment.isLink + ? (TapGestureRecognizer()..onTap = () => _openExternalUrl(segment.url!)) + : null, ), ) .toList(growable: false), @@ -566,6 +1415,153 @@ class _IrcFormattedText extends StatelessWidget { } } +class _MessageAttachments extends StatelessWidget { + const _MessageAttachments({ + required this.content, + }); + + final String content; + + @override + Widget build(BuildContext context) { + final parts = parseMessageContent(stripIrcFormatting(content)); + final previews = parts + .where((part) => part.type != ParsedMessagePartType.text) + .toList(growable: false); + if (previews.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(top: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: previews + .map((part) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _AttachmentCard(part: part), + )) + .toList(growable: false), + ), + ); + } +} + +class _AttachmentCard extends StatelessWidget { + const _AttachmentCard({ + required this.part, + }); + + final ParsedMessagePart part; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final url = part.url; + final isImage = part.type == ParsedMessagePartType.image && url != null; + final title = switch (part.type) { + ParsedMessagePartType.image => 'Image', + ParsedMessagePartType.media => 'Encrypted media', + ParsedMessagePartType.url => 'Link', + ParsedMessagePartType.text => 'Text', + }; + final icon = switch (part.type) { + ParsedMessagePartType.image => Icons.image_outlined, + ParsedMessagePartType.media => Icons.lock_outline, + ParsedMessagePartType.url when url != null && isVideoUrl(url) => Icons.movie_outlined, + ParsedMessagePartType.url when url != null && isAudioUrl(url) => Icons.audiotrack_outlined, + ParsedMessagePartType.url when url != null && isDownloadableFileUrl(url) => Icons.download_outlined, + ParsedMessagePartType.url => Icons.link, + ParsedMessagePartType.text => Icons.notes, + }; + + return Material( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: url == null + ? null + : () => isImage ? _showImagePreview(context, url) : _openExternalUrl(url), + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isImage) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.network( + url, + height: 160, + width: double.infinity, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + height: 120, + alignment: Alignment.center, + color: theme.colorScheme.surfaceContainer, + child: const Icon(Icons.broken_image_outlined), + ), + loadingBuilder: (context, child, progress) { + if (progress == null) { + return child; + } + return Container( + height: 120, + alignment: Alignment.center, + color: theme.colorScheme.surfaceContainer, + child: const CircularProgressIndicator(strokeWidth: 2), + ); + }, + ), + ), + const SizedBox(height: 10), + ], + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: theme.colorScheme.primary), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.labelLarge, + ), + Text( + part.mediaId ?? part.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + if (url != null) + IconButton( + onPressed: () => _copyToClipboard(context, url), + icon: const Icon(Icons.copy_outlined), + tooltip: 'Copy link', + ), + ], + ), + ], + ), + ), + ), + ); + } +} + class _ConnectionBanner extends StatelessWidget { const _ConnectionBanner({ required this.controller, @@ -687,3 +1683,63 @@ class _ConnectionBanner extends StatelessWidget { } } } + +Future _openExternalUrl(String rawUrl) async { + final normalized = rawUrl.contains('://') ? rawUrl : 'https://$rawUrl'; + final uri = Uri.tryParse(normalized); + if (uri == null) { + return; + } + + await launchUrl(uri, mode: LaunchMode.platformDefault); +} + +Future _copyToClipboard(BuildContext context, String text) async { + await Clipboard.setData(ClipboardData(text: text)); + if (!context.mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Copied to clipboard.')), + ); +} + +Future _showImagePreview(BuildContext context, String url) async { + await showDialog( + context: context, + builder: (context) { + return Dialog.fullscreen( + backgroundColor: Colors.black, + child: Stack( + children: [ + Center( + child: InteractiveViewer( + minScale: 1, + maxScale: 4, + child: Image.network( + url, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => const Icon( + Icons.broken_image_outlined, + color: Colors.white, + size: 48, + ), + ), + ), + ), + Positioned( + top: 16, + right: 16, + child: IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white), + tooltip: 'Close image preview', + ), + ), + ], + ), + ); + }, + ); +} diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 19fba7b..0806047 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -33,17 +33,74 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(16), children: [ Card( - child: SwitchListTile( - title: const Text('Show raw IRC events'), - subtitle: const Text( - 'Keep low-level IRC send/receive lines visible in the server tab.', - ), - value: _settings.showRawEvents, - onChanged: (value) async { - final next = _settings.copyWith(showRawEvents: value); - setState(() => _settings = next); - await _repository.saveSettings(next); - }, + child: Column( + children: [ + SwitchListTile( + title: const Text('Show raw IRC events'), + subtitle: const Text( + 'Keep low-level IRC send/receive lines visible in the server tab.', + ), + value: _settings.showRawEvents, + onChanged: (value) async { + final next = _settings.copyWith(showRawEvents: value); + setState(() => _settings = next); + await _repository.saveSettings(next); + }, + ), + const Divider(height: 1), + SwitchListTile( + title: const Text('Show header search button'), + subtitle: const Text( + 'Keep the inline message search toggle visible in the chat header.', + ), + value: _settings.showHeaderSearchButton, + onChanged: (value) async { + final next = _settings.copyWith(showHeaderSearchButton: value); + setState(() => _settings = next); + await _repository.saveSettings(next); + }, + ), + const Divider(height: 1), + SwitchListTile( + title: const Text('Show attachment previews'), + subtitle: const Text( + 'Render link, file, image, and media cards below matching messages.', + ), + value: _settings.showAttachmentPreviews, + onChanged: (value) async { + final next = _settings.copyWith(showAttachmentPreviews: value); + setState(() => _settings = next); + await _repository.saveSettings(next); + }, + ), + const Divider(height: 1), + ListTile( + title: const Text('Notice routing'), + subtitle: const Text( + 'Choose where incoming NOTICE messages should appear.', + ), + trailing: DropdownButton( + value: _settings.noticeRouting, + onChanged: (value) async { + if (value == null) { + return; + } + + final next = _settings.copyWith(noticeRouting: value); + setState(() => _settings = next); + await _repository.saveSettings(next); + }, + items: NoticeRoutingMode.values + .map( + (mode) => DropdownMenuItem( + value: mode, + child: Text(_labelForNoticeRouting(mode)), + ), + ) + .toList(growable: false), + ), + ), + ], ), ), ], @@ -63,4 +120,13 @@ class _SettingsScreenState extends State { _isLoading = false; }); } + + String _labelForNoticeRouting(NoticeRoutingMode mode) { + return switch (mode) { + NoticeRoutingMode.server => 'Server tab', + NoticeRoutingMode.active => 'Active tab', + NoticeRoutingMode.notice => 'Notice tab', + NoticeRoutingMode.private => 'Private query', + }; + } } diff --git a/lib/irc/models/irc_message_frame.dart b/lib/irc/models/irc_message_frame.dart index 14eb5cc..da5895e 100644 --- a/lib/irc/models/irc_message_frame.dart +++ b/lib/irc/models/irc_message_frame.dart @@ -3,11 +3,13 @@ class IrcMessageFrame { required this.raw, required this.command, required this.params, + this.tags = const {}, this.prefix, this.trailing, }); final String raw; + final Map tags; final String? prefix; final String command; final List params; diff --git a/lib/irc/parser/ctcp.dart b/lib/irc/parser/ctcp.dart new file mode 100644 index 0000000..6e9b2a4 --- /dev/null +++ b/lib/irc/parser/ctcp.dart @@ -0,0 +1,45 @@ +class CtcpMessage { + const CtcpMessage({ + required this.isCtcp, + this.command, + this.args, + }); + + final bool isCtcp; + final String? command; + final String? args; +} + +const _ctcpDelimiter = '\u0001'; + +CtcpMessage parseCtcp(String message) { + if (!message.startsWith(_ctcpDelimiter) || !message.endsWith(_ctcpDelimiter)) { + return const CtcpMessage(isCtcp: false); + } + + final body = message.substring(1, message.length - 1).trim(); + if (body.isEmpty) { + return const CtcpMessage(isCtcp: false); + } + + final separator = body.indexOf(' '); + if (separator == -1) { + return CtcpMessage(isCtcp: true, command: body.toUpperCase()); + } + + return CtcpMessage( + isCtcp: true, + command: body.substring(0, separator).toUpperCase(), + args: body.substring(separator + 1), + ); +} + +String encodeCtcp(String command, [String? args]) { + final normalizedCommand = command.trim().toUpperCase(); + final normalizedArgs = (args ?? '').trim(); + if (normalizedArgs.isEmpty) { + return '$_ctcpDelimiter$normalizedCommand$_ctcpDelimiter'; + } + + return '$_ctcpDelimiter$normalizedCommand $normalizedArgs$_ctcpDelimiter'; +} diff --git a/lib/irc/parser/dcc_parser.dart b/lib/irc/parser/dcc_parser.dart new file mode 100644 index 0000000..ef26458 --- /dev/null +++ b/lib/irc/parser/dcc_parser.dart @@ -0,0 +1,84 @@ +class DccOffer { + const DccOffer({ + required this.command, + required this.target, + this.filename, + this.host, + this.port, + this.size, + this.token, + }); + + final String command; + final String target; + final String? filename; + final String? host; + final int? port; + final int? size; + final String? token; +} + +DccOffer? parseDccOffer(String args) { + final trimmed = args.trim(); + if (trimmed.isEmpty) { + return null; + } + + final parts = trimmed.split(RegExp(r'\s+')); + if (parts.length < 2 || parts.first.toUpperCase() != 'DCC') { + return null; + } + + final command = parts[1].toUpperCase(); + switch (command) { + case 'CHAT': + if (parts.length >= 5) { + return DccOffer( + command: command, + target: parts[2], + host: _normalizeDccHost(parts[3]), + port: int.tryParse(parts[4]), + ); + } + return null; + case 'SEND': + if (parts.length >= 5) { + final filenameStart = trimmed.indexOf('SEND') + 5; + final afterCommand = trimmed.substring(filenameStart).trim(); + final match = RegExp(r'^"?(.*?)"?\s+(\S+)\s+(\d+)\s+(\d+)(?:\s+(\S+))?$') + .firstMatch(afterCommand); + if (match != null) { + return DccOffer( + command: command, + target: 'file', + filename: match.group(1), + host: _normalizeDccHost(match.group(2)!), + port: int.tryParse(match.group(3)!), + size: int.tryParse(match.group(4)!), + token: match.group(5), + ); + } + } + return null; + default: + return DccOffer(command: command, target: parts.length > 2 ? parts[2] : ''); + } +} + +String _normalizeDccHost(String host) { + if (!RegExp(r'^\d+$').hasMatch(host)) { + return host; + } + + final value = int.tryParse(host); + if (value == null) { + return host; + } + + return [ + (value >> 24) & 255, + (value >> 16) & 255, + (value >> 8) & 255, + value & 255, + ].join('.'); +} diff --git a/lib/irc/parser/irc_message_parser.dart b/lib/irc/parser/irc_message_parser.dart index 94166f2..9ef51ca 100644 --- a/lib/irc/parser/irc_message_parser.dart +++ b/lib/irc/parser/irc_message_parser.dart @@ -2,9 +2,18 @@ import 'package:androidircx/irc/models/irc_message_frame.dart'; IrcMessageFrame parseIrcMessage(String raw) { var rest = raw.trim(); + Map tags = const {}; String? prefix; String? trailing; + if (rest.startsWith('@')) { + final tagsEnd = rest.indexOf(' '); + if (tagsEnd != -1) { + tags = _parseMessageTags(rest.substring(1, tagsEnd)); + rest = rest.substring(tagsEnd + 1); + } + } + if (rest.startsWith(':')) { final prefixEnd = rest.indexOf(' '); if (prefixEnd != -1) { @@ -30,9 +39,64 @@ IrcMessageFrame parseIrcMessage(String raw) { return IrcMessageFrame( raw: raw, + tags: tags, prefix: prefix, command: parts.first.toUpperCase(), params: parts.skip(1).toList(growable: false), trailing: trailing, ); } + +Map _parseMessageTags(String source) { + if (source.isEmpty) { + return const {}; + } + + final tags = {}; + for (final entry in source.split(';')) { + if (entry.isEmpty) { + continue; + } + + final separator = entry.indexOf('='); + if (separator == -1) { + tags[entry] = null; + continue; + } + + final key = entry.substring(0, separator); + final value = entry.substring(separator + 1); + tags[key] = _unescapeTagValue(value); + } + + return tags; +} + +String _unescapeTagValue(String value) { + final buffer = StringBuffer(); + for (var i = 0; i < value.length; i += 1) { + final current = value[i]; + if (current != r'\' || i + 1 >= value.length) { + buffer.write(current); + continue; + } + + i += 1; + switch (value[i]) { + case ':': + buffer.write(';'); + case 's': + buffer.write(' '); + case r'\': + buffer.write(r'\'); + case 'r': + buffer.write('\r'); + case 'n': + buffer.write('\n'); + default: + buffer.write(value[i]); + } + } + + return buffer.toString(); +} diff --git a/lib/irc/services/irc_service.dart b/lib/irc/services/irc_service.dart index 5ba0812..6f0c533 100644 --- a/lib/irc/services/irc_service.dart +++ b/lib/irc/services/irc_service.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:androidircx/core/models/connection_state.dart'; import 'package:androidircx/core/models/network_config.dart'; import 'package:androidircx/irc/models/irc_message_frame.dart'; +import 'package:androidircx/irc/parser/ctcp.dart'; import 'package:androidircx/irc/parser/irc_message_parser.dart'; import 'package:androidircx/irc/sasl/scram_sha256_session.dart'; import 'package:androidircx/irc/services/irc_transport.dart'; @@ -11,6 +12,38 @@ import 'package:androidircx/irc/services/irc_transport.dart'; typedef IrcTransportConnector = Future Function(NetworkConfig network); class IrcService { + static const Set _preferredCapabilities = { + 'account-notify', + 'account-tag', + 'away-notify', + 'batch', + 'bot', + 'cap-notify', + 'chghost', + 'draft/account-registration', + 'draft/channel-rename', + 'draft/multiline', + 'draft/read-marker', + 'draft/typing', + 'draft/message-redaction', + 'echo-message', + 'event-playback', + 'extended-join', + 'extended-monitor', + 'invite-notify', + 'labeled-response', + 'message-tags', + 'monitor', + 'multi-prefix', + 'server-time', + 'setname', + 'standard-replies', + 'sts', + 'typing', + 'userhost-in-names', + 'utf8only', + }; + IrcService({ IrcTransportConnector? transportConnector, String Function()? scramNonceGenerator, @@ -29,6 +62,9 @@ class IrcService { StreamController.broadcast(); final StreamController _stateController = StreamController.broadcast(); + final StreamController<({String label, String command, IrcMessageFrame frame})> + _labeledResponsesController = + StreamController<({String label, String command, IrcMessageFrame frame})>.broadcast(); IrcTransport? _transport; StreamSubscription? _linesSubscription; @@ -46,6 +82,8 @@ class IrcService { int _altNickAttempt = 0; ScramSha256Session? _scramSession; bool _scramAwaitingServerFinal = false; + int _labelCounter = 0; + final Map _pendingLabels = {}; ConnectionSnapshot get state => _state; String? get currentNick => _currentNick; @@ -54,6 +92,16 @@ class IrcService { Stream get rawEvents => _rawEventsController.stream; Stream get frames => _framesController.stream; Stream get stateStream => _stateController.stream; + Stream<({String label, String command, IrcMessageFrame frame})> get labeledResponses => + _labeledResponsesController.stream; + bool get supportsChatHistory => + _capEnabled.contains('chathistory') || _capEnabled.contains('draft/chathistory'); + bool get supportsReadMarker => _capEnabled.contains('draft/read-marker'); + bool get supportsMessageRedaction => _capEnabled.contains('draft/message-redaction'); + bool get supportsMultiline => _capEnabled.contains('draft/multiline'); + bool get supportsTyping => + _capEnabled.contains('typing') || _capEnabled.contains('draft/typing'); + bool get supportsSetName => _capEnabled.contains('setname'); Future connect(NetworkConfig network) async { if (_state.phase == ConnectionPhase.connecting || @@ -148,6 +196,20 @@ class IrcService { await transport.sendLine(line); } + Future sendRawLabeled(String line) async { + if (_capEnabled.contains('labeled-response') || + _capEnabled.contains('draft/labeled-response')) { + _labelCounter += 1; + final label = 'androidircx-${DateTime.now().millisecondsSinceEpoch}-$_labelCounter'; + _pendingLabels[label] = line; + await sendRaw('@label=$label $line'); + return label; + } + + await sendRaw(line); + return ''; + } + Future joinChannel(String channel) async { await sendRaw('JOIN $channel'); } @@ -155,8 +217,50 @@ class IrcService { Future sendPrivmsg({ required String target, required String text, + String? replyTo, + }) async { + if (text.contains('\n')) { + await _sendMultilinePrivmsg( + target: target, + text: text, + replyTo: replyTo, + ); + return; + } + + final normalizedReply = (replyTo ?? '').trim(); + if (normalizedReply.isEmpty) { + await sendRaw('PRIVMSG $target :$text'); + return; + } + + await sendRaw('@+draft/reply=$normalizedReply PRIVMSG $target :$text'); + } + + Future _sendMultilinePrivmsg({ + required String target, + required String text, + String? replyTo, }) async { - await sendRaw('PRIVMSG $target :$text'); + final lines = text.split('\n'); + if (!supportsMultiline || lines.length <= 1) { + for (final line in lines) { + await sendPrivmsg(target: target, text: line, replyTo: replyTo); + } + return; + } + + final concatTag = 'androidircx-multiline-${DateTime.now().millisecondsSinceEpoch}'; + final normalizedReply = (replyTo ?? '').trim(); + for (var index = 0; index < lines.length; index += 1) { + final line = lines[index]; + final isLast = index == lines.length - 1; + final tags = [ + 'draft/multiline-concat=${isLast ? '' : concatTag}', + if (normalizedReply.isNotEmpty && isLast) '+draft/reply=$normalizedReply', + ]; + await sendRaw('@${tags.join(';')} PRIVMSG $target :$line'); + } } Future sendNotice({ @@ -170,6 +274,15 @@ class IrcService { await sendRaw('WHOIS $nick $nick'); } + Future sendSetName(String realName) async { + if (!supportsSetName) { + return false; + } + + await sendRaw('SETNAME :$realName'); + return true; + } + Future sendWho(String mask) async { final value = mask.trim(); await sendRaw(value.isEmpty ? 'WHO' : 'WHO $value'); @@ -188,6 +301,47 @@ class IrcService { await sendRaw(value.isEmpty ? 'LIST' : 'LIST $value'); } + Future sendChatHistory({ + required String target, + String subcommand = 'LATEST', + String reference = '*', + int limit = 50, + }) async { + if (!supportsChatHistory) { + return false; + } + + await sendRawLabeled( + 'CHATHISTORY ${subcommand.toUpperCase()} $target $reference $limit', + ); + return true; + } + + Future sendReadMarker({ + required String target, + int? timestampMillis, + }) async { + if (!supportsReadMarker) { + return false; + } + + final effectiveTimestamp = timestampMillis ?? DateTime.now().millisecondsSinceEpoch; + await sendRaw('MARKREAD $target timestamp=$effectiveTimestamp'); + return true; + } + + Future redactMessage({ + required String target, + required String msgid, + }) async { + if (!supportsMessageRedaction) { + return false; + } + + await sendRaw('REDACT $target $msgid'); + return true; + } + Future sendMotd() async { await sendRaw('MOTD'); } @@ -207,6 +361,40 @@ class IrcService { await sendRaw(value.isEmpty ? 'LINKS' : 'LINKS $value'); } + Future sendIson(List nicknames) async { + final filtered = nicknames.map((nick) => nick.trim()).where((nick) => nick.isNotEmpty).toList(growable: false); + if (filtered.isEmpty) { + return; + } + await sendRaw('ISON ${filtered.join(' ')}'); + } + + Future sendUserhost(List nicknames) async { + final filtered = nicknames.map((nick) => nick.trim()).where((nick) => nick.isNotEmpty).toList(growable: false); + if (filtered.isEmpty) { + return; + } + await sendRaw('USERHOST ${filtered.join(' ')}'); + } + + Future sendMonitor({ + required String subcommand, + List nicknames = const [], + }) async { + final normalizedSubcommand = subcommand.trim().toUpperCase(); + if (normalizedSubcommand.isEmpty) { + return; + } + + final filtered = nicknames.map((nick) => nick.trim()).where((nick) => nick.isNotEmpty).toList(growable: false); + if (filtered.isEmpty) { + await sendRaw('MONITOR $normalizedSubcommand'); + return; + } + + await sendRaw('MONITOR $normalizedSubcommand ${filtered.join(',')}'); + } + Future sendInvite({ required String nick, required String channel, @@ -281,12 +469,50 @@ class IrcService { required String target, required String text, }) async { - await sendRaw('PRIVMSG $target :\u0001ACTION $text\u0001'); + await sendCtcpRequest(target: target, command: 'ACTION', args: text); + } + + Future sendCtcpRequest({ + required String target, + required String command, + String? args, + }) async { + await sendRaw('PRIVMSG $target :${encodeCtcp(command, args)}'); + } + + Future sendCtcpReply({ + required String target, + required String command, + String? args, + }) async { + await sendRaw('NOTICE $target :${encodeCtcp(command, args)}'); + } + + Future sendTyping({ + required String target, + required String status, + }) async { + if (!supportsTyping) { + return false; + } + + final tagName = _capEnabled.contains('typing') ? '+typing' : '+draft/typing'; + await sendRaw('@$tagName=$status TAGMSG $target'); + return true; + } + + Future sendReaction({ + required String target, + required String msgid, + required String emoji, + }) async { + await sendRaw('@+draft/react=$msgid\\:$emoji TAGMSG $target'); } void _handleIncomingLine(String line) { _rawEventsController.add('<< $line'); final frame = parseIrcMessage(line); + _handleLabeledResponse(frame); _framesController.add(frame); if (frame.command == 'PING') { @@ -377,8 +603,12 @@ class IrcService { _capAvailable.addAll(_parseCapabilityNames(capabilities)); final isLast = !rest.contains('*'); if (isLast) { - if (_capAvailable.contains('sasl') && _shouldUseSasl(_network)) { - unawaited(sendRaw('CAP REQ :sasl')); + final requested = { + if (_capAvailable.contains('sasl') && _shouldUseSasl(_network)) 'sasl', + ..._preferredCapabilities.where(_capAvailable.contains), + }.toList(growable: false); + if (requested.isNotEmpty) { + unawaited(sendRaw('CAP REQ :${requested.join(' ')}')); } else { unawaited(_endCapNegotiation()); } @@ -577,6 +807,7 @@ class IrcService { void _handleTransportDone() { _transport = null; + _pendingLabels.clear(); _updateState( ConnectionSnapshot( networkId: _state.networkId, @@ -587,6 +818,7 @@ class IrcService { } void _handleTransportError(Object error, StackTrace stackTrace) { + _pendingLabels.clear(); _updateState( ConnectionSnapshot( networkId: _state.networkId, @@ -604,6 +836,7 @@ class IrcService { void dispose() { _linesSubscription?.cancel(); _transport?.close(); + _labeledResponsesController.close(); _rawEventsController.close(); _framesController.close(); _stateController.close(); @@ -679,4 +912,20 @@ class IrcService { } return names; } + + void _handleLabeledResponse(IrcMessageFrame frame) { + final label = frame.tags['label']; + if (label == null || label.isEmpty) { + return; + } + + final command = _pendingLabels.remove(label); + if (command == null) { + _rawEventsController.add('** Labeled response for unknown label: $label'); + return; + } + + _rawEventsController.add('** Labeled response matched: $label ($command)'); + _labeledResponsesController.add((label: label, command: command, frame: frame)); + } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..f6f23bf 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..f16b4c3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 724bb2a..997e35d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,7 +6,9 @@ import FlutterMacOS import Foundation import shared_preferences_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index e7f2b26..6d6dc44 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -325,6 +325,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" vector_math: dependency: transitive description: @@ -375,4 +439,4 @@ packages: version: "1.1.0" sdks: dart: ">=3.11.1 <4.0.0" - flutter: ">=3.35.0" + flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml index 580529d..d8def55 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: crypto: ^3.0.6 shared_preferences: ^2.5.3 web_socket_channel: ^3.0.3 + url_launcher: ^6.3.2 dev_dependencies: flutter_test: diff --git a/test/chat_session_controller_test.dart b/test/chat_session_controller_test.dart index b48fa6a..55892e7 100644 --- a/test/chat_session_controller_test.dart +++ b/test/chat_session_controller_test.dart @@ -1,6 +1,14 @@ import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:androidircx/core/models/app_settings.dart'; +import 'package:androidircx/core/models/chat_tab.dart'; +import 'package:androidircx/core/models/irc_message.dart'; import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/dcc/services/dcc_service.dart'; +import 'package:androidircx/dcc/services/dcc_socket_backend.dart'; +import 'package:androidircx/core/storage/settings_repository.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'; @@ -30,6 +38,69 @@ class _FakeTransport implements IrcTransport { } } +class _FakeDccConnection implements DccSocketConnection { + final StreamController> _controller = StreamController>.broadcast(); + final List> sentPackets = >[]; + + @override + Stream> get bytes => _controller.stream; + + @override + Future close() async { + await _controller.close(); + } + + @override + Future sendBytes(List data) async { + sentPackets.add(Uint8List.fromList(data)); + } +} + +class _FakeDccServer implements DccSocketServer { + _FakeDccServer(this.connection); + + final DccSocketConnection connection; + + @override + String get address => '127.0.0.1'; + + @override + Stream get connections => Stream.value(connection); + + @override + int get port => 5001; + + @override + Future close() async {} +} + +class _FakeDccBackend implements DccSocketBackend { + final _FakeDccConnection connection = _FakeDccConnection(); + + @override + Future bindEphemeral() async => _FakeDccServer(connection); + + @override + Future connect({ + required String host, + required int port, + }) async => connection; +} + +class _FakeSettingsRepository implements SettingsRepository { + _FakeSettingsRepository(this._settings); + + AppSettings _settings; + + @override + Future loadSettings() async => _settings; + + @override + Future saveSettings(AppSettings settings) async { + _settings = settings; + } +} + void main() { setUp(() { SharedPreferences.setMockInitialValues({}); @@ -230,6 +301,1587 @@ void main() { controller.dispose(); }); + test('sends reply-tagged messages through privmsg when reply target is provided', () 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: '#room')); + await controller.handleComposerSubmit('reply body', replyTo: 'msg-123'); + + expect( + transport.sentLines, + contains('@+draft/reply=msg-123 PRIVMSG #room :reply body'), + ); + + controller.dispose(); + }); + + test('routes incoming notices to a dedicated notice tab when configured', () 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, + settingsRepository: _FakeSettingsRepository( + const AppSettings(noticeRouting: NoticeRoutingMode.notice), + ), + ); + + await controller.start(); + transport.emit(':services.example NOTICE AndroidIRCX :Maintenance tonight'); + await Future.delayed(Duration.zero); + + final noticeTab = controller.tabs.firstWhere((tab) => tab.type.name == 'notice'); + controller.selectTab(noticeTab.id); + expect( + controller.activeMessages.any((message) => message.content == 'Maintenance tonight'), + isTrue, + ); + + controller.dispose(); + }); + + test('routes incoming notices to the active tab when configured', () 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, + settingsRepository: _FakeSettingsRepository( + const AppSettings(noticeRouting: NoticeRoutingMode.active), + ), + ); + + await controller.start(); + await controller.joinChannel(const JoinChannelRequest(channel: '#room')); + transport.emit(':services.example NOTICE AndroidIRCX :Maintenance tonight'); + await Future.delayed(Duration.zero); + + expect(controller.activeTab.name, '#room'); + expect( + controller.activeMessages.any((message) => message.content == 'Maintenance tonight'), + isTrue, + ); + + controller.dispose(); + }); + + test('routes incoming notices to a private query when configured', () 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, + settingsRepository: _FakeSettingsRepository( + const AppSettings(noticeRouting: NoticeRoutingMode.private), + ), + ); + + await controller.start(); + transport.emit(':NickServ!service@example NOTICE AndroidIRCX :Identify now'); + await Future.delayed(Duration.zero); + + final queryTab = controller.tabs.firstWhere((tab) => tab.type.name == 'query'); + controller.selectTab(queryTab.id); + expect(queryTab.name, 'NickServ'); + expect( + controller.activeMessages.any((message) => message.content == 'Identify now'), + isTrue, + ); + + controller.dispose(); + }); + + test('uses server-time tag as message timestamp and stores tags', () 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( + '@time=2026-03-17T10:11:12.000Z;+draft/source=test :alice!user@example PRIVMSG #room :hello', + ); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + controller.selectTab( + controller.tabs.firstWhere((tab) => tab.type.name == 'channel' && tab.name == '#room').id, + ); + final message = controller.activeMessages.last; + expect(message.timestamp.toUtc(), DateTime.parse('2026-03-17T10:11:12.000Z')); + expect(message.tags['+draft/source'], 'test'); + + controller.dispose(); + }); + + test('routes self echo direct messages into the target query 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(); + transport.emit(':server 001 AndroidIRCX :Welcome'); + transport.emit(':server CAP * ACK :echo-message message-tags server-time'); + transport.emit(':AndroidIRCX!me@example PRIVMSG alice :hello from echo'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + controller.selectTab( + controller.tabs.firstWhere((tab) => tab.type.name == 'query' && tab.name == 'alice').id, + ); + expect( + controller.activeMessages.any( + (message) => + message.isOwn && + message.sender == 'AndroidIRCX' && + message.content == 'hello from echo', + ), + isTrue, + ); + + controller.dispose(); + }); + + test('filters and exports current tab history', () 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(':alice!user@example PRIVMSG #room :hello flutter'); + transport.emit(':bob!user@example NOTICE #room :system notice'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + controller.selectTab( + controller.tabs.firstWhere((tab) => tab.type.name == 'channel' && tab.name == '#room').id, + ); + final chatOnly = controller.messagesForTab( + controller.activeTabId, + query: 'flutter', + kinds: const {IrcMessageKind.chat}, + ); + final export = controller.exportTabHistory(controller.activeTabId, query: 'flutter'); + + expect(chatOnly, hasLength(1)); + expect(chatOnly.single.content, 'hello flutter'); + expect(export, contains('hello flutter')); + expect(export, isNot(contains('system notice'))); + + controller.dispose(); + }); + + test('renders draft intent action as an action message', () 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('@draft/intent=ACTION :alice!user@example PRIVMSG #room :waves'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + controller.selectTab( + controller.tabs.firstWhere((tab) => tab.type.name == 'channel' && tab.name == '#room').id, + ); + expect( + controller.activeMessages.any((message) => message.content == '• waves'), + isTrue, + ); + + controller.dispose(); + }); + + test('deduplicates messages with the same msgid', () 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('@msgid=abc123 :alice!user@example PRIVMSG #room :hello once'); + transport.emit('@msgid=abc123 :alice!user@example PRIVMSG #room :hello once'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + controller.selectTab( + controller.tabs.firstWhere((tab) => tab.type.name == 'channel' && tab.name == '#room').id, + ); + expect(controller.activeMessages.where((message) => message.content == 'hello once'), hasLength(1)); + + controller.dispose(); + }); + + test('tracks batch start and end with message count', () 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 BATCH +batch-1 chathistory #room'); + transport.emit('@batch=batch-1;msgid=1 :alice!user@example PRIVMSG #room :first history'); + transport.emit(':server BATCH -batch-1'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + controller.selectTab(controller.tabs.firstWhere((tab) => tab.type.name == 'server').id); + expect( + controller.activeMessages.any((message) => message.content.contains('BATCH start: chathistory #room')), + isTrue, + ); + expect( + controller.activeMessages.any((message) => message.content.contains('Playback batch completed: 1 messages')), + isTrue, + ); + + controller.dispose(); + }); + + test('shows playback batch summary and labeled response match in server 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(); + transport.emit(':server CAP * ACK :labeled-response'); + await Future.delayed(Duration.zero); + final label = await service.sendRawLabeled('WHOIS alice alice'); + transport.emit(':server BATCH +batch-2 znc.in/playback #room'); + transport.emit('@batch=batch-2;msgid=2 :alice!user@example PRIVMSG #room :older line'); + transport.emit(':server BATCH -batch-2'); + transport.emit('@label=$label :server 318 AndroidIRCX alice :End of /WHOIS list.'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + controller.selectTab(controller.tabs.firstWhere((tab) => tab.type.name == 'server').id); + expect( + controller.activeMessages.any( + (message) => message.content.contains('Playback batch completed: 1 messages'), + ), + isTrue, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('Labeled response matched: WHOIS alice alice'), + ), + isTrue, + ); + + controller.dispose(); + }); + + test('requests chathistory for the active channel when supported', () 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: '#room')); + transport.emit(':server CAP * ACK :draft/chathistory labeled-response'); + await Future.delayed(Duration.zero); + + await controller.handleComposerSubmit('/chathistory 25'); + + expect( + transport.sentLines.any((line) => line.contains('CHATHISTORY LATEST #room * 25')), + isTrue, + ); + controller.selectTab(controller.tabs.firstWhere((tab) => tab.type.name == 'server').id); + expect( + controller.activeMessages.any( + (message) => message.content.contains( + 'Requested CHATHISTORY LATEST for #room (*, 25 messages).', + ), + ), + isTrue, + ); + + controller.dispose(); + }); + + test('uses latest known msgid for /chathistory before when omitted', () 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: '#room')); + transport.emit('@msgid=abc123 :alice!user@example PRIVMSG #room :hello'); + transport.emit(':server CAP * ACK :chathistory labeled-response'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + await controller.handleComposerSubmit('/chathistory before 20'); + + expect( + transport.sentLines.any((line) => line.contains('CHATHISTORY BEFORE #room abc123 20')), + isTrue, + ); + + controller.dispose(); + }); + + test('marks playback messages from chathistory batch', () 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 BATCH +hist chathistory #room'); + transport.emit('@batch=hist;msgid=1 :alice!user@example PRIVMSG #room :older line'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + controller.selectTab( + controller.tabs.firstWhere((tab) => tab.type.name == 'channel' && tab.name == '#room').id, + ); + expect(controller.activeMessages.single.isPlayback, isTrue); + + controller.dispose(); + }); + + test('requests recent history for the active tab through controller API', () 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: '#room')); + transport.emit(':server CAP * ACK :chathistory labeled-response'); + await Future.delayed(Duration.zero); + + expect(await controller.requestRecentHistory(limit: 25), isTrue); + expect( + transport.sentLines.any((line) => line.contains('CHATHISTORY LATEST #room * 25')), + isTrue, + ); + + controller.selectTab(controller.tabs.firstWhere((tab) => tab.type.name == 'server').id); + expect( + controller.activeMessages.any( + (message) => message.content.contains('Requested recent history for #room (25 messages).'), + ), + isTrue, + ); + + controller.dispose(); + }); + + test('auto-requests channel history after end of names when supported', () 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 CAP * ACK :draft/chathistory labeled-response'); + transport.emit(':AndroidIRCX!user@example JOIN #room'); + transport.emit(':server 366 AndroidIRCX #room :End of /NAMES list.'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect( + transport.sentLines.where((line) => line.contains('CHATHISTORY LATEST #room * 50')).length, + 1, + ); + + controller.dispose(); + }); + + test('auto-history is only requested once per join burst', () 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 CAP * ACK :chathistory labeled-response'); + transport.emit(':AndroidIRCX!user@example JOIN #room'); + transport.emit(':server 366 AndroidIRCX #room :End of /NAMES list.'); + transport.emit(':server 366 AndroidIRCX #room :End of /NAMES list.'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect( + transport.sentLines.where((line) => line.contains('CHATHISTORY LATEST #room * 50')).length, + 1, + ); + + controller.dispose(); + }); + + test('does not auto-request channel history when capability is missing', () 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(':AndroidIRCX!user@example JOIN #room'); + transport.emit(':server 366 AndroidIRCX #room :End of /NAMES list.'); + await Future.delayed(Duration.zero); + + expect( + transport.sentLines.any((line) => line.contains('CHATHISTORY LATEST #room')), + isFalse, + ); + + controller.dispose(); + }); + + test('sends read marker when selecting a non-server tab and capability is enabled', () 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: '#room')); + transport.emit(':server CAP * ACK :draft/read-marker'); + transport.emit('@msgid=msg-1 :alice!user@example PRIVMSG #room :hello'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + controller.selectTab(controller.tabs.firstWhere((tab) => tab.name == '#room').id); + await Future.delayed(Duration.zero); + + expect( + transport.sentLines.any((line) => line.startsWith('MARKREAD #room timestamp=')), + isTrue, + ); + + controller.dispose(); + }); + + test('redacts a message in-place when a REDACT command arrives', () 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: '#room')); + transport.emit('@msgid=gone-1 :alice!user@example PRIVMSG #room :soon deleted'); + transport.emit(':mod!user@example REDACT #room gone-1'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + controller.selectTab(controller.tabs.firstWhere((tab) => tab.name == '#room').id); + expect( + controller.activeMessages.any( + (message) => + message.tags['msgid'] == 'gone-1' && + message.tags['redacted'] == 'true' && + message.content == '[message deleted]', + ), + isTrue, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('deleted a message'), + ), + isTrue, + ); + + controller.dispose(); + }); + + test('sends redact command for message actions when capability is enabled', () 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: '#room')); + transport.emit(':server CAP * ACK :draft/message-redaction'); + transport.emit('@msgid=del-1 :alice!user@example PRIVMSG #room :delete me'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + final targetMessage = controller + .messagesForTab(controller.tabs.firstWhere((tab) => tab.name == '#room').id) + .firstWhere((message) => message.tags['msgid'] == 'del-1'); + expect(await controller.redactMessage(targetMessage), isTrue); + + expect(transport.sentLines, contains('REDACT #room del-1')); + + controller.dispose(); + }); + + test('assembles draft multiline messages into a single chat entry', () 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: '#room')); + transport.emit( + '@draft/multiline-concat=batch-1;msgid=multi-1 :alice!user@example PRIVMSG #room :first line', + ); + transport.emit( + '@draft/multiline-concat=;msgid=multi-1 :alice!user@example PRIVMSG #room :second line', + ); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + controller.selectTab(controller.tabs.firstWhere((tab) => tab.name == '#room').id); + final assembled = controller.activeMessages.firstWhere((message) => message.tags['msgid'] == 'multi-1'); + expect(assembled.content, 'first line\nsecond line'); + + controller.dispose(); + }); + + test('tracks incoming typing indicators from TAGMSG', () 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: '#room')); + transport.emit('@+typing=active :alice!user@example TAGMSG #room'); + await Future.delayed(Duration.zero); + + controller.selectTab(controller.tabs.firstWhere((tab) => tab.name == '#room').id); + expect(controller.activeTypingUsers, contains('alice')); + + transport.emit('@+typing=done :alice!user@example TAGMSG #room'); + await Future.delayed(Duration.zero); + expect(controller.activeTypingUsers, isEmpty); + + controller.dispose(); + }); + + test('records reactions from TAGMSG react tags', () 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: '#room')); + transport.emit('@msgid=react-1 :alice!user@example PRIVMSG #room :Hello'); + transport.emit('@+draft/react=react-1\\::thumbsup: :bob!user@example TAGMSG #room'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + final message = controller + .messagesForTab(controller.tabs.firstWhere((tab) => tab.name == '#room').id) + .firstWhere((item) => item.tags['msgid'] == 'react-1'); + expect(controller.reactionsForMessage(message), containsPair(':thumbsup:', 1)); + + controller.dispose(); + }); + + test('handles account away host and setname user-state frames', () 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(':alice!user@example ACCOUNT aliceAccount'); + transport.emit(':alice!user@example AWAY :be right back'); + transport.emit(':alice!ident@example CHGHOST ident new.host.example'); + transport.emit(':alice!ident@example SETNAME :Alice Realname'); + await Future.delayed(Duration.zero); + + controller.selectTab(controller.tabs.firstWhere((tab) => tab.type.name == 'server').id); + expect(controller.activeMessages.any((m) => m.content.contains('logged in as aliceAccount')), isTrue); + expect(controller.activeMessages.any((m) => m.content.contains('is now away: be right back')), isTrue); + expect(controller.activeMessages.any((m) => m.content.contains('changed host to new.host.example')), isTrue); + expect(controller.activeMessages.any((m) => m.content.contains('changed realname to: Alice Realname')), isTrue); + + controller.dispose(); + }); + + test('supports setname command from composer when capability is enabled', () 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 CAP * ACK :setname'); + await Future.delayed(Duration.zero); + + await controller.handleComposerSubmit('/setname AndroidIRCx Flutter'); + + expect(transport.sentLines, contains('SETNAME :AndroidIRCx Flutter')); + controller.dispose(); + }); + + test('formats DCC CTCP requests into readable system 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(':alice!user@example PRIVMSG AndroidIRCX :\u0001DCC SEND "movie.mkv" 127001 5000 42\u0001'); + await Future.delayed(Duration.zero); + + controller.selectTab(controller.tabs.firstWhere((tab) => tab.type == ChatTabType.dcc).id); + expect( + controller.activeMessages.any((m) => m.content.contains('DCC SEND offer from alice: movie.mkv')), + isTrue, + ); + + controller.dispose(); + }); + + test('creates a dedicated DCC tab for incoming offers', () async { + final transport = _FakeTransport(); + final service = IrcService( + transportConnector: (_) async => transport, + ); + final dccBackend = _FakeDccBackend(); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + dccService: DccService(backend: dccBackend), + ); + + await controller.start(); + transport.emit(':alice!user@example PRIVMSG AndroidIRCX :\u0001DCC CHAT chat 127001 5001\u0001'); + await Future.delayed(Duration.zero); + + final dccTab = controller.tabs.firstWhere((tab) => tab.type == ChatTabType.dcc); + expect(dccTab.name, 'DCC CHAT alice'); + controller.selectTab(dccTab.id); + expect(controller.activeMessages.any((m) => m.content.contains('DCC CHAT request from alice')), isTrue); + + controller.dispose(); + }); + + test('accepting dcc chat enables local dcc chat composer flow', () async { + final transport = _FakeTransport(); + final service = IrcService( + transportConnector: (_) async => transport, + ); + final dccBackend = _FakeDccBackend(); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + dccService: DccService(backend: dccBackend), + ); + + await controller.start(); + transport.emit(':alice!user@example PRIVMSG AndroidIRCX :\u0001DCC CHAT chat 127001 5001\u0001'); + await Future.delayed(Duration.zero); + + final dccTab = controller.tabs.firstWhere((tab) => tab.type == ChatTabType.dcc); + controller.selectTab(dccTab.id); + await controller.acceptActiveDccSession(); + await controller.handleComposerSubmit('Hello over DCC'); + + expect( + controller.activeMessages.any((m) => m.content == 'Hello over DCC' && m.isOwn), + isTrue, + ); + expect( + transport.sentLines.any((line) => line.contains('PRIVMSG DCC CHAT')), + isFalse, + ); + + controller.dispose(); + }); + + test('dcc send tabs reject composer messages with a clear error', () 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(':alice!user@example PRIVMSG AndroidIRCX :\u0001DCC SEND "movie.mkv" 127001 5000 42\u0001'); + await Future.delayed(Duration.zero); + + final dccTab = controller.tabs.firstWhere((tab) => tab.type == ChatTabType.dcc); + controller.selectTab(dccTab.id); + await controller.handleComposerSubmit('this should fail'); + + expect( + controller.activeMessages.any((m) => m.content.contains('DCC SEND tabs do not support chat messages')), + isTrue, + ); + + controller.dispose(); + }); + + test('starts outgoing dcc chat offers and sends ctcp payload to target nick', () async { + final transport = _FakeTransport(); + final service = IrcService( + transportConnector: (_) async => transport, + ); + final dccBackend = _FakeDccBackend(); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + dccService: DccService(backend: dccBackend), + ); + + await controller.start(); + await controller.handleComposerSubmit('/dccchat alice'); + await Future.delayed(Duration.zero); + + final dccTab = controller.tabs.firstWhere((tab) => tab.type == ChatTabType.dcc); + controller.selectTab(dccTab.id); + + expect(dccTab.name, 'DCC CHAT alice'); + expect( + transport.sentLines.any( + (line) => line.startsWith('PRIVMSG alice :\u0001DCC CHAT chat '), + ), + isTrue, + ); + expect( + controller.activeMessages.any((message) => message.content.contains('Offering DCC CHAT to alice')), + isTrue, + ); + + controller.dispose(); + }); + + test('starts outgoing dcc send offers and sends ctcp payload to target nick', () async { + final transport = _FakeTransport(); + final service = IrcService( + transportConnector: (_) async => transport, + ); + final dccBackend = _FakeDccBackend(); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + dccService: DccService(backend: dccBackend), + ); + final file = File.fromUri( + Directory.systemTemp.uri.resolve('androidircx-dcc-test.txt'), + ); + await file.writeAsString('hello dcc'); + + await controller.start(); + await controller.handleComposerSubmit('/dccsend alice ${file.path}'); + await Future.delayed(Duration.zero); + + final dccTab = controller.tabs.firstWhere((tab) => tab.type == ChatTabType.dcc); + controller.selectTab(dccTab.id); + + expect(dccTab.name, 'DCC SEND alice'); + expect( + transport.sentLines.any( + (line) => line.startsWith('PRIVMSG alice :\u0001DCC SEND "androidircx-dcc-test.txt" '), + ), + isTrue, + ); + expect( + controller.activeMessages.any((message) => message.content.contains('Offering DCC SEND to alice')), + isTrue, + ); + + controller.dispose(); + }); + + test('routes invite kick and extended whois numerics into chat state', () 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: '#room')); + transport.emit(':server 341 AndroidIRCX bob #room'); + transport.emit(':carol!user@example INVITE AndroidIRCX :#room'); + transport.emit(':alice!user@example JOIN :#room'); + transport.emit(':carol!user@example KICK #room alice :cleanup'); + transport.emit(':server 301 AndroidIRCX bob :is away'); + transport.emit(':server 671 AndroidIRCX bob :is using a secure connection'); + transport.emit(':server 328 AndroidIRCX #room :https://example.com/room'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + final roomTab = controller.tabs.firstWhere((tab) => tab.name == '#room'); + controller.selectTab(roomTab.id); + + expect( + controller.activeMessages.any((message) => message.content.contains('Invitation sent to bob for #room')), + isTrue, + ); + expect( + controller.activeMessages.any((message) => message.content.contains('carol invited you to #room')), + isTrue, + ); + expect( + controller.activeMessages.any((message) => message.content.contains('alice was kicked from #room by carol')), + isTrue, + ); + expect( + controller.activeMessages.any((message) => message.content.contains('Channel URL: https://example.com/room')), + isTrue, + ); + controller.selectTab(controller.tabs.firstWhere((tab) => tab.name == 'bob').id); + expect( + controller.activeMessages.any((message) => message.content.contains('WHOIS away: bob is away')), + isTrue, + ); + expect( + controller.activeMessages.any((message) => message.content.contains('secure connection')), + isTrue, + ); + + controller.dispose(); + }); + + test('sends monitor ison and userhost commands from 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('/monitor + alice,bob'); + await controller.handleComposerSubmit('/ison alice bob'); + await controller.handleComposerSubmit('/userhost alice bob'); + + expect(transport.sentLines, contains('MONITOR + alice,bob')); + expect(transport.sentLines, contains('ISON alice bob')); + expect(transport.sentLines, contains('USERHOST alice bob')); + + controller.dispose(); + }); + + test('routes monitor ison and userhost 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 303 AndroidIRCX :alice bob'); + transport.emit(':server 302 AndroidIRCX :alice=+user@host'); + transport.emit(':server 730 AndroidIRCX :alice,bob'); + transport.emit(':server 731 AndroidIRCX :carol'); + transport.emit(':server 732 AndroidIRCX :alice,bob'); + transport.emit(':server 733 AndroidIRCX :End of MONITOR list'); + transport.emit(':server 734 AndroidIRCX :Monitor list is full'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect( + controller.activeMessages.any((message) => message.content.contains('ISON online: alice bob')), + isTrue, + ); + expect( + controller.activeMessages.any((message) => message.content.contains('USERHOST: alice=+user@host')), + isTrue, + ); + expect( + controller.activeMessages.any((message) => message.content.contains('MONITOR online: alice, bob')), + isTrue, + ); + expect( + controller.activeMessages.any((message) => message.content.contains('MONITOR offline: carol')), + isTrue, + ); + expect( + controller.activeMessages.any((message) => message.content.contains('MONITOR list: alice,bob')), + isTrue, + ); + expect( + controller.activeMessages.any((message) => message.content.contains('Monitor list is full')), + isTrue, + ); + + controller.dispose(); + }); + + test('requests older history using the oldest known msgid anchor', () 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: '#room')); + transport.emit('@msgid=first-1 :alice!user@example PRIVMSG #room :older'); + transport.emit('@msgid=last-1 :bob!user@example PRIVMSG #room :newer'); + transport.emit(':server CAP * ACK :draft/chathistory labeled-response'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(await controller.requestOlderHistory(limit: 40), isTrue); + expect( + transport.sentLines.any((line) => line.contains('CHATHISTORY BEFORE #room first-1 40')), + isTrue, + ); + + controller.dispose(); + }); + + test('reports missing history anchor when requesting older history too early', () 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: '#room')); + transport.emit(':server CAP * ACK :chathistory labeled-response'); + await Future.delayed(Duration.zero); + + expect(await controller.requestOlderHistory(limit: 50), isFalse); + controller.selectTab(controller.tabs.firstWhere((tab) => tab.type.name == 'server').id); + expect( + controller.activeMessages.any( + (message) => message.content.contains('No history anchor is available yet for #room.'), + ), + isTrue, + ); + + controller.dispose(); + }); + + test('requests newer history using the latest known msgid anchor', () 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: '#room')); + transport.emit('@msgid=first-1 :alice!user@example PRIVMSG #room :older'); + transport.emit('@msgid=last-1 :bob!user@example PRIVMSG #room :newer'); + transport.emit(':server CAP * ACK :draft/chathistory labeled-response'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(await controller.requestNewerHistory(limit: 40), isTrue); + expect( + transport.sentLines.any((line) => line.contains('CHATHISTORY AFTER #room last-1 40')), + isTrue, + ); + + controller.dispose(); + }); + + test('requests surrounding history around the latest known msgid anchor', () 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: '#room')); + transport.emit('@msgid=mid-1 :alice!user@example PRIVMSG #room :hello'); + transport.emit(':server CAP * ACK :chathistory labeled-response'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(await controller.requestAroundLatestHistory(limit: 30), isTrue); + expect( + transport.sentLines.any((line) => line.contains('CHATHISTORY AROUND #room mid-1 30')), + isTrue, + ); + + controller.dispose(); + }); + + test('reports missing recent anchor when requesting newer history too early', () 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: '#room')); + transport.emit(':server CAP * ACK :chathistory labeled-response'); + await Future.delayed(Duration.zero); + + expect(await controller.requestNewerHistory(limit: 50), isFalse); + controller.selectTab(controller.tabs.firstWhere((tab) => tab.type.name == 'server').id); + expect( + controller.activeMessages.any( + (message) => message.content.contains('No recent history anchor is available yet for #room.'), + ), + isTrue, + ); + + controller.dispose(); + }); + + test('shows unsupported chathistory error when capability is missing', () 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: '#room')); + await controller.handleComposerSubmit('/chathistory'); + + controller.selectTab(controller.tabs.firstWhere((tab) => tab.type.name == 'server').id); + expect( + controller.activeMessages.any( + (message) => message.content.contains('CHATHISTORY is not supported by this server.'), + ), + isTrue, + ); + + controller.dispose(); + }); + + test('routes CTCP requests and sends CTCP replies', () 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(':alice!user@example PRIVMSG AndroidIRCX :\u0001VERSION\u0001'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect( + transport.sentLines, + contains('NOTICE alice :\u0001VERSION AndroidIRCx Flutter 1.0.0\u0001'), + ); + controller.selectTab( + controller.tabs.firstWhere((tab) => tab.type.name == 'query' && tab.name == 'alice').id, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('CTCP VERSION request from alice'), + ), + isTrue, + ); + + controller.dispose(); + }); + + test('routes CTCP replies into the matching query 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(); + transport.emit(':alice!user@example NOTICE AndroidIRCX :\u0001PING 12345\u0001'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + controller.selectTab( + controller.tabs.firstWhere((tab) => tab.type.name == 'query' && tab.name == 'alice').id, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('CTCP PING reply from alice: 12345'), + ), + isTrue, + ); + + controller.dispose(); + }); + + test('sends CTCP 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('/ctcp alice ping 999'); + + expect( + transport.sentLines, + contains('PRIVMSG alice :\u0001PING 999\u0001'), + ); + expect(controller.activeTab.name, 'alice'); + expect( + controller.activeMessages.any( + (message) => message.content == 'Sent CTCP PING: 999', + ), + isTrue, + ); + + controller.dispose(); + }); + test('handles CAP commands from the composer', () async { final transport = _FakeTransport(); final service = IrcService( diff --git a/test/ctcp_test.dart b/test/ctcp_test.dart new file mode 100644 index 0000000..7d91253 --- /dev/null +++ b/test/ctcp_test.dart @@ -0,0 +1,35 @@ +import 'package:androidircx/irc/parser/ctcp.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('parses CTCP command with args', () { + final parsed = parseCtcp('\u0001VERSION AndroidIRCX\u0001'); + + expect(parsed.isCtcp, isTrue); + expect(parsed.command, 'VERSION'); + expect(parsed.args, 'AndroidIRCX'); + }); + + test('parses CTCP ACTION', () { + final parsed = parseCtcp('\u0001ACTION waves\u0001'); + + expect(parsed.isCtcp, isTrue); + expect(parsed.command, 'ACTION'); + expect(parsed.args, 'waves'); + }); + + test('returns non-ctcp for regular text', () { + final parsed = parseCtcp('hello'); + + expect(parsed.isCtcp, isFalse); + expect(parsed.command, isNull); + expect(parsed.args, isNull); + }); + + test('encodes CTCP command', () { + expect( + encodeCtcp('ping', '123'), + '\u0001PING 123\u0001', + ); + }); +} diff --git a/test/irc_message_parser_test.dart b/test/irc_message_parser_test.dart index a6a642f..4c161dd 100644 --- a/test/irc_message_parser_test.dart +++ b/test/irc_message_parser_test.dart @@ -19,4 +19,15 @@ void main() { expect(frame.params, isEmpty); expect(frame.trailing, 'server.example'); }); + + test('parses IRCv3 message tags and server-time', () { + final frame = parseIrcMessage( + '@time=2026-03-17T10:11:12.000Z;+draft/example=hello\\sworld :nick!user@host PRIVMSG #flutter :hi', + ); + + expect(frame.tags['time'], '2026-03-17T10:11:12.000Z'); + expect(frame.tags['+draft/example'], 'hello world'); + expect(frame.command, 'PRIVMSG'); + expect(frame.trailing, 'hi'); + }); } diff --git a/test/irc_service_sasl_test.dart b/test/irc_service_sasl_test.dart index 898576b..8c9f223 100644 --- a/test/irc_service_sasl_test.dart +++ b/test/irc_service_sasl_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/irc/models/irc_message_frame.dart'; import 'package:androidircx/irc/services/irc_service.dart'; import 'package:androidircx/irc/sasl/scram_sha256_session.dart'; import 'package:androidircx/irc/services/irc_transport.dart'; @@ -65,11 +66,14 @@ void main() { expect(transport.sentLines, containsAllInOrder(['CAP LS 302', 'NICK AndroidIRCX'])); - transport.emit(':server CAP * LS :multi-prefix sasl'); + transport.emit(':server CAP * LS :multi-prefix sasl message-tags server-time echo-message'); await Future.delayed(Duration.zero); - expect(transport.sentLines, contains('CAP REQ :sasl')); + expect( + transport.sentLines, + contains('CAP REQ :sasl echo-message message-tags multi-prefix server-time'), + ); - transport.emit(':server CAP * ACK :sasl'); + transport.emit(':server CAP * ACK :sasl echo-message message-tags server-time'); await Future.delayed(Duration.zero); expect(transport.sentLines, contains('AUTHENTICATE PLAIN')); @@ -254,4 +258,205 @@ void main() { await subscription.cancel(); service.dispose(); }); + + test('sendRawLabeled prefixes label when capability is enabled', () 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', + ), + ); + + transport.emit(':server CAP * ACK :labeled-response'); + await Future.delayed(Duration.zero); + final label = await service.sendRawLabeled('WHOIS alice alice'); + + expect(label, isNotEmpty); + expect( + transport.sentLines.any((line) => line.startsWith('@label=$label WHOIS alice alice')), + isTrue, + ); + + service.dispose(); + }); + + test('resolves labeled response when matching label returns', () async { + final transport = _FakeTransport(); + final service = IrcService( + transportConnector: (_) async => transport, + ); + final matches = <({String label, String command, IrcMessageFrame frame})>[]; + final subscription = service.labeledResponses.listen(matches.add); + + await service.connect( + const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + ), + ); + + transport.emit(':server CAP * ACK :labeled-response'); + await Future.delayed(Duration.zero); + final label = await service.sendRawLabeled('WHOIS alice alice'); + transport.emit('@label=$label :server 318 AndroidIRCX alice :End of /WHOIS list.'); + await Future.delayed(Duration.zero); + + expect(matches, hasLength(1)); + expect(matches.single.label, label); + expect(matches.single.command, 'WHOIS alice alice'); + expect(matches.single.frame.command, '318'); + + await subscription.cancel(); + service.dispose(); + }); + + test('sendChatHistory requires chathistory capability', () 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', + ), + ); + + expect( + await service.sendChatHistory(target: '#room', limit: 25), + isFalse, + ); + + transport.emit(':server CAP * ACK :draft/chathistory labeled-response'); + await Future.delayed(Duration.zero); + + expect( + await service.sendChatHistory( + target: '#room', + subcommand: 'BEFORE', + reference: 'msgid-1', + limit: 25, + ), + isTrue, + ); + expect( + transport.sentLines.any((line) => line.contains('CHATHISTORY BEFORE #room msgid-1 25')), + isTrue, + ); + + service.dispose(); + }); + + test('sendPrivmsg uses draft multiline when capability is enabled', () 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', + ), + ); + + transport.emit(':server CAP * ACK :draft/multiline'); + await Future.delayed(Duration.zero); + + await service.sendPrivmsg(target: '#room', text: 'one\ntwo'); + + expect( + transport.sentLines.where((line) => line.contains('PRIVMSG #room :one')).length, + 1, + ); + expect( + transport.sentLines.where((line) => line.contains('PRIVMSG #room :two')).length, + 1, + ); + expect( + transport.sentLines.any((line) => line.startsWith('@draft/multiline-concat=')), + isTrue, + ); + + service.dispose(); + }); + + test('sendTyping and sendReaction use tagmsg commands', () 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', + ), + ); + + transport.emit(':server CAP * ACK :typing draft/typing'); + await Future.delayed(Duration.zero); + + expect(await service.sendTyping(target: '#room', status: 'active'), isTrue); + await service.sendReaction(target: '#room', msgid: 'abc123', emoji: ':thumbsup:'); + + expect( + transport.sentLines.any((line) => line == '@+typing=active TAGMSG #room'), + isTrue, + ); + expect( + transport.sentLines.any((line) => line == '@+draft/react=abc123\\::thumbsup: TAGMSG #room'), + isTrue, + ); + + service.dispose(); + }); + + test('sendSetName requires setname capability', () 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', + ), + ); + + expect(await service.sendSetName('New Realname'), isFalse); + + transport.emit(':server CAP * ACK :setname'); + await Future.delayed(Duration.zero); + + expect(await service.sendSetName('New Realname'), isTrue); + expect(transport.sentLines, contains('SETNAME :New Realname')); + + service.dispose(); + }); } diff --git a/test/storage_repositories_test.dart b/test/storage_repositories_test.dart index 83a1408..70f9675 100644 --- a/test/storage_repositories_test.dart +++ b/test/storage_repositories_test.dart @@ -101,13 +101,23 @@ void main() { expect(saved.webSocketPort, 16697); }); - test('settings repository saves and loads showRawEvents', () async { + test('settings repository saves and loads chat display settings', () async { final repository = SharedPrefsSettingsRepository(); - await repository.saveSettings(const AppSettings(showRawEvents: false)); + await repository.saveSettings( + const AppSettings( + showRawEvents: false, + noticeRouting: NoticeRoutingMode.notice, + showHeaderSearchButton: false, + showAttachmentPreviews: false, + ), + ); final settings = await repository.loadSettings(); expect(settings.showRawEvents, isFalse); + expect(settings.noticeRouting, NoticeRoutingMode.notice); + expect(settings.showHeaderSearchButton, isFalse); + expect(settings.showAttachmentPreviews, isFalse); }); test('chat session persistence saves tabs and history', () async { @@ -124,6 +134,7 @@ void main() { sender: 'nick', content: 'hello', timestamp: DateTime(2026, 3, 16, 12, 0), + tags: const {'time': '2026-03-16T12:00:00.000Z'}, ); await persistence.save( @@ -141,6 +152,7 @@ void main() { expect(snapshot!.tabs.single.name, '#flutter'); expect(snapshot.activeTabId, tab.id); expect(snapshot.messagesByTab[tab.id]!.single.content, 'hello'); + expect(snapshot.messagesByTab[tab.id]!.single.tags['time'], '2026-03-16T12:00:00.000Z'); }); test('chat session persistence restores growable message lists', () async { diff --git a/test/widget_test.dart b/test/widget_test.dart index 0c8080e..9ab99c4 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,11 +1,15 @@ import 'dart:async'; +import 'dart:typed_data'; 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/dcc/services/dcc_service.dart'; +import 'package:androidircx/dcc/services/dcc_socket_backend.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/chat/presentation/join_channel_dialog.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'; @@ -16,6 +20,7 @@ import 'package:shared_preferences/shared_preferences.dart'; class _FakeTransport implements IrcTransport { final StreamController _controller = StreamController.broadcast(); + final List sentLines = []; @override Stream get lines => _controller.stream; @@ -26,7 +31,62 @@ class _FakeTransport implements IrcTransport { } @override - Future sendLine(String line) async {} + Future sendLine(String line) async { + sentLines.add(line); + } + + void emit(String line) { + _controller.add(line); + } +} + +class _FakeDccConnection implements DccSocketConnection { + final StreamController> _controller = StreamController>.broadcast(); + final List> sentPackets = >[]; + + @override + Stream> get bytes => _controller.stream; + + @override + Future close() async { + await _controller.close(); + } + + @override + Future sendBytes(List data) async { + sentPackets.add(Uint8List.fromList(data)); + } +} + +class _FakeDccServer implements DccSocketServer { + _FakeDccServer(this.connection); + + final DccSocketConnection connection; + + @override + String get address => '127.0.0.1'; + + @override + Stream get connections => Stream.value(connection); + + @override + int get port => 5001; + + @override + Future close() async {} +} + +class _FakeDccBackend implements DccSocketBackend { + final _FakeDccConnection connection = _FakeDccConnection(); + + @override + Future bindEphemeral() async => _FakeDccServer(connection); + + @override + Future connect({ + required String host, + required int port, + }) async => connection; } void main() { @@ -123,4 +183,362 @@ void main() { controller.dispose(); }); + + testWidgets('shows server playback actions in history tools for chat tabs', ( + 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(); + + await controller.joinChannel(const JoinChannelRequest(channel: '#room')); + transport.emit(':server CAP * ACK :chathistory labeled-response'); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.byTooltip('History tools')); + await tester.pumpAndSettle(); + + expect(find.text('Server playback'), findsOneWidget); + expect(find.text('Recent 25'), findsOneWidget); + expect(find.text('Recent 100'), findsOneWidget); + expect(find.text('Older 50'), findsOneWidget); + expect(find.text('Newer 50'), findsOneWidget); + expect(find.text('Around latest'), findsOneWidget); + + controller.dispose(); + }); + + testWidgets('toggles inline message search from the chat header', ( + 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(); + + await tester.tap(find.byTooltip('Search messages')); + await tester.pump(); + expect(find.text('Search current tab'), findsOneWidget); + + await tester.enterText(find.byType(TextField).first, 'nickserv'); + await tester.pump(); + expect(find.text('0 matches'), findsOneWidget); + + await tester.tap(find.byTooltip('Close message search')); + await tester.pump(); + expect(find.text('Search current tab'), findsNothing); + + controller.dispose(); + }); + + testWidgets('shows message attachment cards and long-press actions', ( + 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 dccBackend = _FakeDccBackend(); + final controller = ChatSessionController( + network: network, + ircService: IrcService( + transportConnector: (_) async => transport, + ), + dccService: DccService(backend: dccBackend), + ); + + await tester.pumpWidget( + MaterialApp( + home: ChatScreen(controller: controller), + ), + ); + await tester.pump(); + + transport.emit(':alice!user@example PRIVMSG AndroidIRCX :see https://example.com/file.pdf'); + await tester.pump(); + + expect(find.text('Link'), findsOneWidget); + expect(find.textContaining('https://example.com/file.pdf'), findsWidgets); + + await tester.longPress(find.textContaining('https://example.com/file.pdf').first); + await tester.pumpAndSettle(); + + expect(find.text('Copy clean text'), findsOneWidget); + expect(find.text('Copy first link'), findsOneWidget); + + controller.dispose(); + }); + + testWidgets('fills composer from message quote and reply actions', ( + 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(); + + await controller.joinChannel(const JoinChannelRequest(channel: '#room')); + transport.emit(':alice!user@example PRIVMSG #room :Hello there'); + await tester.pump(); + + await tester.longPress(find.textContaining('Hello there').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Quote in composer')); + await tester.pumpAndSettle(); + expect(find.text('> Hello there'), findsOneWidget); + + await tester.longPress(find.textContaining('Hello there').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Reply with nick')); + await tester.pumpAndSettle(); + expect(find.text('> Hello there alice: '), findsOneWidget); + + controller.dispose(); + }); + + testWidgets('shows pending reply bar and sends tagged reply flow', ( + 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(); + + await controller.joinChannel(const JoinChannelRequest(channel: '#room')); + transport.emit('@msgid=seed-1 :alice!user@example PRIVMSG #room :Original text'); + await tester.pump(); + + await tester.longPress(find.textContaining('Original text').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Reply to message')); + await tester.pumpAndSettle(); + + expect(find.text('Replying to alice'), findsOneWidget); + + await tester.enterText(find.byType(TextField).last, 'Reply body'); + await tester.tap(find.text('Send')); + await tester.pump(); + + expect( + transport.sentLines, + contains('@+draft/reply=seed-1 PRIVMSG #room :Reply body'), + ); + + controller.dispose(); + }); + + testWidgets('shows delete message action and sends redact command', ( + 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(); + + await controller.joinChannel(const JoinChannelRequest(channel: '#room')); + transport.emit(':server CAP * ACK :draft/message-redaction'); + transport.emit('@msgid=seed-redact :alice!user@example PRIVMSG #room :Delete this'); + await tester.pump(); + await tester.pump(); + + await tester.longPress(find.textContaining('Delete this').first); + await tester.pumpAndSettle(); + expect(find.text('Delete message'), findsOneWidget); + + await tester.ensureVisible(find.text('Delete message')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete message')); + await tester.pump(); + + expect(transport.sentLines, contains('REDACT #room seed-redact')); + + controller.dispose(); + }); + + testWidgets('shows typing indicator and reaction chips from TAGMSG state', ( + 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(); + + await controller.joinChannel(const JoinChannelRequest(channel: '#room')); + transport.emit('@msgid=react-ui-1 :alice!user@example PRIVMSG #room :Hello'); + transport.emit('@+draft/react=react-ui-1\\::thumbsup: :bob!user@example TAGMSG #room'); + transport.emit('@+typing=active :alice!user@example TAGMSG #room'); + await tester.pump(); + await tester.pump(); + + expect(find.textContaining(':thumbsup: 1'), findsOneWidget); + expect(find.text('alice is typing…'), findsOneWidget); + + controller.dispose(); + }); + + testWidgets('shows dcc session banner and actions for incoming dcc tabs', ( + 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(); + + transport.emit(':alice!user@example PRIVMSG AndroidIRCX :\u0001DCC CHAT chat 127001 5001\u0001'); + await tester.pump(); + await tester.pump(); + + expect(find.text('DCC CHAT session'), findsOneWidget); + expect(find.text('Accept'), findsOneWidget); + expect(find.text('Decline'), findsOneWidget); + + await tester.tap(find.text('Accept')); + await tester.pumpAndSettle(); + + expect(find.text('Accept'), findsNothing); + expect(find.text('Close'), findsOneWidget); + + controller.dispose(); + }); } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..4f78848 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..88b22e5 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST