Files
singlechat/ios/YpChat/Data/ChatRepository.swift
Torsten Schulz (local) 810b084e10 Refactor application structure and configuration
- Updated the application namespace and ID from "net.ypchat.app" to "de.ypchat.android" for better alignment with branding.
- Increased Gradle heap size settings to optimize build performance.
- Disabled dependency constraints to simplify dependency management.
- Removed obsolete files related to the previous application structure, including MainActivity, YpChatApp, and various core components, streamlining the codebase.

These changes collectively enhance the application's configuration and structure, improving maintainability and performance.
2026-05-12 14:25:55 +02:00

494 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.commandTable = CommandTableState(title: title, columns: columns, rows: rows)
case .error(let message):
current.errorMessage = message
}
return current
}
}