- 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.
496 lines
16 KiB
Swift
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
|
|
}
|
|
}
|