import Combine import Foundation /// Entspricht `CommandTableState` in `ChatRepository.kt`. struct CommandTableState: Equatable, Sendable { var title: String var columns: [String] var rows: [[String]] } /// Zentraler UI-Zustand wie `ChatState` in `ChatRepository.kt`. struct ChatState: Equatable, Sendable { var isConnected: Bool = false var isLoggedIn: Bool = false var expressSessionId: String? var currentUser: UserDto? var users: [UserDto] = [] var currentConversation: String? var messages: [ChatMessageDto] = [] var searchResults: [UserDto] = [] var inboxResults: [InboxItemDto] = [] var historyResults: [HistoryItemDto] = [] var countries: [CountryOption] = [] var feedbackItems: [FeedbackItemDto] = [] var feedbackMessage: String? var feedbackAdminAuthenticated: Bool = false var feedbackAdminUserName: String? var feedbackAdminError: String? var partnerLinks: [PartnerLinkDto] = [] var partnersError: String? var savedProfile: SavedProfile = SavedProfile() var commandLines: [String] = [] var commandKind: String? var commandTable: CommandTableState? var awaitingLoginUsername: Bool = false var awaitingLoginPassword: Bool = false var remainingSecondsToTimeout: Int = 1800 var isUploadingImage: Bool = false var imageUploadMessage: String? var unreadChatsCount: Int = 0 var errorMessage: String? } /// Analog `ChatRepository.kt`: REST + Socket + `reduce` + Timeout. final class ChatRepository: ObservableObject { @Published private(set) var state = ChatState() private let api: RestAPIClient private let socket: SocketClient private let profileStore: ProfileStore private var cancellables = Set() private var timeoutTickerStarted = false init(api: RestAPIClient, socket: SocketClient, profileStore: ProfileStore) { self.api = api self.socket = socket self.profileStore = profileStore socket.eventPublisher .receive(on: DispatchQueue.main) .sink { [weak self] event in self?.reduce(event) } .store(in: &cancellables) startTimeoutTicker() } // MARK: - Session / Profil func restoreSession() async { var s = state s.savedProfile = profileStore.read() state = s await loadCountries() await loadFeedbackAdminStatus() do { let session = try await api.sessionStatus() var next = state next.expressSessionId = session.sessionId next.isLoggedIn = session.loggedIn && session.user != nil next.currentUser = session.user next.errorMessage = nil state = next if session.loggedIn, session.user != nil { resetTimeout() } connectSocket(expressSessionId: session.sessionId) } catch { var next = state next.errorMessage = error.localizedDescription state = next } } func loadCountries() async { do { let countries = try await api.countries() let locale = Locale.current let options: [CountryOption] = countries.map { englishName, code in let normalized = code.uppercased() let localized = Locale.current.localizedString(forRegionCode: normalized) let display = localized.flatMap { $0.isEmpty ? nil : $0 } ?? englishName return CountryOption(englishName: englishName, displayName: display, isoCode: code) } .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } var next = state next.countries = options state = next } catch { var next = state next.errorMessage = "Country list could not be loaded: \(error.localizedDescription)" state = next } } func login(userName: String, gender: String, age: Int, country: String) async { profileStore.write(SavedProfile(nickname: userName, gender: gender, age: age, country: country)) let session = try? await api.sessionStatus() let sessionId = session?.sessionId ?? state.expressSessionId var next = state next.expressSessionId = sessionId state = next if !socket.isConnected { connectSocket(expressSessionId: sessionId) } socket.login(userName: userName, gender: gender, age: age, country: country, expressSessionId: sessionId) resetTimeout() } func logout() async { try? await api.logout() socket.disconnect() clearCookies() var fresh = ChatState() fresh.savedProfile = profileStore.read() fresh.countries = state.countries state = fresh } func connectSocket(expressSessionId: String? = nil) { let sid = expressSessionId ?? state.expressSessionId socket.connect() if let sid { socket.setSessionId(sid) } } // MARK: - Chat func openConversation(userName: String) { var next = state next.currentConversation = userName next.messages = [] state = next socket.requestConversation(withUserName: userName) resetTimeout() } func closeConversation() { var next = state next.currentConversation = nil next.messages = [] state = next } func sendMessage(text: String) { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } let target = state.currentConversation let isCommand = trimmed.hasPrefix("/") if target == nil, !isCommand { return } socket.sendMessage(toUserName: target, message: trimmed) if !isCommand { let msg = ChatMessageDto( from: state.currentUser?.userName ?? "", to: target, message: trimmed, messageId: nil, timestamp: ISO8601DateFormatter().string(from: Date()), read: false, isImage: false, imageType: nil, imageUrl: nil, imageCode: nil ) var next = state next.messages.append(msg) state = next } resetTimeout() } func sendImage(toUserName: String, imageCode: String, imageUrl: String) { let absoluteUrl: String = imageUrl.hasPrefix("http") ? imageUrl : AppConfig.baseURL + imageUrl socket.sendImage(toUserName: toUserName, imageCode: imageCode, imageUrl: absoluteUrl) let msg = ChatMessageDto( from: state.currentUser?.userName ?? "", to: toUserName, message: absoluteUrl, messageId: nil, timestamp: ISO8601DateFormatter().string(from: Date()), read: false, isImage: true, imageType: nil, imageUrl: absoluteUrl, imageCode: imageCode ) var next = state next.messages.append(msg) state = next resetTimeout() } func setImageUploadState(inProgress: Bool, message: String? = nil) { var next = state next.isUploadingImage = inProgress next.imageUploadMessage = message state = next } func uploadImage(data: Data, fileName: String, mimeType: String) async throws -> ImageUploadResponse { let result = try await api.uploadImage(data: data, fileName: fileName, mimeType: mimeType) return result.response } // MARK: - Feedback / Partner func loadFeedback() async { do { let response = try await api.feedback() var next = state next.feedbackItems = response.items next.feedbackMessage = nil state = next } catch { var next = state next.feedbackMessage = error.localizedDescription state = next } } func submitFeedback(comment: String) async { let trimmed = comment.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } let profile = state.savedProfile do { try await api.submitFeedback( FeedbackRequest( name: profile.nickname, age: profile.age, country: profile.country, gender: profile.gender, comment: trimmed ) ) var next = state next.feedbackMessage = "Feedback saved" state = next await loadFeedback() } catch { var next = state next.feedbackMessage = error.localizedDescription state = next } } func loadFeedbackAdminStatus() async { do { let response = try await api.feedbackAdminStatus() var next = state next.feedbackAdminAuthenticated = response.authenticated next.feedbackAdminUserName = response.username next.feedbackAdminError = nil state = next } catch { var next = state next.feedbackAdminAuthenticated = false next.feedbackAdminUserName = nil state = next } } func loginFeedbackAdmin(username: String, password: String) async { do { let response = try await api.feedbackAdminLogin(FeedbackAdminLoginRequest(username: username, password: password)) var next = state next.feedbackAdminAuthenticated = true next.feedbackAdminUserName = response.username next.feedbackAdminError = nil state = next await loadFeedback() } catch { var next = state next.feedbackAdminError = error.localizedDescription state = next } } func logoutFeedbackAdmin() async { try? await api.feedbackAdminLogout() var next = state next.feedbackAdminAuthenticated = false next.feedbackAdminUserName = nil next.feedbackAdminError = nil state = next } func deleteFeedback(id: String) async { do { try await api.deleteFeedback(id: id) await loadFeedback() } catch { var next = state next.feedbackAdminError = error.localizedDescription state = next } } func loadPartners() async { do { let links = try await api.partners() var next = state next.partnerLinks = links next.partnersError = nil state = next } catch { var next = state next.partnersError = error.localizedDescription state = next } } // MARK: - Socket actions func search(nameIncludes: String?, minAge: Int?, maxAge: Int?, countries: [String], genders: [String]) { socket.userSearch(nameIncludes: nameIncludes, minAge: minAge, maxAge: maxAge, countries: countries, genders: genders) resetTimeout() } func requestInbox() { socket.requestOpenConversations() resetTimeout() } func requestHistory() { socket.requestHistory() resetTimeout() } func blockUser(userName: String) { socket.blockUser(userName: userName) resetTimeout() } func unblockUser(userName: String) { socket.unblockUser(userName: userName) resetTimeout() } // MARK: - Private private func clearCookies() { HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } } private func startTimeoutTicker() { guard !timeoutTickerStarted else { return } timeoutTickerStarted = true Timer.publish(every: 1, on: .main, in: .common) .autoconnect() .sink { [weak self] _ in self?.runTimeoutTick() } .store(in: &cancellables) } private func runTimeoutTick() { guard state.isLoggedIn else { return } let nextSec = max(0, state.remainingSecondsToTimeout - 1) var next = state next.remainingSecondsToTimeout = nextSec state = next if nextSec == 0 { Task { await self.logout() } } } private func resetTimeout() { guard state.isLoggedIn || state.currentUser != nil else { return } var next = state next.remainingSecondsToTimeout = 1800 state = next } private func reduce(_ event: SocketEvent) { state = Self.apply(state: state, event: event, socket: socket) } private static func apply(state: ChatState, event: SocketEvent, socket: SocketClient) -> ChatState { var current = state switch event { case .connectionChanged(let connected, _): current.isConnected = connected case .connected(let sessionId, let loggedIn, let user): if let sid = sessionId { socket.setSessionId(sid) } current.expressSessionId = sessionId ?? current.expressSessionId current.isLoggedIn = loggedIn || current.isLoggedIn current.currentUser = user ?? current.currentUser current.errorMessage = nil if current.isLoggedIn { current.remainingSecondsToTimeout = 1800 } case .loginSuccess(let sessionId, let user): current.expressSessionId = sessionId ?? current.expressSessionId current.isLoggedIn = true current.currentUser = user current.errorMessage = nil current.remainingSecondsToTimeout = 1800 case .userList(let users): current.users = users case .incomingMessage(let message): let active = current.currentConversation == message.from if active { current.messages.append(message) } if !active { current.unreadChatsCount += 1 } current.remainingSecondsToTimeout = 1800 case .messageSent: break case .conversation(let withUserName, let messages): current.currentConversation = withUserName current.messages = messages current.unreadChatsCount = max(0, current.unreadChatsCount - 1) current.remainingSecondsToTimeout = 1800 case .searchResults(let results): current.searchResults = results case .historyResults(let results): current.historyResults = results case .inboxResults(let results): current.inboxResults = results case .unreadChats(let count): current.unreadChatsCount = count case .userBlocked(let userName): current.errorMessage = "\(userName) blocked" case .userUnblocked(let userName): current.errorMessage = "\(userName) unblocked" case .commandResult(let lines, let kind): current.commandLines = lines current.commandKind = kind current.commandTable = nil current.awaitingLoginUsername = kind == "loginPromptUsername" current.awaitingLoginPassword = kind == "loginPromptPassword" if kind == "info" || kind.hasPrefix("login") { current.errorMessage = lines.joined(separator: " | ") } case .commandTable(let title, let columns, let rows): current.commandLines = [] current.commandKind = nil current.commandTable = CommandTableState(title: title, columns: columns, rows: rows) case .error(let message): current.errorMessage = message } return current } }