notification from socket
This commit is contained in:
parent
cc32d7acad
commit
a6f399bb08
@ -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" : {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -10,8 +10,16 @@ import SwiftUI
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct ChatDeepLink: Identifiable {
|
||||
let id = UUID()
|
||||
let chatId: String
|
||||
let chatProfile: ChatProfile?
|
||||
let message: MessageItem?
|
||||
}
|
||||
|
||||
struct ChatsTab: View {
|
||||
@ObservedObject private var loginViewModel: LoginViewModel
|
||||
@Binding private var pendingDeepLink: ChatDeepLink?
|
||||
@Binding var searchRevealProgress: CGFloat
|
||||
@Binding var searchText: String
|
||||
private let searchService = SearchService()
|
||||
@ -38,8 +46,14 @@ struct ChatsTab: View {
|
||||
return userId.isEmpty ? nil : userId
|
||||
}
|
||||
|
||||
init(loginViewModel: LoginViewModel, searchRevealProgress: Binding<CGFloat>, searchText: Binding<String>) {
|
||||
init(
|
||||
loginViewModel: LoginViewModel,
|
||||
pendingDeepLink: Binding<ChatDeepLink?>,
|
||||
searchRevealProgress: Binding<CGFloat>,
|
||||
searchText: Binding<String>
|
||||
) {
|
||||
self._loginViewModel = ObservedObject(wrappedValue: loginViewModel)
|
||||
self._pendingDeepLink = pendingDeepLink
|
||||
self._searchRevealProgress = searchRevealProgress
|
||||
self._searchText = searchText
|
||||
}
|
||||
@ -52,9 +66,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
|
||||
@ -87,6 +107,13 @@ struct ChatsTab: View {
|
||||
globalSearchTask?.cancel()
|
||||
globalSearchTask = nil
|
||||
}
|
||||
.onChange(of: pendingDeepLink?.id) { _ in
|
||||
guard let link = pendingDeepLink else { return }
|
||||
handle(chatDeepLink: link)
|
||||
DispatchQueue.main.async {
|
||||
pendingDeepLink = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@ -116,11 +143,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)
|
||||
}
|
||||
@ -532,6 +559,44 @@ private extension ChatsTab {
|
||||
#endif
|
||||
}
|
||||
|
||||
func handle(chatDeepLink: ChatDeepLink) {
|
||||
dismissKeyboard()
|
||||
if !searchText.isEmpty {
|
||||
searchText = ""
|
||||
}
|
||||
if searchRevealProgress > 0 {
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
|
||||
searchRevealProgress = 0
|
||||
}
|
||||
}
|
||||
|
||||
let existingChat = viewModel.chats.first(where: { $0.chatId == chatDeepLink.chatId })
|
||||
pendingChatItem = existingChat ?? makeChatItem(from: chatDeepLink)
|
||||
selectedChatId = chatDeepLink.chatId
|
||||
isPendingChatActive = true
|
||||
|
||||
if existingChat == nil {
|
||||
if loginViewModel.chatLoadingState != .loading {
|
||||
loginViewModel.chatLoadingState = .loading
|
||||
}
|
||||
viewModel.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func makeChatItem(from deepLink: ChatDeepLink) -> PrivateChatListItem {
|
||||
let profile = deepLink.chatProfile ?? deepLink.message?.senderData
|
||||
let lastMessage = deepLink.message
|
||||
let createdAt = deepLink.message?.createdAt
|
||||
return PrivateChatListItem(
|
||||
chatId: deepLink.chatId,
|
||||
chatType: .privateChat,
|
||||
chatData: profile,
|
||||
lastMessage: lastMessage,
|
||||
createdAt: createdAt,
|
||||
unreadCount: 0
|
||||
)
|
||||
}
|
||||
|
||||
func handleSearchQueryChange(_ query: String) {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@ -1151,10 +1216,12 @@ struct ChatsTab_Previews: PreviewProvider {
|
||||
@State private var progress: CGFloat = 1
|
||||
@State private var searchText: String = ""
|
||||
@StateObject private var loginViewModel = LoginViewModel()
|
||||
@State private var deepLink: ChatDeepLink?
|
||||
|
||||
var body: some View {
|
||||
ChatsTab(
|
||||
loginViewModel: loginViewModel,
|
||||
pendingDeepLink: $deepLink,
|
||||
searchRevealProgress: $progress,
|
||||
searchText: $searchText
|
||||
)
|
||||
|
||||
@ -15,6 +15,9 @@ struct MainView: View {
|
||||
@State private var menuOffset: CGFloat = 0
|
||||
@State private var chatSearchRevealProgress: CGFloat = 0
|
||||
@State private var chatSearchText: String = ""
|
||||
@State private var pendingChatDeepLink: ChatDeepLink?
|
||||
@State private var incomingMessageBanner: IncomingMessageBanner?
|
||||
@State private var bannerDismissWorkItem: DispatchWorkItem?
|
||||
|
||||
private var tabTitle: String {
|
||||
switch selectedTab {
|
||||
@ -32,66 +35,82 @@ 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,
|
||||
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,
|
||||
pendingDeepLink: $pendingChatDeepLink,
|
||||
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)
|
||||
}
|
||||
|
||||
if let banner = incomingMessageBanner {
|
||||
NewMessageBannerView(
|
||||
senderName: banner.senderName,
|
||||
messagePreview: banner.messagePreview,
|
||||
onOpen: { openChat(from: banner) },
|
||||
onDismiss: { dismissIncomingBanner() }
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
DragGesture()
|
||||
@ -133,6 +152,10 @@ struct MainView: View {
|
||||
menuOffset = presented ? menuWidth : 0
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .socketDidReceivePrivateMessage)) { notification in
|
||||
guard let message = notification.object as? MessageItem else { return }
|
||||
handleIncomingMessage(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -143,3 +166,135 @@ struct MainView_Previews: PreviewProvider {
|
||||
.environmentObject(ThemeManager())
|
||||
}
|
||||
}
|
||||
|
||||
private extension MainView {
|
||||
func handleIncomingMessage(_ message: MessageItem) {
|
||||
guard message.senderId != viewModel.userId else { return }
|
||||
|
||||
let banner = IncomingMessageBanner(
|
||||
message: message,
|
||||
senderName: senderDisplayName(for: message),
|
||||
messagePreview: messagePreview(for: message)
|
||||
)
|
||||
|
||||
bannerDismissWorkItem?.cancel()
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
|
||||
incomingMessageBanner = banner
|
||||
}
|
||||
|
||||
let workItem = DispatchWorkItem {
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
|
||||
incomingMessageBanner = nil
|
||||
}
|
||||
bannerDismissWorkItem = nil
|
||||
}
|
||||
bannerDismissWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: workItem)
|
||||
}
|
||||
|
||||
func dismissIncomingBanner() {
|
||||
bannerDismissWorkItem?.cancel()
|
||||
bannerDismissWorkItem = nil
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
|
||||
incomingMessageBanner = nil
|
||||
}
|
||||
}
|
||||
|
||||
func openChat(from banner: IncomingMessageBanner) {
|
||||
dismissIncomingBanner()
|
||||
pendingChatDeepLink = ChatDeepLink(
|
||||
chatId: banner.message.chatId,
|
||||
chatProfile: banner.message.senderData,
|
||||
message: banner.message
|
||||
)
|
||||
withAnimation(.easeInOut) {
|
||||
selectedTab = 2
|
||||
isSideMenuPresented = false
|
||||
menuOffset = 0
|
||||
}
|
||||
}
|
||||
|
||||
func senderDisplayName(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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct IncomingMessageBanner: Identifiable {
|
||||
let id = UUID()
|
||||
let message: MessageItem
|
||||
let senderName: String
|
||||
let messagePreview: String
|
||||
}
|
||||
|
||||
struct NewMessageBannerView: View {
|
||||
let senderName: String
|
||||
let messagePreview: String
|
||||
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(senderName)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user