Исправление расширения поля пароля при переключении видимости: перенос toggle в UIKit
This commit is contained in:
@@ -5,11 +5,18 @@ struct ChatDetailView: View {
|
||||
var onPresentedChange: ((Bool) -> Void)? = nil
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject private var messageRepository = MessageRepository.shared
|
||||
@StateObject private var viewModel: ChatDetailViewModel
|
||||
|
||||
init(route: ChatRoute, onPresentedChange: ((Bool) -> Void)? = nil) {
|
||||
self.route = route
|
||||
self.onPresentedChange = onPresentedChange
|
||||
_viewModel = StateObject(wrappedValue: ChatDetailViewModel(dialogKey: route.publicKey))
|
||||
}
|
||||
|
||||
@State private var messageText = ""
|
||||
@State private var sendError: String?
|
||||
@State private var isViewActive = false
|
||||
// markReadTask removed — read receipts no longer sent from .onChange(of: messages.count)
|
||||
@FocusState private var isInputFocused: Bool
|
||||
|
||||
private var currentPublicKey: String {
|
||||
@@ -21,11 +28,11 @@ struct ChatDetailView: View {
|
||||
}
|
||||
|
||||
private var messages: [ChatMessage] {
|
||||
messageRepository.messages(for: route.publicKey)
|
||||
viewModel.messages
|
||||
}
|
||||
|
||||
private var isTyping: Bool {
|
||||
messageRepository.isTyping(dialogKey: route.publicKey)
|
||||
viewModel.isTyping
|
||||
}
|
||||
|
||||
private var titleText: String {
|
||||
@@ -45,7 +52,6 @@ struct ChatDetailView: View {
|
||||
|
||||
private var subtitleText: String {
|
||||
if route.isSavedMessages { return "" }
|
||||
if ProtocolManager.shared.connectionState != .authenticated { return "connecting..." }
|
||||
if isTyping { return "typing..." }
|
||||
if let dialog, dialog.isOnline { return "online" }
|
||||
return "offline"
|
||||
@@ -74,9 +80,7 @@ struct ChatDetailView: View {
|
||||
private var sendButtonWidth: CGFloat { 38 }
|
||||
private var sendButtonHeight: CGFloat { 36 }
|
||||
|
||||
private var composerTrailingPadding: CGFloat {
|
||||
isInputFocused ? 16 : 28
|
||||
}
|
||||
private var composerTrailingPadding: CGFloat { 16 }
|
||||
|
||||
private var composerAnimation: Animation {
|
||||
.spring(response: 0.28, dampingFraction: 0.9)
|
||||
@@ -125,7 +129,7 @@ struct ChatDetailView: View {
|
||||
}
|
||||
.onDisappear {
|
||||
isViewActive = false
|
||||
messageRepository.setDialogActive(route.publicKey, isActive: false)
|
||||
MessageRepository.shared.setDialogActive(route.publicKey, isActive: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,20 +323,25 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached tiled pattern color — computed once, reused across renders
|
||||
private static let cachedTiledColor: Color? = {
|
||||
guard let uiImage = UIImage(named: "ChatBackground"),
|
||||
let cgImage = uiImage.cgImage else { return nil }
|
||||
let tileWidth: CGFloat = 200
|
||||
let scaleFactor = uiImage.size.width / tileWidth
|
||||
let scaledImage = UIImage(
|
||||
cgImage: cgImage,
|
||||
scale: uiImage.scale * scaleFactor,
|
||||
orientation: .up
|
||||
)
|
||||
return Color(uiColor: UIColor(patternImage: scaledImage))
|
||||
}()
|
||||
|
||||
/// Tiled chat background with properly scaled tiles (200pt wide)
|
||||
private var tiledChatBackground: some View {
|
||||
Group {
|
||||
if let uiImage = UIImage(named: "ChatBackground"),
|
||||
let cgImage = uiImage.cgImage {
|
||||
let tileWidth: CGFloat = 200
|
||||
let scaleFactor = uiImage.size.width / tileWidth
|
||||
let scaledImage = UIImage(
|
||||
cgImage: cgImage,
|
||||
scale: uiImage.scale * scaleFactor,
|
||||
orientation: .up
|
||||
)
|
||||
Color(uiColor: UIColor(patternImage: scaledImage))
|
||||
.opacity(0.18)
|
||||
if let color = Self.cachedTiledColor {
|
||||
color.opacity(0.18)
|
||||
} else {
|
||||
Color.clear
|
||||
}
|
||||
@@ -389,7 +398,8 @@ private extension ChatDetailView {
|
||||
ScrollViewReader { proxy in
|
||||
let scroll = ScrollView(.vertical, showsIndicators: false) {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in
|
||||
ForEach(messages.indices, id: \.self) { index in
|
||||
let message = messages[index]
|
||||
messageRow(
|
||||
message,
|
||||
maxBubbleWidth: maxBubbleWidth,
|
||||
@@ -406,7 +416,7 @@ private extension ChatDetailView {
|
||||
.padding(.top, messagesTopInset)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.onTapGesture { isInputFocused = false }
|
||||
.onAppear {
|
||||
DispatchQueue.main.async { scrollToBottom(proxy: proxy, animated: false) }
|
||||
@@ -420,16 +430,19 @@ private extension ChatDetailView {
|
||||
}
|
||||
.onChange(of: messages.count) { _, _ in
|
||||
scrollToBottom(proxy: proxy, animated: true)
|
||||
if isViewActive {
|
||||
markDialogAsRead()
|
||||
}
|
||||
// Read receipts are NOT sent here — SessionManager already sends
|
||||
// 0x07 for each incoming message when dialog is active (shouldMarkRead).
|
||||
// Sending again from .onChange caused duplicate packets (2-3× more than
|
||||
// desktop), which may contribute to server RST disconnects.
|
||||
// The initial read is handled in .task with 600ms delay.
|
||||
}
|
||||
.onChange(of: isInputFocused) { _, focused in
|
||||
guard focused else { return }
|
||||
// User tapped the input — reset idle timer.
|
||||
SessionManager.shared.recordUserInteraction()
|
||||
// Delay matches keyboard animation (~250ms) so scroll happens after layout settles.
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 80_000_000)
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
scrollToBottom(proxy: proxy, animated: true)
|
||||
}
|
||||
}
|
||||
@@ -469,7 +482,13 @@ private extension ChatDetailView {
|
||||
: RosettaColors.Adaptive.textSecondary.opacity(0.6)
|
||||
)
|
||||
|
||||
if outgoing { deliveryIndicator(message.deliveryStatus) }
|
||||
if outgoing {
|
||||
if message.deliveryStatus == .error {
|
||||
errorMenu(for: message)
|
||||
} else {
|
||||
deliveryIndicator(message.deliveryStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 11)
|
||||
.padding(.bottom, 5)
|
||||
@@ -545,50 +564,11 @@ private extension ChatDetailView {
|
||||
.frame(height: 36, alignment: .center)
|
||||
.overlay(alignment: .trailing) {
|
||||
Button(action: sendCurrentMessage) {
|
||||
ZStack {
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.sendPlane,
|
||||
viewBox: CGSize(width: 22, height: 19),
|
||||
color: .white
|
||||
)
|
||||
.blendMode(.difference)
|
||||
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.sendPlane,
|
||||
viewBox: CGSize(width: 22, height: 19),
|
||||
color: .white
|
||||
)
|
||||
.blendMode(.saturation)
|
||||
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.sendPlane,
|
||||
viewBox: CGSize(width: 22, height: 19),
|
||||
color: .white
|
||||
)
|
||||
.blendMode(.overlay)
|
||||
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.sendPlane,
|
||||
viewBox: CGSize(width: 22, height: 19),
|
||||
color: .black
|
||||
)
|
||||
.blendMode(.overlay)
|
||||
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.sendPlane,
|
||||
viewBox: CGSize(width: 22, height: 19),
|
||||
color: .white
|
||||
)
|
||||
.blendMode(.overlay)
|
||||
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.sendPlane,
|
||||
viewBox: CGSize(width: 22, height: 19),
|
||||
color: .black
|
||||
)
|
||||
.blendMode(.overlay)
|
||||
}
|
||||
.compositingGroup()
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.sendPlane,
|
||||
viewBox: CGSize(width: 22, height: 19),
|
||||
color: .white
|
||||
)
|
||||
.opacity(0.42 + (0.58 * sendButtonProgress))
|
||||
.scaleEffect(0.72 + (0.28 * sendButtonProgress))
|
||||
.frame(width: 22, height: 19)
|
||||
@@ -644,10 +624,9 @@ private extension ChatDetailView {
|
||||
.padding(.leading, 16)
|
||||
.padding(.trailing, composerTrailingPadding)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, isInputFocused ? 8 : 0)
|
||||
.padding(.bottom, 4)
|
||||
.animation(composerAnimation, value: canSend)
|
||||
.animation(composerAnimation, value: shouldShowSendButton)
|
||||
.animation(composerAnimation, value: isInputFocused)
|
||||
}
|
||||
.background {
|
||||
if #available(iOS 26, *) {
|
||||
@@ -796,6 +775,41 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func errorMenu(for message: ChatMessage) -> some View {
|
||||
Menu {
|
||||
Button {
|
||||
retryMessage(message)
|
||||
} label: {
|
||||
Label("Retry", systemImage: "arrow.clockwise")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
removeMessage(message)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
}
|
||||
}
|
||||
|
||||
func retryMessage(_ message: ChatMessage) {
|
||||
let text = message.text
|
||||
let toKey = message.toPublicKey
|
||||
MessageRepository.shared.deleteMessage(id: message.id)
|
||||
DialogRepository.shared.reconcileAfterMessageDelete(opponentKey: toKey)
|
||||
Task {
|
||||
try? await SessionManager.shared.sendMessage(text: text, toPublicKey: toKey)
|
||||
}
|
||||
}
|
||||
|
||||
func removeMessage(_ message: ChatMessage) {
|
||||
MessageRepository.shared.deleteMessage(id: message.id)
|
||||
DialogRepository.shared.reconcileAfterMessageDelete(opponentKey: message.toPublicKey)
|
||||
}
|
||||
|
||||
func messageTime(_ timestamp: Int64) -> String {
|
||||
Self.timeFormatter.string(from: Date(timeIntervalSince1970: Double(timestamp) / 1000))
|
||||
}
|
||||
@@ -829,12 +843,12 @@ private extension ChatDetailView {
|
||||
myPublicKey: currentPublicKey
|
||||
)
|
||||
}
|
||||
messageRepository.setDialogActive(route.publicKey, isActive: true)
|
||||
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
|
||||
}
|
||||
|
||||
func markDialogAsRead() {
|
||||
DialogRepository.shared.markAsRead(opponentKey: route.publicKey)
|
||||
messageRepository.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey)
|
||||
MessageRepository.shared.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey)
|
||||
SessionManager.shared.sendReadReceipt(toPublicKey: route.publicKey)
|
||||
}
|
||||
|
||||
|
||||
65
Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift
Normal file
65
Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Per-dialog observation isolation for ChatDetailView.
|
||||
///
|
||||
/// Instead of `@ObservedObject messageRepository` (which re-renders on ANY dialog change),
|
||||
/// this ViewModel subscribes only to the specific dialog's messages via Combine pipeline
|
||||
/// with `removeDuplicates()`. The view re-renders ONLY when its own dialog's data changes.
|
||||
@MainActor
|
||||
final class ChatDetailViewModel: ObservableObject {
|
||||
let dialogKey: String
|
||||
|
||||
@Published private(set) var messages: [ChatMessage] = []
|
||||
@Published private(set) var isTyping: Bool = false
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(dialogKey: String) {
|
||||
self.dialogKey = dialogKey
|
||||
|
||||
let repo = MessageRepository.shared
|
||||
|
||||
// Seed with current values
|
||||
messages = repo.messages(for: dialogKey)
|
||||
isTyping = repo.isTyping(dialogKey: dialogKey)
|
||||
|
||||
// Subscribe to messagesByDialog changes, filtered to our dialog only.
|
||||
// Broken into steps to help the Swift type-checker.
|
||||
let key = dialogKey
|
||||
let messagesPublisher = repo.$messagesByDialog
|
||||
.map { (dict: [String: [ChatMessage]]) -> [ChatMessage] in
|
||||
dict[key] ?? []
|
||||
}
|
||||
.removeDuplicates { (lhs: [ChatMessage], rhs: [ChatMessage]) -> Bool in
|
||||
guard lhs.count == rhs.count else { return false }
|
||||
for i in lhs.indices {
|
||||
if lhs[i].id != rhs[i].id || lhs[i].deliveryStatus != rhs[i].deliveryStatus {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
||||
messagesPublisher
|
||||
.sink { [weak self] newMessages in
|
||||
self?.messages = newMessages
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Subscribe to typing state changes, filtered to our dialog
|
||||
let typingPublisher = repo.$typingDialogs
|
||||
.map { (dialogs: Set<String>) -> Bool in
|
||||
dialogs.contains(key)
|
||||
}
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
||||
typingPublisher
|
||||
.sink { [weak self] typing in
|
||||
self?.isTyping = typing
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,11 @@ private extension ChatListSearchContent {
|
||||
|
||||
ForEach(viewModel.recentSearches, id: \.publicKey) { recent in
|
||||
recentRow(recent)
|
||||
if recent.publicKey != viewModel.recentSearches.last?.publicKey {
|
||||
Divider()
|
||||
.padding(.leading, 68)
|
||||
.foregroundStyle(RosettaColors.Adaptive.divider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +184,7 @@ private extension ChatListSearchContent {
|
||||
let colorIdx = RosettaColors.avatarColorIndex(for: user.title, publicKey: user.publicKey)
|
||||
|
||||
return Button {
|
||||
onSelectRecent(user.username.isEmpty ? user.publicKey : user.username)
|
||||
onOpenDialog(ChatRoute(recent: user))
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
AvatarView(
|
||||
@@ -207,6 +212,7 @@ private extension ChatListSearchContent {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 5)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -263,6 +269,7 @@ private extension ChatListSearchContent {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@@ -32,16 +32,13 @@ struct ChatListView: View {
|
||||
@State private var hasPinnedChats = false
|
||||
@FocusState private var isSearchFocused: Bool
|
||||
|
||||
@MainActor static var _bodyCount = 0
|
||||
var body: some View {
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("🟡 ChatListView.body #\(Self._bodyCount)")
|
||||
NavigationStack(path: $navigationState.path) {
|
||||
VStack(spacing: 0) {
|
||||
// Custom search bar
|
||||
customSearchBar
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 6)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 8)
|
||||
.background(
|
||||
(hasPinnedChats && !isSearchActive
|
||||
@@ -290,7 +287,7 @@ private extension ChatListView {
|
||||
Text("Edit")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.frame(height: 44)
|
||||
.frame(height: 40)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -312,7 +309,7 @@ private extension ChatListView {
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 22, height: 22)
|
||||
.frame(width: 44, height: 44)
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Add chat")
|
||||
@@ -323,7 +320,7 @@ private extension ChatListView {
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20)
|
||||
.frame(width: 44, height: 44)
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("New chat")
|
||||
@@ -377,10 +374,7 @@ private struct ToolbarTitleView: View {
|
||||
/// Changes to these `@Observable` singletons only re-render this small view,
|
||||
/// not the parent ChatListView / NavigationStack.
|
||||
private struct ToolbarStoriesAvatar: View {
|
||||
@MainActor static var _bodyCount = 0
|
||||
var body: some View {
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("🟣 ToolbarStoriesAvatar.body #\(Self._bodyCount)")
|
||||
let pk = AccountManager.shared.currentAccount?.publicKey ?? ""
|
||||
let initials = RosettaColors.initials(
|
||||
name: SessionManager.shared.displayName, publicKey: pk
|
||||
@@ -396,10 +390,7 @@ private struct ToolbarStoriesAvatar: View {
|
||||
/// During handshake, `connectionState` changes 4+ times rapidly — this view
|
||||
/// absorbs those re-renders instead of cascading them to the NavigationStack.
|
||||
private struct DeviceVerificationBannersContainer: View {
|
||||
@MainActor static var _bodyCount = 0
|
||||
var body: some View {
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("⚪ DeviceVerificationBanners.body #\(Self._bodyCount)")
|
||||
let proto = ProtocolManager.shared
|
||||
|
||||
if proto.connectionState == .deviceVerificationRequired {
|
||||
@@ -424,11 +415,7 @@ private struct ChatListDialogContent: View {
|
||||
@ObservedObject var viewModel: ChatListViewModel
|
||||
@ObservedObject var navigationState: ChatListNavigationState
|
||||
var onPinnedStateChange: (Bool) -> Void = { _ in }
|
||||
@MainActor static var _bodyCount = 0
|
||||
|
||||
var body: some View {
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("🔶 ChatListDialogContent.body #\(Self._bodyCount)")
|
||||
let hasPinned = !viewModel.pinnedDialogs.isEmpty
|
||||
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
|
||||
ChatEmptyStateView(searchText: "")
|
||||
|
||||
@@ -46,8 +46,8 @@ final class ChatListViewModel: ObservableObject {
|
||||
var unpinnedDialogs: [Dialog] { filteredDialogs.filter { !$0.isPinned } }
|
||||
|
||||
var totalUnreadCount: Int {
|
||||
DialogRepository.shared.sortedDialogs
|
||||
.filter { !$0.isMuted }
|
||||
DialogRepository.shared.dialogs.values
|
||||
.lazy.filter { !$0.isMuted }
|
||||
.reduce(0) { $0 + $1.unreadCount }
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ final class ChatListViewModel: ObservableObject {
|
||||
|
||||
var packet = PacketSearch()
|
||||
packet.privateKey = hash
|
||||
packet.search = query
|
||||
packet.search = query.lowercased()
|
||||
Self.logger.debug("📤 Sending search packet for '\(query)'")
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
// MARK: - ChatRowView
|
||||
|
||||
@@ -20,6 +21,11 @@ import SwiftUI
|
||||
struct ChatRowView: View {
|
||||
let dialog: Dialog
|
||||
|
||||
/// Desktop parity: recheck delivery timeout every 40s so clock → error
|
||||
/// transitions happen automatically without user scrolling.
|
||||
@State private var now = Date()
|
||||
private let recheckTimer = Timer.publish(every: 40, on: .main, in: .common).autoconnect()
|
||||
|
||||
var displayTitle: String {
|
||||
if dialog.isSavedMessages { return "Saved Messages" }
|
||||
if !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
|
||||
@@ -38,6 +44,7 @@ struct ChatRowView: View {
|
||||
.padding(.trailing, 16)
|
||||
.frame(height: 78)
|
||||
.contentShape(Rectangle())
|
||||
.onReceive(recheckTimer) { now = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +222,7 @@ private extension ChatRowView {
|
||||
private var isWithinWaitingWindow: Bool {
|
||||
guard dialog.lastMessageTimestamp > 0 else { return true }
|
||||
let sentDate = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000)
|
||||
return Date().timeIntervalSince(sentDate) < Self.maxWaitingSeconds
|
||||
return now.timeIntervalSince(sentDate) < Self.maxWaitingSeconds
|
||||
}
|
||||
|
||||
var unreadBadge: some View {
|
||||
@@ -244,6 +251,16 @@ private extension ChatRowView {
|
||||
// MARK: - Time Formatting
|
||||
|
||||
private extension ChatRowView {
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "h:mm a"; return f
|
||||
}()
|
||||
private static let dayFormatter: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "EEE"; return f
|
||||
}()
|
||||
private static let dateFormatter: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "dd.MM.yy"; return f
|
||||
}()
|
||||
|
||||
var formattedTime: String {
|
||||
guard dialog.lastMessageTimestamp > 0 else { return "" }
|
||||
|
||||
@@ -252,19 +269,13 @@ private extension ChatRowView {
|
||||
let calendar = Calendar.current
|
||||
|
||||
if calendar.isDateInToday(date) {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "h:mm a"
|
||||
return f.string(from: date)
|
||||
return Self.timeFormatter.string(from: date)
|
||||
} else if calendar.isDateInYesterday(date) {
|
||||
return "Yesterday"
|
||||
} else if let days = calendar.dateComponents([.day], from: date, to: now).day, days < 7 {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "EEE"
|
||||
return f.string(from: date)
|
||||
return Self.dayFormatter.string(from: date)
|
||||
} else {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "dd.MM.yy"
|
||||
return f.string(from: date)
|
||||
return Self.dateFormatter.string(from: date)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ private extension SearchResultsSection {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 5)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@@ -280,6 +280,7 @@ private struct RecentSection: View {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 5)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ final class SearchViewModel: ObservableObject {
|
||||
|
||||
var packet = PacketSearch()
|
||||
packet.privateKey = hash
|
||||
packet.search = currentQuery
|
||||
packet.search = currentQuery.lowercased()
|
||||
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user