Compare commits
	
		
			3 Commits
		
	
	
		
			cc32d7acad
			...
			ed91efacf5
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| ed91efacf5 | |||
| 3658d5a963 | |||
| a6f399bb08 | 
							
								
								
									
										51
									
								
								yobble/Components/NewMessageBannerView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								yobble/Components/NewMessageBannerView.swift
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,51 @@
 | 
			
		||||
import SwiftUI
 | 
			
		||||
 | 
			
		||||
struct IncomingMessageBanner: Identifiable {
 | 
			
		||||
    let id = UUID()
 | 
			
		||||
    let message: MessageItem
 | 
			
		||||
    let senderName: String
 | 
			
		||||
    let messagePreview: String
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct NewMessageBannerView: View {
 | 
			
		||||
    let banner: IncomingMessageBanner
 | 
			
		||||
    let onOpen: () -> Void
 | 
			
		||||
    let onDismiss: () -> Void
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        HStack(alignment: .center, spacing: 12) {
 | 
			
		||||
            Image(systemName: "bubble.left.and.bubble.right.fill")
 | 
			
		||||
                .foregroundColor(.accentColor)
 | 
			
		||||
                .imageScale(.medium)
 | 
			
		||||
 | 
			
		||||
            VStack(alignment: .leading, spacing: 4) {
 | 
			
		||||
                Text(banner.senderName)
 | 
			
		||||
                    .font(.headline)
 | 
			
		||||
                    .foregroundColor(.primary)
 | 
			
		||||
                    .lineLimit(1)
 | 
			
		||||
 | 
			
		||||
                Text(banner.messagePreview)
 | 
			
		||||
                    .font(.subheadline)
 | 
			
		||||
                    .foregroundColor(.secondary)
 | 
			
		||||
                    .lineLimit(2)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            Spacer(minLength: 8)
 | 
			
		||||
 | 
			
		||||
            Button(action: onDismiss) {
 | 
			
		||||
                Image(systemName: "xmark")
 | 
			
		||||
                    .font(.footnote.weight(.semibold))
 | 
			
		||||
                    .foregroundColor(.secondary)
 | 
			
		||||
                    .padding(6)
 | 
			
		||||
                    .background(Color.secondary.opacity(0.15), in: Circle())
 | 
			
		||||
            }
 | 
			
		||||
            .buttonStyle(.plain)
 | 
			
		||||
        }
 | 
			
		||||
        .padding(.horizontal, 16)
 | 
			
		||||
        .padding(.vertical, 14)
 | 
			
		||||
        .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
 | 
			
		||||
        .shadow(color: Color.black.opacity(0.12), radius: 12, y: 6)
 | 
			
		||||
        .contentShape(Rectangle())
 | 
			
		||||
        .onTapGesture(perform: onOpen)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -9,6 +9,7 @@ struct TopBarView: View {
 | 
			
		||||
    var accounts: [String]
 | 
			
		||||
//    var viewModel: LoginViewModel
 | 
			
		||||
    @ObservedObject var viewModel: LoginViewModel
 | 
			
		||||
    @Binding var isSettingsPresented: Bool
 | 
			
		||||
    
 | 
			
		||||
    // Привязка для управления боковым меню
 | 
			
		||||
    @Binding var isSideMenuPresented: Bool
 | 
			
		||||
@ -101,7 +102,9 @@ struct TopBarView: View {
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                } else if isProfileTab {
 | 
			
		||||
                    NavigationLink(destination: SettingsView(viewModel: viewModel)) {
 | 
			
		||||
                    NavigationLink(isActive: $isSettingsPresented) {
 | 
			
		||||
                        SettingsView(viewModel: viewModel)
 | 
			
		||||
                    } label: {
 | 
			
		||||
                        Image(systemName: "wrench")
 | 
			
		||||
                            .imageScale(.large)
 | 
			
		||||
                            .foregroundColor(.primary)
 | 
			
		||||
@ -213,6 +216,7 @@ struct TopBarView_Previews: PreviewProvider {
 | 
			
		||||
        @State private var revealProgress: CGFloat = 1
 | 
			
		||||
        @StateObject private var viewModel = LoginViewModel()
 | 
			
		||||
        @State private var searchText: String = ""
 | 
			
		||||
        @State private var isSettingsPresented = false
 | 
			
		||||
 | 
			
		||||
        var body: some View {
 | 
			
		||||
            TopBarView(
 | 
			
		||||
@ -220,6 +224,7 @@ struct TopBarView_Previews: PreviewProvider {
 | 
			
		||||
                selectedAccount: $selectedAccount,
 | 
			
		||||
                accounts: [selectedAccount],
 | 
			
		||||
                viewModel: viewModel,
 | 
			
		||||
                isSettingsPresented: $isSettingsPresented,
 | 
			
		||||
                isSideMenuPresented: $isSideMenuPresented,
 | 
			
		||||
                chatSearchRevealProgress: $revealProgress,
 | 
			
		||||
                chatSearchText: $searchText
 | 
			
		||||
 | 
			
		||||
@ -195,6 +195,9 @@
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "Аудио" : {
 | 
			
		||||
      "comment" : "Audio message placeholder"
 | 
			
		||||
    },
 | 
			
		||||
    "Без звука (скоро)" : {
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
@ -232,6 +235,9 @@
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "Видео" : {
 | 
			
		||||
      "comment" : "Video message placeholder"
 | 
			
		||||
    },
 | 
			
		||||
    "Видимость и контент" : {
 | 
			
		||||
      "localizations" : {
 | 
			
		||||
        "en" : {
 | 
			
		||||
@ -524,6 +530,9 @@
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "Изображение" : {
 | 
			
		||||
      "comment" : "Image message placeholder"
 | 
			
		||||
    },
 | 
			
		||||
    "Инвайт-код (необязательно)" : {
 | 
			
		||||
      "comment" : "Инвайт-код",
 | 
			
		||||
      "localizations" : {
 | 
			
		||||
@ -1141,6 +1150,9 @@
 | 
			
		||||
    "Ничего не найдено" : {
 | 
			
		||||
      "comment" : "Global search empty state"
 | 
			
		||||
    },
 | 
			
		||||
    "Новое сообщение" : {
 | 
			
		||||
      "comment" : "Default banner subtitle"
 | 
			
		||||
    },
 | 
			
		||||
    "Новый пароль" : {
 | 
			
		||||
      "comment" : "Новый пароль",
 | 
			
		||||
      "localizations" : {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										105
									
								
								yobble/Services/IncomingMessageCenter.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								yobble/Services/IncomingMessageCenter.swift
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,105 @@
 | 
			
		||||
import Foundation
 | 
			
		||||
import Combine
 | 
			
		||||
 | 
			
		||||
final class IncomingMessageCenter: ObservableObject {
 | 
			
		||||
    @Published private(set) var banner: IncomingMessageBanner?
 | 
			
		||||
    @Published var presentedChat: PrivateChatListItem?
 | 
			
		||||
    var currentUserId: String?
 | 
			
		||||
 | 
			
		||||
    private var dismissWorkItem: DispatchWorkItem?
 | 
			
		||||
    private var cancellables = Set<AnyCancellable>()
 | 
			
		||||
    private let notificationCenter: NotificationCenter
 | 
			
		||||
 | 
			
		||||
    init(notificationCenter: NotificationCenter = .default) {
 | 
			
		||||
        self.notificationCenter = notificationCenter
 | 
			
		||||
        notificationCenter.publisher(for: .socketDidReceivePrivateMessage)
 | 
			
		||||
            .compactMap { $0.object as? MessageItem }
 | 
			
		||||
            .receive(on: RunLoop.main)
 | 
			
		||||
            .sink { [weak self] message in
 | 
			
		||||
                self?.handleIncoming(message)
 | 
			
		||||
            }
 | 
			
		||||
            .store(in: &cancellables)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func dismissBanner() {
 | 
			
		||||
        dismissWorkItem?.cancel()
 | 
			
		||||
        dismissWorkItem = nil
 | 
			
		||||
        banner = nil
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func openCurrentChat() {
 | 
			
		||||
        guard let banner else { return }
 | 
			
		||||
        presentedChat = makeChatItem(from: banner.message)
 | 
			
		||||
        dismissBanner()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func handleIncoming(_ message: MessageItem) {
 | 
			
		||||
        if let currentUserId, message.senderId == currentUserId {
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        banner = buildBanner(from: message)
 | 
			
		||||
        scheduleDismiss()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func buildBanner(from message: MessageItem) -> IncomingMessageBanner {
 | 
			
		||||
        let sender = senderName(for: message)
 | 
			
		||||
        let preview = messagePreview(for: message)
 | 
			
		||||
        return IncomingMessageBanner(message: message, senderName: sender, messagePreview: preview)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func senderName(for message: MessageItem) -> String {
 | 
			
		||||
        let candidates: [String?] = [
 | 
			
		||||
            message.senderData?.customName,
 | 
			
		||||
            message.senderData?.fullName,
 | 
			
		||||
            message.senderData?.login.map { "@\($0)" }
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        for candidate in candidates {
 | 
			
		||||
            if let value = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty {
 | 
			
		||||
                return value
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return message.senderId
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func messagePreview(for message: MessageItem) -> String {
 | 
			
		||||
        if let content = message.content?.trimmingCharacters(in: .whitespacesAndNewlines), !content.isEmpty {
 | 
			
		||||
            return content
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        switch message.messageType.lowercased() {
 | 
			
		||||
        case "image":
 | 
			
		||||
            return NSLocalizedString("Изображение", comment: "Image message placeholder")
 | 
			
		||||
        case "audio":
 | 
			
		||||
            return NSLocalizedString("Аудио", comment: "Audio message placeholder")
 | 
			
		||||
        case "video":
 | 
			
		||||
            return NSLocalizedString("Видео", comment: "Video message placeholder")
 | 
			
		||||
        default:
 | 
			
		||||
            return NSLocalizedString("Новое сообщение", comment: "Default banner subtitle")
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func makeChatItem(from message: MessageItem) -> PrivateChatListItem {
 | 
			
		||||
        let profile = message.senderData
 | 
			
		||||
        return PrivateChatListItem(
 | 
			
		||||
            chatId: message.chatId,
 | 
			
		||||
            chatType: .privateChat,
 | 
			
		||||
            chatData: profile,
 | 
			
		||||
            lastMessage: message,
 | 
			
		||||
            createdAt: message.createdAt,
 | 
			
		||||
            unreadCount: 0
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func scheduleDismiss(after delay: TimeInterval = 5) {
 | 
			
		||||
        dismissWorkItem?.cancel()
 | 
			
		||||
        let workItem = DispatchWorkItem { [weak self] in
 | 
			
		||||
            self?.banner = nil
 | 
			
		||||
            self?.dismissWorkItem = nil
 | 
			
		||||
        }
 | 
			
		||||
        dismissWorkItem = workItem
 | 
			
		||||
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -25,6 +25,12 @@ final class SocketService {
 | 
			
		||||
            .eraseToAnyPublisher()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var newPrivateMessagePublisher: AnyPublisher<MessageItem, Never> {
 | 
			
		||||
        privateMessageSubject
 | 
			
		||||
            .receive(on: DispatchQueue.main)
 | 
			
		||||
            .eraseToAnyPublisher()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var currentConnectionState: ConnectionState {
 | 
			
		||||
        connectionStateSubject.value
 | 
			
		||||
    }
 | 
			
		||||
@ -42,6 +48,7 @@ final class SocketService {
 | 
			
		||||
    private var lastHeartbeatSentAt: Date?
 | 
			
		||||
    private var consecutiveHeartbeatMisses = 0
 | 
			
		||||
    private var reconnectWorkItem: DispatchWorkItem?
 | 
			
		||||
    private let privateMessageSubject = PassthroughSubject<MessageItem, Never>()
 | 
			
		||||
    #endif
 | 
			
		||||
 | 
			
		||||
    private init() {}
 | 
			
		||||
@ -204,13 +211,17 @@ final class SocketService {
 | 
			
		||||
        socket.on(clientEvent: .disconnect) { data, _ in
 | 
			
		||||
            if AppConfig.DEBUG { print("[SocketService] Disconnected: \(data)") }
 | 
			
		||||
            self.updateConnectionState(.disconnected)
 | 
			
		||||
            self.scheduleReconnect()
 | 
			
		||||
            if self.currentToken != nil {
 | 
			
		||||
                self.scheduleReconnect()
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        socket.on(clientEvent: .error) { data, _ in
 | 
			
		||||
            if AppConfig.DEBUG { print("[SocketService] Error: \(data)") }
 | 
			
		||||
            self.updateConnectionState(.disconnected)
 | 
			
		||||
            self.scheduleReconnect()
 | 
			
		||||
            if self.currentToken != nil {
 | 
			
		||||
                self.scheduleReconnect()
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        socket.on("pong") { [weak self] _, _ in
 | 
			
		||||
@ -221,6 +232,10 @@ final class SocketService {
 | 
			
		||||
            self?.handleMessageEvent(data)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        socket.on("chat_private:new_message") { [weak self] data, _ in
 | 
			
		||||
            self?.handleNewPrivateMessage(data)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        self.manager = manager
 | 
			
		||||
        self.socket = socket
 | 
			
		||||
    }
 | 
			
		||||
@ -322,6 +337,42 @@ final class SocketService {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func handleNewPrivateMessage(_ data: [Any]) {
 | 
			
		||||
        guard let payload = data.first else { return }
 | 
			
		||||
 | 
			
		||||
        let messageData: Data
 | 
			
		||||
        if let dictionary = payload as? [String: Any],
 | 
			
		||||
           JSONSerialization.isValidJSONObject(dictionary),
 | 
			
		||||
           let json = try? JSONSerialization.data(withJSONObject: dictionary, options: []) {
 | 
			
		||||
            messageData = json
 | 
			
		||||
        } else if let string = payload as? String,
 | 
			
		||||
                  let data = string.data(using: .utf8) {
 | 
			
		||||
            messageData = data
 | 
			
		||||
        } else {
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let decoder = JSONDecoder()
 | 
			
		||||
        decoder.keyDecodingStrategy = .convertFromSnakeCase
 | 
			
		||||
        decoder.dateDecodingStrategy = .custom(Self.decodeServerDate)
 | 
			
		||||
 | 
			
		||||
        do {
 | 
			
		||||
            let message = try decoder.decode(MessageItem.self, from: messageData)
 | 
			
		||||
            DispatchQueue.main.async {
 | 
			
		||||
                NotificationCenter.default.post(name: .socketDidReceivePrivateMessage, object: message)
 | 
			
		||||
                self.privateMessageSubject.send(message)
 | 
			
		||||
                NotificationCenter.default.post(name: .chatsShouldRefresh, object: nil)
 | 
			
		||||
            }
 | 
			
		||||
        } catch {
 | 
			
		||||
            if AppConfig.DEBUG {
 | 
			
		||||
                print("[SocketService] Failed to decode new message: \(error)")
 | 
			
		||||
                if let payloadString = String(data: messageData, encoding: .utf8) {
 | 
			
		||||
                    print("[SocketService] payload=\(payloadString)")
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func handleHeartbeatSuccess() {
 | 
			
		||||
        consecutiveHeartbeatMisses = 0
 | 
			
		||||
        heartbeatAckInFlight = false
 | 
			
		||||
@ -367,7 +418,38 @@ final class SocketService {
 | 
			
		||||
        reconnectWorkItem?.cancel()
 | 
			
		||||
        reconnectWorkItem = nil
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static func decodeServerDate(from decoder: Decoder) throws -> Date {
 | 
			
		||||
        let container = try decoder.singleValueContainer()
 | 
			
		||||
        let string = try container.decode(String.self)
 | 
			
		||||
        if let date = iso8601WithFractionalSeconds.date(from: string) {
 | 
			
		||||
            return date
 | 
			
		||||
        }
 | 
			
		||||
        if let date = iso8601Simple.date(from: string) {
 | 
			
		||||
            return date
 | 
			
		||||
        }
 | 
			
		||||
        throw DecodingError.dataCorruptedError(
 | 
			
		||||
            in: container,
 | 
			
		||||
            debugDescription: "Unable to decode date: \(string)"
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static let iso8601WithFractionalSeconds: ISO8601DateFormatter = {
 | 
			
		||||
        let formatter = ISO8601DateFormatter()
 | 
			
		||||
        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
 | 
			
		||||
        return formatter
 | 
			
		||||
    }()
 | 
			
		||||
 | 
			
		||||
    private static let iso8601Simple: ISO8601DateFormatter = {
 | 
			
		||||
        let formatter = ISO8601DateFormatter()
 | 
			
		||||
        formatter.formatOptions = [.withInternetDateTime]
 | 
			
		||||
        return formatter
 | 
			
		||||
    }()
 | 
			
		||||
    #else
 | 
			
		||||
    private func disconnectInternal() { }
 | 
			
		||||
    #endif
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
extension Notification.Name {
 | 
			
		||||
    static let socketDidReceivePrivateMessage = Notification.Name("socketDidReceivePrivateMessage")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -15,12 +15,32 @@ final class PrivateChatViewModel: ObservableObject {
 | 
			
		||||
    private let maxMessageLength: Int = 4096
 | 
			
		||||
    private var hasMore: Bool = true
 | 
			
		||||
    private var didLoadInitially: Bool = false
 | 
			
		||||
    private var messageObserver: NSObjectProtocol?
 | 
			
		||||
 | 
			
		||||
    init(chatId: String, currentUserId: String?, chatService: ChatService = ChatService(), pageSize: Int = 30) {
 | 
			
		||||
        self.chatId = chatId
 | 
			
		||||
        self.currentUserId = currentUserId
 | 
			
		||||
        self.chatService = chatService
 | 
			
		||||
        self.pageSize = pageSize
 | 
			
		||||
 | 
			
		||||
        messageObserver = NotificationCenter.default.addObserver(
 | 
			
		||||
            forName: .socketDidReceivePrivateMessage,
 | 
			
		||||
            object: nil,
 | 
			
		||||
            queue: .main
 | 
			
		||||
        ) { [weak self] notification in
 | 
			
		||||
            guard
 | 
			
		||||
                let self,
 | 
			
		||||
                let message = notification.object as? MessageItem,
 | 
			
		||||
                message.chatId == self.chatId
 | 
			
		||||
            else { return }
 | 
			
		||||
            self.messages = Self.merge(existing: self.messages, newMessages: [message])
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    deinit {
 | 
			
		||||
        if let observer = messageObserver {
 | 
			
		||||
            NotificationCenter.default.removeObserver(observer)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func loadInitialHistory(force: Bool = false) {
 | 
			
		||||
 | 
			
		||||
@ -38,7 +38,11 @@ struct ChatsTab: View {
 | 
			
		||||
        return userId.isEmpty ? nil : userId
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    init(loginViewModel: LoginViewModel, searchRevealProgress: Binding<CGFloat>, searchText: Binding<String>) {
 | 
			
		||||
    init(
 | 
			
		||||
        loginViewModel: LoginViewModel,
 | 
			
		||||
        searchRevealProgress: Binding<CGFloat>,
 | 
			
		||||
        searchText: Binding<String>
 | 
			
		||||
    ) {
 | 
			
		||||
        self._loginViewModel = ObservedObject(wrappedValue: loginViewModel)
 | 
			
		||||
        self._searchRevealProgress = searchRevealProgress
 | 
			
		||||
        self._searchText = searchText
 | 
			
		||||
@ -52,9 +56,15 @@ struct ChatsTab: View {
 | 
			
		||||
                handleSearchQueryChange(searchText)
 | 
			
		||||
            }
 | 
			
		||||
            .onReceive(NotificationCenter.default.publisher(for: .debugRefreshChats)) { _ in
 | 
			
		||||
                if loginViewModel.chatLoadingState != .loading {
 | 
			
		||||
                    loginViewModel.chatLoadingState = .loading
 | 
			
		||||
                }
 | 
			
		||||
                viewModel.refresh()
 | 
			
		||||
            }
 | 
			
		||||
            .onReceive(NotificationCenter.default.publisher(for: .chatsShouldRefresh)) { _ in
 | 
			
		||||
                if loginViewModel.chatLoadingState != .loading {
 | 
			
		||||
                    loginViewModel.chatLoadingState = .loading
 | 
			
		||||
                }
 | 
			
		||||
                viewModel.refresh()
 | 
			
		||||
            }
 | 
			
		||||
            .onChange(of: searchText) { newValue in
 | 
			
		||||
@ -116,11 +126,11 @@ struct ChatsTab: View {
 | 
			
		||||
                            Text(message)
 | 
			
		||||
                                .font(.subheadline)
 | 
			
		||||
                                .foregroundColor(.orange)
 | 
			
		||||
//                            Spacer(minLength: 0)
 | 
			
		||||
//                            Button(action: triggerChatsReload) {
 | 
			
		||||
//                                Text(NSLocalizedString("Обновить", comment: ""))
 | 
			
		||||
//                                    .font(.subheadline)
 | 
			
		||||
//                            }
 | 
			
		||||
                            Spacer(minLength: 0)
 | 
			
		||||
                            Button(action: triggerChatsReload) {
 | 
			
		||||
                                Text(NSLocalizedString("Обновить", comment: ""))
 | 
			
		||||
                                    .font(.subheadline)
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        .padding(.vertical, 4)
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
@ -15,7 +15,8 @@ struct MainView: View {
 | 
			
		||||
    @State private var menuOffset: CGFloat = 0
 | 
			
		||||
    @State private var chatSearchRevealProgress: CGFloat = 0
 | 
			
		||||
    @State private var chatSearchText: String = ""
 | 
			
		||||
    
 | 
			
		||||
    @State private var isSettingsPresented = false
 | 
			
		||||
 | 
			
		||||
    private var tabTitle: String {
 | 
			
		||||
        switch selectedTab {
 | 
			
		||||
        case 0: return "Home"
 | 
			
		||||
@ -32,66 +33,69 @@ struct MainView: View {
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        NavigationView {
 | 
			
		||||
            ZStack(alignment: .leading) { // Выравниваем ZStack по левому краю
 | 
			
		||||
                // Основной контент
 | 
			
		||||
                VStack(spacing: 0) {
 | 
			
		||||
                    TopBarView(
 | 
			
		||||
                        title: tabTitle,
 | 
			
		||||
                        selectedAccount: $selectedAccount,
 | 
			
		||||
                        accounts: accounts,
 | 
			
		||||
                        viewModel: viewModel,
 | 
			
		||||
                        isSideMenuPresented: $isSideMenuPresented,
 | 
			
		||||
                        chatSearchRevealProgress: $chatSearchRevealProgress,
 | 
			
		||||
                        chatSearchText: $chatSearchText
 | 
			
		||||
                    )
 | 
			
		||||
                    
 | 
			
		||||
                    ZStack {
 | 
			
		||||
                        NewHomeTab()
 | 
			
		||||
                            .opacity(selectedTab == 0 ? 1 : 0)
 | 
			
		||||
 | 
			
		||||
                        ConceptTab()
 | 
			
		||||
                            .opacity(selectedTab == 1 ? 1 : 0)
 | 
			
		||||
 | 
			
		||||
                        ChatsTab(
 | 
			
		||||
                            loginViewModel: viewModel,
 | 
			
		||||
                            searchRevealProgress: $chatSearchRevealProgress,
 | 
			
		||||
                            searchText: $chatSearchText
 | 
			
		||||
            ZStack(alignment: .top) {
 | 
			
		||||
                ZStack(alignment: .leading) { // Выравниваем ZStack по левому краю
 | 
			
		||||
                    // Основной контент
 | 
			
		||||
                    VStack(spacing: 0) {
 | 
			
		||||
                        TopBarView(
 | 
			
		||||
                            title: tabTitle,
 | 
			
		||||
                            selectedAccount: $selectedAccount,
 | 
			
		||||
                            accounts: accounts,
 | 
			
		||||
                            viewModel: viewModel,
 | 
			
		||||
                            isSettingsPresented: $isSettingsPresented,
 | 
			
		||||
                            isSideMenuPresented: $isSideMenuPresented,
 | 
			
		||||
                            chatSearchRevealProgress: $chatSearchRevealProgress,
 | 
			
		||||
                            chatSearchText: $chatSearchText
 | 
			
		||||
                        )
 | 
			
		||||
                            .opacity(selectedTab == 2 ? 1 : 0)
 | 
			
		||||
                            .allowsHitTesting(selectedTab == 2)
 | 
			
		||||
                        
 | 
			
		||||
                        ProfileTab()
 | 
			
		||||
                            .opacity(selectedTab == 3 ? 1 : 0)
 | 
			
		||||
                    }
 | 
			
		||||
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
 | 
			
		||||
                    
 | 
			
		||||
                    CustomTabBar(selectedTab: $selectedTab) {
 | 
			
		||||
                        print("Create button tapped")
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                .frame(maxWidth: .infinity, maxHeight: .infinity) // Убедимся, что основной контент занимает все пространство
 | 
			
		||||
                .ignoresSafeArea(edges: .bottom)
 | 
			
		||||
                .navigationBarHidden(true)
 | 
			
		||||
//                .sheet(item: $sheetType) { type in
 | 
			
		||||
//                    // ... sheet presentation logic
 | 
			
		||||
//                }
 | 
			
		||||
                
 | 
			
		||||
                // Затемнение и закрытие по тапу
 | 
			
		||||
                Color.black
 | 
			
		||||
                    .opacity(Double(menuOffset / menuWidth) * 0.4)
 | 
			
		||||
                    .ignoresSafeArea()
 | 
			
		||||
                    .onTapGesture {
 | 
			
		||||
                        withAnimation(.easeInOut) {
 | 
			
		||||
                            isSideMenuPresented = false
 | 
			
		||||
                        ZStack {
 | 
			
		||||
                            NewHomeTab()
 | 
			
		||||
                                .opacity(selectedTab == 0 ? 1 : 0)
 | 
			
		||||
 | 
			
		||||
                            ConceptTab()
 | 
			
		||||
                                .opacity(selectedTab == 1 ? 1 : 0)
 | 
			
		||||
 | 
			
		||||
                            ChatsTab(
 | 
			
		||||
                                loginViewModel: viewModel,
 | 
			
		||||
                                searchRevealProgress: $chatSearchRevealProgress,
 | 
			
		||||
                                searchText: $chatSearchText
 | 
			
		||||
                            )
 | 
			
		||||
                                .opacity(selectedTab == 2 ? 1 : 0)
 | 
			
		||||
                                .allowsHitTesting(selectedTab == 2)
 | 
			
		||||
                            
 | 
			
		||||
                            ProfileTab()
 | 
			
		||||
                                .opacity(selectedTab == 3 ? 1 : 0)
 | 
			
		||||
                        }
 | 
			
		||||
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
 | 
			
		||||
                        
 | 
			
		||||
                        CustomTabBar(selectedTab: $selectedTab) {
 | 
			
		||||
                            print("Create button tapped")
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    .allowsHitTesting(menuOffset > 0)
 | 
			
		||||
                    .frame(maxWidth: .infinity, maxHeight: .infinity) // Убедимся, что основной контент занимает все пространство
 | 
			
		||||
                    .ignoresSafeArea(edges: .bottom)
 | 
			
		||||
                    .navigationBarHidden(true)
 | 
			
		||||
//                    .sheet(item: $sheetType) { type in
 | 
			
		||||
//                        // ... sheet presentation logic
 | 
			
		||||
//                    }
 | 
			
		||||
                    
 | 
			
		||||
                    // Затемнение и закрытие по тапу
 | 
			
		||||
                    Color.black
 | 
			
		||||
                        .opacity(Double(menuOffset / menuWidth) * 0.4)
 | 
			
		||||
                        .ignoresSafeArea()
 | 
			
		||||
                        .onTapGesture {
 | 
			
		||||
                            withAnimation(.easeInOut) {
 | 
			
		||||
                                isSideMenuPresented = false
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        .allowsHitTesting(menuOffset > 0)
 | 
			
		||||
 | 
			
		||||
                // Боковое меню
 | 
			
		||||
                SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented)
 | 
			
		||||
                    .frame(width: menuWidth)
 | 
			
		||||
                    .offset(x: -menuWidth + menuOffset) // Новая логика смещения
 | 
			
		||||
                    .ignoresSafeArea(edges: .vertical)
 | 
			
		||||
                    // Боковое меню
 | 
			
		||||
                    SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented)
 | 
			
		||||
                        .frame(width: menuWidth)
 | 
			
		||||
                        .offset(x: -menuWidth + menuOffset) // Новая логика смещения
 | 
			
		||||
                        .ignoresSafeArea(edges: .vertical)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            .gesture(
 | 
			
		||||
                DragGesture()
 | 
			
		||||
 | 
			
		||||
@ -12,22 +12,60 @@ import CoreData
 | 
			
		||||
struct yobbleApp: App {
 | 
			
		||||
    @StateObject private var themeManager = ThemeManager()
 | 
			
		||||
    @StateObject private var viewModel = LoginViewModel()
 | 
			
		||||
    @StateObject private var messageCenter = IncomingMessageCenter()
 | 
			
		||||
    private let persistenceController = PersistenceController.shared
 | 
			
		||||
    
 | 
			
		||||
    var body: some Scene {
 | 
			
		||||
        WindowGroup {
 | 
			
		||||
            Group {
 | 
			
		||||
                if viewModel.isLoading {
 | 
			
		||||
                    SplashScreenView()
 | 
			
		||||
                } else if viewModel.isLoggedIn {
 | 
			
		||||
                    MainView(viewModel: viewModel)
 | 
			
		||||
                } else {
 | 
			
		||||
                    LoginView(viewModel: viewModel)
 | 
			
		||||
            ZStack(alignment: .top) {
 | 
			
		||||
                Group {
 | 
			
		||||
                    if viewModel.isLoading {
 | 
			
		||||
                        SplashScreenView()
 | 
			
		||||
                    } else if viewModel.isLoggedIn {
 | 
			
		||||
                        MainView(viewModel: viewModel)
 | 
			
		||||
                    } else {
 | 
			
		||||
                        LoginView(viewModel: viewModel)
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if let banner = messageCenter.banner {
 | 
			
		||||
                    NewMessageBannerView(
 | 
			
		||||
                        banner: banner,
 | 
			
		||||
                        onOpen: { messageCenter.openCurrentChat() },
 | 
			
		||||
                        onDismiss: { messageCenter.dismissBanner() }
 | 
			
		||||
                    )
 | 
			
		||||
                    .padding(.horizontal, 16)
 | 
			
		||||
                    .padding(.top, 12)
 | 
			
		||||
                    .transition(.move(edge: .top).combined(with: .opacity))
 | 
			
		||||
                    .zIndex(1)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            .animation(.spring(response: 0.35, dampingFraction: 0.8), value: messageCenter.banner != nil)
 | 
			
		||||
            .sheet(item: $messageCenter.presentedChat) { chatItem in
 | 
			
		||||
                NavigationView {
 | 
			
		||||
                    PrivateChatView(
 | 
			
		||||
                        chat: chatItem,
 | 
			
		||||
                        currentUserId: messageCenter.currentUserId
 | 
			
		||||
                    )
 | 
			
		||||
                        .toolbar {
 | 
			
		||||
                            ToolbarItem(placement: .cancellationAction) {
 | 
			
		||||
                                Button(NSLocalizedString("Закрыть", comment: "")) {
 | 
			
		||||
                                    messageCenter.presentedChat = nil
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            .environmentObject(messageCenter)
 | 
			
		||||
            .environmentObject(themeManager)
 | 
			
		||||
            .preferredColorScheme(themeManager.theme.colorScheme)
 | 
			
		||||
            .environment(\.managedObjectContext, persistenceController.viewContext)
 | 
			
		||||
            .onAppear {
 | 
			
		||||
                messageCenter.currentUserId = viewModel.userId.isEmpty ? nil : viewModel.userId
 | 
			
		||||
            }
 | 
			
		||||
            .onChange(of: viewModel.userId) { newValue in
 | 
			
		||||
                messageCenter.currentUserId = newValue.isEmpty ? nil : newValue
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user