Compare commits

...

5 Commits

Author SHA1 Message Date
452ccfacd6 add alert in chat 2025-12-12 02:19:24 +03:00
28d2525e4a drafttext fix 2025-12-12 02:14:25 +03:00
6b7bc45fed patch chat 2025-12-12 02:09:43 +03:00
cef8a13237 down bar 2025-12-12 02:02:35 +03:00
59d2f8c161 ios 15 2025-12-12 01:51:35 +03:00
3 changed files with 251 additions and 102 deletions

View File

@ -598,6 +598,9 @@
},
"Добро пожаловать в Yobble!" : {
},
"Дождитесь отправки предыдущего сообщения." : {
},
"Дополнительные действия." : {
"comment" : "Message profile more action description"
@ -1910,6 +1913,9 @@
},
"Ошибка в данных. Проверьте введённую информацию." : {
},
"Ошибка отправки" : {
},
"Ошибка при деавторизации." : {
"localizations" : {

View File

@ -6,6 +6,7 @@ final class PrivateChatViewModel: ObservableObject {
@Published private(set) var isInitialLoading: Bool = false
@Published private(set) var isLoadingMore: Bool = false
@Published var errorMessage: String?
@Published var sendingErrorMessage: String?
@Published private(set) var isSending: Bool = false
@Published private(set) var hasMore: Bool = true
@ -74,11 +75,12 @@ final class PrivateChatViewModel: ObservableObject {
return
}
guard trimmed.count <= maxMessageLength else {
errorMessage = NSLocalizedString("Сообщение слишком длинное.", comment: "")
sendingErrorMessage = NSLocalizedString("Сообщение слишком длинное.", comment: "")
completion(false)
return
}
guard !isSending else {
sendingErrorMessage = NSLocalizedString("Дождитесь отправки предыдущего сообщения.", comment: "")
completion(false)
return
}
@ -88,6 +90,7 @@ final class PrivateChatViewModel: ObservableObject {
}
isSending = true
sendingErrorMessage = nil
chatService.sendPrivateMessage(chatId: chatId, content: trimmed) { [weak self] result in
guard let self else { return }
@ -113,7 +116,7 @@ final class PrivateChatViewModel: ObservableObject {
self.errorMessage = nil
completion(true)
case .failure(let error):
self.errorMessage = self.message(for: error)
self.sendingErrorMessage = self.message(for: error)
completion(false)
}
@ -213,4 +216,4 @@ final class PrivateChatViewModel: ObservableObject {
return lhs.messageId < rhs.messageId
}
}
}

View File

@ -22,6 +22,7 @@ struct PrivateChatView: View {
@State private var legacyComposerHeight: CGFloat = 40
@State private var isProfilePresented: Bool = false
@FocusState private var isComposerFocused: Bool
@EnvironmentObject private var themeManager: ThemeManager
@EnvironmentObject private var messageCenter: IncomingMessageCenter
@Environment(\.dismiss) private var dismiss
@ -65,6 +66,14 @@ struct PrivateChatView: View {
}
.hidden()
}
.alert("Ошибка отправки", isPresented: Binding(
get: { viewModel.sendingErrorMessage != nil },
set: { if !$0 { viewModel.sendingErrorMessage = nil } }
), actions: {
Button("OK") { }
}, message: {
Text(viewModel.sendingErrorMessage ?? "Не удалось отправить сообщение.")
})
.navigationTitle(toolbarTitle)
.navigationBarTitleDisplayMode(.inline)
// .navigationBarBackButtonHidden(true)
@ -244,104 +253,213 @@ struct PrivateChatView: View {
let topPadding: CGFloat = decoratedMessage.showHorns ? 5 : -12
let verticalPadding: CGFloat = hasDecorations ? 0 : 0
return HStack(alignment: .bottom, spacing: 8) {
if isCurrentUser { Spacer(minLength: 32) }
messageBubble(
for: message,
decorations: decoratedMessage,
isCurrentUser: isCurrentUser
)
if !isCurrentUser { Spacer(minLength: 32) }
}
.padding(.horizontal, 8)
.padding(.top, topPadding)
.padding(.vertical, verticalPadding)
.contextMenu {
if isCurrentUser {
if message.isViewed == true {
Text("Прочитано") // Placeholder for read status
if #available(iOS 16.0, *) {
return HStack(alignment: .bottom, spacing: 8) {
if isCurrentUser { Spacer(minLength: 32) }
messageBubble(
for: message,
decorations: decoratedMessage,
isCurrentUser: isCurrentUser
)
if !isCurrentUser { Spacer(minLength: 32) }
}
.padding(.horizontal, 8)
.padding(.top, topPadding)
.padding(.vertical, verticalPadding)
.contextMenu(menuItems: {
if isCurrentUser {
if message.isViewed == true {
Text("Прочитано") // Placeholder for read status
}
Button(action: {
// Reply action
}) {
Text("Ответить")
Image(systemName: "arrowshape.turn.up.left")
}
Button(action: {
// Copy action
}) {
Text("Копировать")
Image(systemName: "doc.on.doc")
}
Button(action: {
// Edit action
}) {
Text("Изменить")
Image(systemName: "pencil")
}
Button(action: {
// Pin action
}) {
Text("Закрепить")
Image(systemName: "pin")
}
Button(action: {
// Forward action
}) {
Text("Переслать")
Image(systemName: "arrowshape.turn.up.right")
}
Button(role: .destructive, action: {
// Delete action
}) {
Text("Удалить")
Image(systemName: "trash")
}
Button(action: {
// Select action
}) {
Text("Выбрать")
Image(systemName: "checkmark.circle")
}
} else {
Button(action: {
// Reply action
}) {
Text("Ответить")
Image(systemName: "arrowshape.turn.up.left")
}
Button(action: {
// Copy action
}) {
Text("Копировать")
Image(systemName: "doc.on.doc")
}
Button(action: {
// Pin action
}) {
Text("Закрепить")
Image(systemName: "pin")
}
Button(action: {
// Forward action
}) {
Text("Переслать")
Image(systemName: "arrowshape.turn.up.right")
}
Button(role: .destructive, action: {
// Delete action
}) {
Text("Удалить")
Image(systemName: "trash")
}
Button(action: {
// Select action
}) {
Text("Выбрать")
Image(systemName: "checkmark.circle")
}
}
Button(action: {
// Reply action
}) {
Text("Ответить")
Image(systemName: "arrowshape.turn.up.left")
}
Button(action: {
// Copy action
}) {
Text("Копировать")
Image(systemName: "doc.on.doc")
}
Button(action: {
// Edit action
}) {
Text("Изменить")
Image(systemName: "pencil")
}
Button(action: {
// Pin action
}) {
Text("Закрепить")
Image(systemName: "pin")
}
Button(action: {
// Forward action
}) {
Text("Переслать")
Image(systemName: "arrowshape.turn.up.right")
}
Button(role: .destructive, action: {
// Delete action
}) {
Text("Удалить")
Image(systemName: "trash")
}
Button(action: {
// Select action
}) {
Text("Выбрать")
Image(systemName: "checkmark.circle")
}
} else {
Button(action: {
// Reply action
}) {
Text("Ответить")
Image(systemName: "arrowshape.turn.up.left")
}
Button(action: {
// Copy action
}) {
Text("Копировать")
Image(systemName: "doc.on.doc")
}
Button(action: {
// Pin action
}) {
Text("Закрепить")
Image(systemName: "pin")
}
Button(action: {
// Forward action
}) {
Text("Переслать")
Image(systemName: "arrowshape.turn.up.right")
}
Button(role: .destructive, action: {
// Delete action
}) {
Text("Удалить")
Image(systemName: "trash")
}
Button(action: {
// Select action
}) {
Text("Выбрать")
Image(systemName: "checkmark.circle")
}, preview: {
messageBubble(
for: message,
decorations: decoratedMessage,
isCurrentUser: isCurrentUser
)
})
} else {
return HStack(alignment: .bottom, spacing: 8) {
if isCurrentUser { Spacer(minLength: 32) }
messageBubble(
for: message,
decorations: decoratedMessage,
isCurrentUser: isCurrentUser
)
if !isCurrentUser { Spacer(minLength: 32) }
}
.padding(.horizontal, 8)
.padding(.top, topPadding)
.padding(.vertical, verticalPadding)
.contextMenu {
if isCurrentUser {
if message.isViewed == true {
Text("Прочитано") // Placeholder for read status
}
Button(action: {
// Reply action
}) {
Text("Ответить")
Image(systemName: "arrowshape.turn.up.left")
}
Button(action: {
// Copy action
}) {
Text("Копировать")
Image(systemName: "doc.on.doc")
}
Button(action: {
// Edit action
}) {
Text("Изменить")
Image(systemName: "pencil")
}
Button(action: {
// Pin action
}) {
Text("Закрепить")
Image(systemName: "pin")
}
Button(action: {
// Forward action
}) {
Text("Переслать")
Image(systemName: "arrowshape.turn.up.right")
}
Button(role: .destructive, action: {
// Delete action
}) {
Text("Удалить")
Image(systemName: "trash")
}
Button(action: {
// Select action
}) {
Text("Выбрать")
Image(systemName: "checkmark.circle")
}
} else {
Button(action: {
// Reply action
}) {
Text("Ответить")
Image(systemName: "arrowshape.turn.up.left")
}
Button(action: {
// Copy action
}) {
Text("Копировать")
Image(systemName: "doc.on.doc")
}
Button(action: {
// Pin action
}) {
Text("Закрепить")
Image(systemName: "pin")
}
Button(action: {
// Forward action
}) {
Text("Переслать")
Image(systemName: "arrowshape.turn.up.right")
}
Button(role: .destructive, action: {
// Delete action
}) {
Text("Удалить")
Image(systemName: "trash")
}
Button(action: {
// Select action
}) {
Text("Выбрать")
Image(systemName: "checkmark.circle")
}
}
}
}
@ -538,7 +656,7 @@ struct PrivateChatView: View {
.padding(.horizontal, 6)
.padding(.top, 10)
.padding(.bottom, 8)
.background(.ultraThinMaterial)
.modifier(ComposerBackgroundModifier(theme: themeManager.theme))
}
private func scrollToBottomButton(proxy: ScrollViewProxy) -> some View {
@ -613,10 +731,10 @@ struct PrivateChatView: View {
let text = draftText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
draftText = ""
scrollToBottomTrigger = .init()
viewModel.sendMessage(text: text) { success in
if success {
draftText = ""
hasPositionedToBottom = true
}
}
@ -912,6 +1030,28 @@ private var headerPlaceholderAvatar: some View {
}
private struct ComposerBackgroundModifier: ViewModifier {
let theme: Theme
@ViewBuilder
func body(content: Content) -> some View {
if theme == .oledDark {
content
.background(Color.black.opacity(0.85)) // глубокий чёрный
// .overlay(
// Color.black.opacity(0.25) // лёгкое затемнение сверху
// .allowsHitTesting(false)
// )
// content.background(.ultraThinMaterial)
// content.background(Color.black)
// content.background(Color(white: 0.15))
} else {
content.background(.ultraThinMaterial)
}
}
}
/// Helper model that stores a message alongside horn/leg flags for grouping sequences.
private struct DecoratedMessage: Identifiable {
let message: MessageItem