Онлайн-статусы, исправление навигации и UI чатов
- Реализован PacketOnlineSubscribe (0x04) для подписки на статус собеседника - Онлайн-статус загружается из результатов поиска (PacketSearch) при каждом хэндшейке - Toolbar capsule показывает online/offline/typing вместо @username - Зелёная точка онлайн-индикатора на аватаре в списке чатов (bottom-left, как в Android) - Убрана точка с аватара в toolbar (статус отображается текстом) - Исправлен баг двойного тапа при входе в чат (программная навигация вместо NavigationLink) - DialogRepository.updateUserInfo теперь принимает и сохраняет online-статус - Очистка requestedUserInfoKeys при реконнекте для обновления статусов - Добавлено логирование результатов поиска и отправки пакетов Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -29,38 +29,25 @@ struct ChatDetailView: View {
|
||||
}
|
||||
|
||||
private var titleText: String {
|
||||
if route.isSavedMessages {
|
||||
return "Saved Messages"
|
||||
}
|
||||
if let dialog, !dialog.opponentTitle.isEmpty {
|
||||
return dialog.opponentTitle
|
||||
}
|
||||
if !route.title.isEmpty {
|
||||
return route.title
|
||||
}
|
||||
if let dialog, !dialog.opponentUsername.isEmpty {
|
||||
return "@\(dialog.opponentUsername)"
|
||||
}
|
||||
if !route.username.isEmpty {
|
||||
return "@\(route.username)"
|
||||
}
|
||||
if route.isSavedMessages { return "Saved Messages" }
|
||||
if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
|
||||
if !route.title.isEmpty { return route.title }
|
||||
if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
|
||||
if !route.username.isEmpty { return "@\(route.username)" }
|
||||
return String(route.publicKey.prefix(12))
|
||||
}
|
||||
|
||||
private var effectiveVerified: Int {
|
||||
if let dialog { return dialog.effectiveVerified }
|
||||
if route.verified > 0 { return route.verified }
|
||||
return 0
|
||||
}
|
||||
|
||||
private var subtitleText: String {
|
||||
if isTyping {
|
||||
return "typing..."
|
||||
}
|
||||
if let dialog, dialog.isOnline {
|
||||
return "online"
|
||||
}
|
||||
if let dialog, !dialog.opponentUsername.isEmpty {
|
||||
return "@\(dialog.opponentUsername)"
|
||||
}
|
||||
if !route.username.isEmpty {
|
||||
return "@\(route.username)"
|
||||
}
|
||||
return String(route.publicKey.prefix(12))
|
||||
if route.isSavedMessages { return "" }
|
||||
if isTyping { return "typing..." }
|
||||
if let dialog, dialog.isOnline { return "online" }
|
||||
return "offline"
|
||||
}
|
||||
|
||||
private var trimmedMessage: String {
|
||||
@@ -86,7 +73,7 @@ struct ChatDetailView: View {
|
||||
private var sendButtonWidth: CGFloat { 38 }
|
||||
private var sendButtonHeight: CGFloat { 36 }
|
||||
|
||||
private var composerHorizontalPadding: CGFloat {
|
||||
private var composerTrailingPadding: CGFloat {
|
||||
isInputFocused ? 16 : 28
|
||||
}
|
||||
|
||||
@@ -98,47 +85,121 @@ struct ChatDetailView: View {
|
||||
|
||||
private static let scrollBottomAnchorId = "chat_detail_bottom_anchor"
|
||||
|
||||
var body: some View {
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
RosettaColors.Adaptive.background
|
||||
RosettaColors.Adaptive.background.ignoresSafeArea()
|
||||
|
||||
tiledChatBackground
|
||||
.ignoresSafeArea()
|
||||
|
||||
messagesList(maxBubbleWidth: max(min(geometry.size.width * 0.72, 380), 140))
|
||||
}
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
chatHeaderContainer
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
composer
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) { composer }
|
||||
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.navigationBarBackButtonHidden(true) // скрываем стандартный back, но НЕ навбар
|
||||
.enableSwipeBack()
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.applyGlassNavBar()
|
||||
.toolbar { chatDetailToolbar } // твой header тут
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
.onAppear {
|
||||
onPresentedChange?(true)
|
||||
.task {
|
||||
// Request user info (non-mutating, won't trigger list rebuild)
|
||||
requestUserInfoIfNeeded()
|
||||
// Delay ALL dialog mutations to let navigation transition complete.
|
||||
// Without this, DialogRepository update rebuilds ChatListView's ForEach
|
||||
// mid-navigation, recreating the NavigationLink and canceling the push.
|
||||
try? await Task.sleep(for: .milliseconds(600))
|
||||
activateDialog()
|
||||
markDialogAsRead()
|
||||
// Subscribe to opponent's online status (Android parity) — only after settled
|
||||
SessionManager.shared.subscribeToOnlineStatus(publicKey: route.publicKey)
|
||||
}
|
||||
.onDisappear {
|
||||
onPresentedChange?(false)
|
||||
messageRepository.setDialogActive(route.publicKey, isActive: false)
|
||||
}
|
||||
.onChange(of: messageText) { _, newValue in
|
||||
if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View { content }
|
||||
}
|
||||
|
||||
private extension ChatDetailView {
|
||||
var avatarInitials: String {
|
||||
if route.isSavedMessages {
|
||||
return "S"
|
||||
// MARK: - Toolbar (как в ChatListView)
|
||||
|
||||
@ToolbarContentBuilder
|
||||
var chatDetailToolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button { dismiss() } label: { backCircleButtonLabel }
|
||||
.frame(width: 36, height: 36)
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||
.accessibilityLabel("Back")
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .principal) {
|
||||
Button { dismiss() } label: {
|
||||
VStack(spacing: 1) {
|
||||
HStack(spacing: 3) {
|
||||
Text(titleText)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
|
||||
if !route.isSavedMessages && effectiveVerified > 0 {
|
||||
VerifiedBadge(verified: effectiveVerified, size: 12)
|
||||
}
|
||||
}
|
||||
|
||||
Text(subtitleText)
|
||||
.font(.system(size: 11, weight: .regular))
|
||||
.foregroundStyle(
|
||||
isTyping || (dialog?.isOnline == true)
|
||||
? RosettaColors.online
|
||||
: RosettaColors.Adaptive.textSecondary
|
||||
)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 44)
|
||||
.background {
|
||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
AvatarView(
|
||||
initials: avatarInitials,
|
||||
colorIndex: avatarColorIndex,
|
||||
size: 35,
|
||||
isOnline: false,
|
||||
isSavedMessages: route.isSavedMessages
|
||||
)
|
||||
.frame(width: 36, height: 36)
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var backCircleButtonLabel: some View {
|
||||
ZStack {
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.backChevron,
|
||||
viewBox: CGSize(width: 11, height: 20),
|
||||
color: .white
|
||||
)
|
||||
.frame(width: 11, height: 20)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.frame(width: 36, height: 36) // iOS hit-area
|
||||
}
|
||||
|
||||
// MARK: - Existing helpers / UI
|
||||
|
||||
var avatarInitials: String {
|
||||
if route.isSavedMessages { return "S" }
|
||||
return RosettaColors.initials(name: titleText, publicKey: route.publicKey)
|
||||
}
|
||||
|
||||
@@ -150,82 +211,73 @@ private extension ChatDetailView {
|
||||
RosettaColors.adaptive(light: Color(hex: 0x2C2C2E), dark: Color(hex: 0x2C2C2E))
|
||||
}
|
||||
|
||||
var chatHeader: some View {
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.backChevron,
|
||||
viewBox: CGSize(width: 11, height: 20),
|
||||
color: .white
|
||||
/// 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
|
||||
)
|
||||
.frame(width: 11, height: 20)
|
||||
.frame(width: 44, height: 44)
|
||||
.background {
|
||||
headerCircleBackground(strokeOpacity: 0.22)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Back")
|
||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
||||
|
||||
Spacer(minLength: 6)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Text(titleText)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(subtitleText)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(
|
||||
isTyping || (dialog?.isOnline == true)
|
||||
? RosettaColors.online
|
||||
: RosettaColors.Adaptive.textSecondary
|
||||
)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 6)
|
||||
.background {
|
||||
headerCapsuleBackground(strokeOpacity: 0.20)
|
||||
}
|
||||
|
||||
Spacer(minLength: 6)
|
||||
|
||||
AvatarView(
|
||||
initials: avatarInitials,
|
||||
colorIndex: avatarColorIndex,
|
||||
size: 38,
|
||||
isOnline: dialog?.isOnline ?? false,
|
||||
isSavedMessages: route.isSavedMessages
|
||||
)
|
||||
.frame(width: 44, height: 44)
|
||||
.background {
|
||||
headerCircleBackground(strokeOpacity: 0.22)
|
||||
Color(uiColor: UIColor(patternImage: scaledImage))
|
||||
.opacity(0.18)
|
||||
} else {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.clear)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var chatHeaderContainer: some View {
|
||||
VStack(spacing: 0) {
|
||||
chatHeader
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 0)
|
||||
.padding(.bottom, 6)
|
||||
|
||||
Rectangle()
|
||||
.fill(Color.white.opacity(0.06))
|
||||
.frame(height: 0.5)
|
||||
}
|
||||
}
|
||||
// MARK: - Messages
|
||||
|
||||
@ViewBuilder
|
||||
func messagesList(maxBubbleWidth: CGFloat) -> some View {
|
||||
if messages.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
messagesScrollView(maxBubbleWidth: maxBubbleWidth)
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 16) {
|
||||
AvatarView(
|
||||
initials: avatarInitials,
|
||||
colorIndex: avatarColorIndex,
|
||||
size: 80,
|
||||
isOnline: dialog?.isOnline ?? false,
|
||||
isSavedMessages: route.isSavedMessages
|
||||
)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text(titleText)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
|
||||
if !route.isSavedMessages {
|
||||
Text(subtitleText)
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text(route.isSavedMessages
|
||||
? "Save messages here for quick access"
|
||||
: "No messages yet")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7))
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { isInputFocused = false }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func messagesScrollView(maxBubbleWidth: CGFloat) -> some View {
|
||||
ScrollViewReader { proxy in
|
||||
let scroll = ScrollView(.vertical, showsIndicators: false) {
|
||||
LazyVStack(spacing: 6) {
|
||||
@@ -235,19 +287,7 @@ private extension ChatDetailView {
|
||||
maxBubbleWidth: maxBubbleWidth,
|
||||
isTailVisible: isTailVisible(for: index)
|
||||
)
|
||||
.id(message.id)
|
||||
}
|
||||
|
||||
if messages.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "bubble.left.and.bubble.right")
|
||||
.font(.system(size: 34, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.45))
|
||||
Text("Start messaging")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
}
|
||||
.padding(.top, 24)
|
||||
.id(message.id)
|
||||
}
|
||||
|
||||
Color.clear
|
||||
@@ -259,17 +299,14 @@ private extension ChatDetailView {
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onTapGesture {
|
||||
isInputFocused = false
|
||||
}
|
||||
.onTapGesture { isInputFocused = false }
|
||||
.onAppear {
|
||||
DispatchQueue.main.async {
|
||||
scrollToBottom(proxy: proxy, animated: false)
|
||||
}
|
||||
DispatchQueue.main.async { scrollToBottom(proxy: proxy, animated: false) }
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(120))
|
||||
scrollToBottom(proxy: proxy, animated: false)
|
||||
}
|
||||
markDialogAsRead()
|
||||
}
|
||||
.onChange(of: messages.count) { _, _ in
|
||||
scrollToBottom(proxy: proxy, animated: true)
|
||||
@@ -293,51 +330,38 @@ private extension ChatDetailView {
|
||||
func messageRow(_ message: ChatMessage, maxBubbleWidth: CGFloat, isTailVisible: Bool) -> some View {
|
||||
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
|
||||
let messageText = message.text.isEmpty ? " " : message.text
|
||||
let textMaxWidth = max(maxBubbleWidth - 28, 40)
|
||||
|
||||
HStack(spacing: 0) {
|
||||
if outgoing {
|
||||
Spacer(minLength: 56)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(messageText)
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineSpacing(0)
|
||||
.frame(maxWidth: textMaxWidth, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
// Text determines bubble width; timestamp overlays at bottom-trailing.
|
||||
// minWidth ensures the bubble is wide enough for the timestamp row.
|
||||
Text(messageText)
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineSpacing(0)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 22)
|
||||
.frame(minWidth: outgoing ? 90 : 70, alignment: .leading)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
HStack(spacing: 4) {
|
||||
Text(messageTime(message.timestamp))
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundStyle(
|
||||
outgoing ? Color.white.opacity(0.72) : RosettaColors.Adaptive.textSecondary
|
||||
)
|
||||
.foregroundStyle(outgoing ? Color.white.opacity(0.72) : RosettaColors.Adaptive.textSecondary)
|
||||
|
||||
if outgoing {
|
||||
deliveryIndicator(message.deliveryStatus)
|
||||
}
|
||||
if outgoing { deliveryIndicator(message.deliveryStatus) }
|
||||
}
|
||||
.frame(maxWidth: textMaxWidth, alignment: .trailing)
|
||||
.padding(.trailing, 14)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
.background {
|
||||
bubbleBackground(outgoing: outgoing, isTailVisible: isTailVisible)
|
||||
}
|
||||
.frame(maxWidth: maxBubbleWidth, alignment: .leading)
|
||||
|
||||
if !outgoing {
|
||||
Spacer(minLength: 56)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 1)
|
||||
.background { bubbleBackground(outgoing: outgoing, isTailVisible: isTailVisible) }
|
||||
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
|
||||
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
|
||||
.padding(.vertical, 1)
|
||||
}
|
||||
|
||||
// MARK: - Composer
|
||||
|
||||
var composer: some View {
|
||||
VStack(spacing: 6) {
|
||||
if let sendError {
|
||||
@@ -348,7 +372,7 @@ private extension ChatDetailView {
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
HStack(alignment: .bottom, spacing: shouldShowSendButton ? 0 : 6) {
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
Button {
|
||||
// Placeholder for attachment picker
|
||||
} label: {
|
||||
@@ -357,9 +381,9 @@ private extension ChatDetailView {
|
||||
viewBox: CGSize(width: 21, height: 24),
|
||||
color: Color.white
|
||||
)
|
||||
.frame(width: 21, height: 24)
|
||||
.frame(width: 42, height: 42)
|
||||
.background { floatingCircleBackground(strokeOpacity: 0.18) }
|
||||
.frame(width: 21, height: 24)
|
||||
.frame(width: 42, height: 42)
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.18) }
|
||||
}
|
||||
.accessibilityLabel("Attach")
|
||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
||||
@@ -378,16 +402,14 @@ private extension ChatDetailView {
|
||||
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
|
||||
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
Button {
|
||||
// Placeholder for quick actions
|
||||
} label: {
|
||||
Button { } label: {
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.emojiMoon,
|
||||
viewBox: CGSize(width: 19, height: 19),
|
||||
color: RosettaColors.Adaptive.textSecondary
|
||||
)
|
||||
.frame(width: 19, height: 19)
|
||||
.frame(width: 20, height: 36)
|
||||
.frame(width: 19, height: 19)
|
||||
.frame(width: 20, height: 36)
|
||||
}
|
||||
.accessibilityLabel("Quick actions")
|
||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
||||
@@ -397,7 +419,6 @@ private extension ChatDetailView {
|
||||
.overlay(alignment: .trailing) {
|
||||
Button(action: sendCurrentMessage) {
|
||||
ZStack {
|
||||
// Mirrors the layered blend stack from the original SVG icon.
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.sendPlane,
|
||||
viewBox: CGSize(width: 22, height: 19),
|
||||
@@ -445,9 +466,7 @@ private extension ChatDetailView {
|
||||
.scaleEffect(0.72 + (0.28 * sendButtonProgress))
|
||||
.frame(width: 22, height: 19)
|
||||
.frame(width: sendButtonWidth, height: sendButtonHeight)
|
||||
.background {
|
||||
Capsule().fill(Color(hex: 0x008BFF))
|
||||
}
|
||||
.background { Capsule().fill(Color(hex: 0x008BFF)) }
|
||||
}
|
||||
.accessibilityLabel("Send")
|
||||
.disabled(!canSend)
|
||||
@@ -468,11 +487,8 @@ private extension ChatDetailView {
|
||||
}
|
||||
.padding(3)
|
||||
.frame(minHeight: 42, alignment: .bottom)
|
||||
.background {
|
||||
floatingComposerInputBackground(
|
||||
strokeOpacity: 0.18
|
||||
)
|
||||
}
|
||||
.background { glass(shape: .rounded(21), strokeOpacity: 0.18) }
|
||||
.padding(.leading, 6)
|
||||
|
||||
Button(action: trailingAction) {
|
||||
TelegramVectorIcon(
|
||||
@@ -482,7 +498,7 @@ private extension ChatDetailView {
|
||||
)
|
||||
.frame(width: 18, height: 24)
|
||||
.frame(width: 42, height: 42)
|
||||
.background { floatingCircleBackground(strokeOpacity: 0.18) }
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.18) }
|
||||
}
|
||||
.accessibilityLabel("Voice message")
|
||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
||||
@@ -494,10 +510,12 @@ private extension ChatDetailView {
|
||||
anchor: .trailing
|
||||
)
|
||||
.blur(radius: (1 - micButtonProgress) * 2.4)
|
||||
.frame(width: 42 * micButtonProgress, height: 42, alignment: .trailing)
|
||||
.padding(.leading, 6 * micButtonProgress)
|
||||
.frame(width: (42 + 6) * micButtonProgress, height: 42, alignment: .trailing)
|
||||
.clipped()
|
||||
}
|
||||
.padding(.horizontal, composerHorizontalPadding)
|
||||
.padding(.leading, 16)
|
||||
.padding(.trailing, composerTrailingPadding)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, isInputFocused ? 8 : 0)
|
||||
.animation(composerAnimation, value: canSend)
|
||||
@@ -507,6 +525,8 @@ private extension ChatDetailView {
|
||||
.background(Color.clear)
|
||||
}
|
||||
|
||||
// MARK: - Bubbles / Glass
|
||||
|
||||
@ViewBuilder
|
||||
func bubbleBackground(outgoing: Bool, isTailVisible: Bool) -> some View {
|
||||
let nearRadius: CGFloat = isTailVisible ? 6 : 17
|
||||
@@ -529,102 +549,62 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func floatingCapsuleBackground(strokeOpacity: Double) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
Color.clear
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
Capsule()
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(RosettaColors.Adaptive.border.opacity(max(0.28, strokeOpacity)), lineWidth: 0.8)
|
||||
)
|
||||
}
|
||||
enum ChatGlassShape {
|
||||
case capsule
|
||||
case circle
|
||||
case rounded(CGFloat)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func floatingComposerInputBackground(strokeOpacity: Double) -> some View {
|
||||
func glass(
|
||||
shape: ChatGlassShape,
|
||||
strokeOpacity: Double = 0.18,
|
||||
strokeColor: Color = RosettaColors.Adaptive.border
|
||||
) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
let shape = RoundedRectangle(cornerRadius: 21, style: .continuous)
|
||||
shape
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular, in: .rect(cornerRadius: 21))
|
||||
switch shape {
|
||||
case .capsule:
|
||||
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
|
||||
case .circle:
|
||||
Circle().fill(.clear).glassEffect(.regular, in: .circle)
|
||||
case let .rounded(radius):
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
|
||||
}
|
||||
} else {
|
||||
let shape = RoundedRectangle(cornerRadius: 21, style: .continuous)
|
||||
shape
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
shape
|
||||
.stroke(RosettaColors.Adaptive.border.opacity(max(0.28, strokeOpacity)), lineWidth: 0.8)
|
||||
)
|
||||
let border = strokeColor.opacity(max(0.28, strokeOpacity))
|
||||
switch shape {
|
||||
case .capsule:
|
||||
Capsule()
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(Capsule().stroke(border, lineWidth: 0.8))
|
||||
case .circle:
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(Circle().stroke(border, lineWidth: 0.8))
|
||||
case let .rounded(radius):
|
||||
let rounded = RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
rounded
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(rounded.stroke(border, lineWidth: 0.8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func headerCapsuleBackground(strokeOpacity: Double) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
Color.clear
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
Capsule()
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(Color.white.opacity(strokeOpacity), lineWidth: 0.8)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func headerCircleBackground(strokeOpacity: Double) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
Color.clear
|
||||
.glassEffect(.regular, in: .circle)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(strokeOpacity), lineWidth: 0.8)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func floatingCircleBackground(strokeOpacity: Double) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
Color.clear
|
||||
.glassEffect(.regular, in: .circle)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(RosettaColors.Adaptive.border.opacity(max(0.28, strokeOpacity)), lineWidth: 0.8)
|
||||
)
|
||||
}
|
||||
}
|
||||
// MARK: - Actions / utils
|
||||
|
||||
func trailingAction() {
|
||||
if canSend {
|
||||
sendCurrentMessage()
|
||||
} else {
|
||||
isInputFocused = true
|
||||
}
|
||||
if canSend { sendCurrentMessage() }
|
||||
else { isInputFocused = true }
|
||||
}
|
||||
|
||||
func deliveryTint(_ status: DeliveryStatus) -> Color {
|
||||
switch status {
|
||||
case .read:
|
||||
return Color(hex: 0xA4E2FF)
|
||||
case .delivered:
|
||||
return Color.white.opacity(0.94)
|
||||
case .error:
|
||||
return RosettaColors.error
|
||||
default:
|
||||
return Color.white.opacity(0.78)
|
||||
case .read: return Color(hex: 0xA4E2FF)
|
||||
case .delivered: return Color.white.opacity(0.94)
|
||||
case .error: return RosettaColors.error
|
||||
default: return Color.white.opacity(0.78)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,8 +622,7 @@ private extension ChatDetailView {
|
||||
switch status {
|
||||
case .read:
|
||||
ZStack {
|
||||
Image(systemName: "checkmark")
|
||||
.offset(x: 3)
|
||||
Image(systemName: "checkmark").offset(x: 3)
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
.font(.system(size: 10.5, weight: .semibold))
|
||||
@@ -677,21 +656,29 @@ private extension ChatDetailView {
|
||||
let next = messages[index + 1]
|
||||
let sameSender = current.isFromMe(myPublicKey: currentPublicKey) == next.isFromMe(myPublicKey: currentPublicKey)
|
||||
|
||||
// Group only plain text bubbles from the same side.
|
||||
let currentIsPlainText = current.attachments.isEmpty
|
||||
let nextIsPlainText = next.attachments.isEmpty
|
||||
|
||||
return !(sameSender && currentIsPlainText && nextIsPlainText)
|
||||
}
|
||||
|
||||
func requestUserInfoIfNeeded() {
|
||||
// Always request — we need fresh online status even if title is already populated.
|
||||
SessionManager.shared.requestUserInfoIfNeeded(forKey: route.publicKey)
|
||||
}
|
||||
|
||||
func activateDialog() {
|
||||
DialogRepository.shared.ensureDialog(
|
||||
opponentKey: route.publicKey,
|
||||
title: route.title,
|
||||
username: route.username,
|
||||
verified: route.verified,
|
||||
myPublicKey: currentPublicKey
|
||||
)
|
||||
// Only update existing dialogs; don't create ghost entries from search.
|
||||
// New dialogs are created when messages are sent/received (SessionManager).
|
||||
if dialogRepository.dialogs[route.publicKey] != nil {
|
||||
DialogRepository.shared.ensureDialog(
|
||||
opponentKey: route.publicKey,
|
||||
title: route.title,
|
||||
username: route.username,
|
||||
verified: route.verified,
|
||||
myPublicKey: currentPublicKey
|
||||
)
|
||||
}
|
||||
messageRepository.setDialogActive(route.publicKey, isActive: true)
|
||||
}
|
||||
|
||||
@@ -727,6 +714,8 @@ private extension ChatDetailView {
|
||||
}()
|
||||
}
|
||||
|
||||
// MARK: - Button Styles
|
||||
|
||||
private struct ChatDetailGlassPressButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
@@ -744,6 +733,33 @@ private struct ChatDetailGlassPressButtonStyle: ButtonStyle {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatDetailGlassCirclePressStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
// почти незаметное сжатие
|
||||
.scaleEffect(configuration.isPressed ? 0.988 : 1.0)
|
||||
// очень лёгкое “подсвечивание”
|
||||
.brightness(configuration.isPressed ? 0.025 : 0.0)
|
||||
.overlay {
|
||||
if configuration.isPressed {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.10))
|
||||
.blendMode(.overlay)
|
||||
.padding(2)
|
||||
|
||||
// тонкий “inner highlight”
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.18), lineWidth: 0.9)
|
||||
.padding(2)
|
||||
.blendMode(.overlay)
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.20, dampingFraction: 0.85), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SVG
|
||||
|
||||
private struct TelegramVectorIcon: View {
|
||||
let pathData: String
|
||||
let viewBox: CGSize
|
||||
@@ -760,15 +776,10 @@ private struct SVGPathShape: Shape {
|
||||
let viewBox: CGSize
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
guard viewBox.width > 0, viewBox.height > 0 else {
|
||||
return Path()
|
||||
}
|
||||
|
||||
guard viewBox.width > 0, viewBox.height > 0 else { return Path() }
|
||||
var parser = SVGPathParser(pathData: pathData)
|
||||
var output = Path(parser.parse())
|
||||
output = output.applying(
|
||||
.init(scaleX: rect.width / viewBox.width, y: rect.height / viewBox.height)
|
||||
)
|
||||
output = output.applying(.init(scaleX: rect.width / viewBox.width, y: rect.height / viewBox.height))
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -787,42 +798,22 @@ private struct SVGPathTokenizer {
|
||||
while index < chars.count {
|
||||
let ch = chars[index]
|
||||
|
||||
if ch.isWhitespace || ch == "," {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if ch.isLetter {
|
||||
tokens.append(.command(ch))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if ch.isWhitespace || ch == "," { index += 1; continue }
|
||||
if ch.isLetter { tokens.append(.command(ch)); index += 1; continue }
|
||||
|
||||
if ch.isNumber || ch == "-" || ch == "+" || ch == "." {
|
||||
let start = index
|
||||
index += 1
|
||||
|
||||
while index < chars.count {
|
||||
let c = chars[index]
|
||||
let prev = chars[index - 1]
|
||||
|
||||
if c.isNumber || c == "." || c == "e" || c == "E" {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (c == "-" || c == "+"), (prev == "e" || prev == "E") {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if c.isNumber || c == "." || c == "e" || c == "E" { index += 1; continue }
|
||||
if (c == "-" || c == "+"), (prev == "e" || prev == "E") { index += 1; continue }
|
||||
break
|
||||
}
|
||||
|
||||
let fragment = String(chars[start..<index])
|
||||
if let value = Double(fragment) {
|
||||
tokens.append(.number(CGFloat(value)))
|
||||
}
|
||||
if let value = Double(fragment) { tokens.append(.number(CGFloat(value))) }
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -849,16 +840,11 @@ private struct SVGPathParser {
|
||||
while index < tokens.count {
|
||||
let command = readCommandOrReuse()
|
||||
switch command {
|
||||
case "M", "m":
|
||||
parseMove(command)
|
||||
case "L", "l":
|
||||
parseLine(command)
|
||||
case "H", "h":
|
||||
parseHorizontal(command)
|
||||
case "V", "v":
|
||||
parseVertical(command)
|
||||
case "C", "c":
|
||||
parseCubic(command)
|
||||
case "M", "m": parseMove(command)
|
||||
case "L", "l": parseLine(command)
|
||||
case "H", "h": parseHorizontal(command)
|
||||
case "V", "v": parseVertical(command)
|
||||
case "C", "c": parseCubic(command)
|
||||
case "Z", "z":
|
||||
cgPath.closeSubpath()
|
||||
current = subpathStart
|
||||
@@ -871,9 +857,7 @@ private struct SVGPathParser {
|
||||
|
||||
private var isAtCommand: Bool {
|
||||
guard index < tokens.count else { return false }
|
||||
if case .command = tokens[index] {
|
||||
return true
|
||||
}
|
||||
if case .command = tokens[index] { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -897,10 +881,7 @@ private struct SVGPathParser {
|
||||
}
|
||||
|
||||
private func resolvedPoint(x: CGFloat, y: CGFloat, relative: Bool) -> CGPoint {
|
||||
if relative {
|
||||
return CGPoint(x: current.x + x, y: current.y + y)
|
||||
}
|
||||
return CGPoint(x: x, y: y)
|
||||
relative ? CGPoint(x: current.x + x, y: current.y + y) : CGPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
private mutating func readPoint(relative: Bool) -> CGPoint? {
|
||||
@@ -934,10 +915,7 @@ private struct SVGPathParser {
|
||||
private mutating func parseHorizontal(_ command: Character) {
|
||||
let relative = command.isLowercase
|
||||
while !isAtCommand, let value = readNumber() {
|
||||
current = CGPoint(
|
||||
x: relative ? current.x + value : value,
|
||||
y: current.y
|
||||
)
|
||||
current = CGPoint(x: relative ? current.x + value : value, y: current.y)
|
||||
cgPath.addLine(to: current)
|
||||
}
|
||||
}
|
||||
@@ -945,10 +923,7 @@ private struct SVGPathParser {
|
||||
private mutating func parseVertical(_ command: Character) {
|
||||
let relative = command.isLowercase
|
||||
while !isAtCommand, let value = readNumber() {
|
||||
current = CGPoint(
|
||||
x: current.x,
|
||||
y: relative ? current.y + value : value
|
||||
)
|
||||
current = CGPoint(x: current.x, y: relative ? current.y + value : value)
|
||||
cgPath.addLine(to: current)
|
||||
}
|
||||
}
|
||||
@@ -975,14 +950,13 @@ private struct SVGPathParser {
|
||||
|
||||
private mutating func skipToNextCommand() {
|
||||
while index < tokens.count {
|
||||
if case .command = tokens[index] {
|
||||
return
|
||||
}
|
||||
if case .command = tokens[index] { return }
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private enum TelegramIconPath {
|
||||
static let backChevron = #"M0.317383 10.5957C0.203451 10.498 0.12207 10.376 0.0732422 10.2295C0.0244141 10.0993 0 9.96094 0 9.81445C0 9.66797 0.0244141 9.52962 0.0732422 9.39941C0.12207 9.25293 0.203451 9.13086 0.317383 9.0332L8.83789 0.317383C8.93555 0.219727 9.05762 0.138346 9.2041 0.0732422C9.33431 0.0244141 9.47266 0 9.61914 0C9.74935 0 9.87956 0.0244141 10.0098 0.0732422C10.1562 0.138346 10.2783 0.219727 10.376 0.317383C10.4899 0.431315 10.5713 0.553385 10.6201 0.683594C10.6689 0.830078 10.6934 0.976562 10.6934 1.12305C10.6934 1.25326 10.6689 1.3916 10.6201 1.53809C10.5713 1.66829 10.4899 1.79036 10.376 1.9043L2.63672 9.81445L10.376 17.7246C10.4899 17.8385 10.5713 17.9606 10.6201 18.0908C10.6689 18.2373 10.6934 18.3757 10.6934 18.5059C10.6934 18.6523 10.6689 18.7988 10.6201 18.9453C10.5713 19.0755 10.4899 19.1976 10.376 19.3115C10.2783 19.4092 10.1562 19.4906 10.0098 19.5557C9.87956 19.6045 9.74935 19.6289 9.61914 19.6289C9.47266 19.6289 9.33431 19.6045 9.2041 19.5557C9.05762 19.4906 8.93555 19.4092 8.83789 19.3115L0.317383 10.5957Z"#
|
||||
|
||||
@@ -997,12 +971,18 @@ private enum TelegramIconPath {
|
||||
|
||||
|
||||
#Preview {
|
||||
ChatDetailView(
|
||||
route: ChatRoute(
|
||||
publicKey: "demo_public_key",
|
||||
title: "Demo User",
|
||||
username: "demo",
|
||||
verified: 0
|
||||
)
|
||||
)
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
ChatDetailView(
|
||||
route: ChatRoute(
|
||||
publicKey: "demo_public_key",
|
||||
title: "Demo User",
|
||||
username: "demo",
|
||||
verified: 0
|
||||
)
|
||||
)
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user