Files
singlechat/ios/YpChat/Data/ChatRepository.swift
Torsten Schulz (local) 8d323ceab1 Refactor command handling in ChatRepository for Android and iOS
- Reset commandLines and commandKind to default values when a command table event is received, ensuring a clean state for subsequent commands.
- This change improves the handling of command events and maintains consistency across platforms.
2026-05-12 15:40:34 +02:00

496 lines
16 KiB
Swift

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<AnyCancellable>()
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
}
}