From bc478ab48489b25333fd89fda7a208bbfa3293cc Mon Sep 17 00:00:00 2001 From: senseiGai Date: Thu, 16 Apr 2026 08:46:34 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=BB=D0=BE=D1=83=20=D1=81=D0=BE=D0=B7?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= =?UTF-8?q?=D1=8B=20=E2=80=94=20glass=20search=20bar,=20=D0=B2=D1=8B=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=20=D1=84=D0=BE=D1=82=D0=BE,=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=B5=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F,=20?= =?UTF-8?q?=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20X=20=D0=B2=20Join=20Grou?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Repositories/DialogRepository.swift | 8 + .../Data/Repositories/MessageRepository.swift | 41 ++ Rosetta/Core/Layout/MessageCellLayout.swift | 57 ++- Rosetta/Core/Services/GroupService.swift | 14 + Rosetta/Core/Utils/DarkMode+Helpers.swift | 59 +-- .../Chats/ChatDetail/ChatDetailView.swift | 64 +-- .../ChatDetail/ChatDetailViewController.swift | 94 +++- .../Chats/ChatDetail/NativeMessageCell.swift | 62 +++ .../Chats/ChatDetail/NativeMessageList.swift | 436 +++++++++++++++--- .../ChatDetail/OpponentProfileView.swift | 1 + .../ChatList/UIKit/ChatListUIKitView.swift | 3 + .../Compose/ComposeGlassComponents.swift | 413 +++++++++++++++++ .../Compose/ComposeViewController.swift | 225 ++++----- .../GroupContactPickerViewController.swift | 357 +++++++------- .../Compose/GroupSetupViewController.swift | 330 +++++++++---- Rosetta/Features/Groups/GroupInfoView.swift | 164 +++++-- Rosetta/Features/Groups/GroupJoinView.swift | 19 +- Rosetta/RosettaApp.swift | 5 +- 18 files changed, 1741 insertions(+), 611 deletions(-) create mode 100644 Rosetta/Features/Compose/ComposeGlassComponents.swift diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index 9e1c667..46c7b1f 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -171,6 +171,14 @@ final class DialogRepository { } } else if textIsEmpty { lastMessageText = "" + } else if lastMsg.text.hasPrefix("$a=") { + // Service message (Desktop parity): strip $a= prefix for display + let action = String(lastMsg.text.dropFirst(3)) + switch action { + case "Group created": lastMessageText = "Group created" + case "Group joined": lastMessageText = "You joined the group" + default: lastMessageText = action + } } else { lastMessageText = lastMsg.text } diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index b713027..5038e78 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -1119,6 +1119,47 @@ final class MessageRepository: ObservableObject { return decryptRecord(record) } + // MARK: - Local Service Messages (Desktop parity: $a= prefix) + + /// Inserts a local-only service message (e.g. "$a=Group created", "$a=Group joined"). + /// Desktop: useGroups.ts:206-216 — INSERT into messages with `$a=Group joined`. + func insertLocalServiceMessage(text: String, dialogKey: String) { + guard !currentAccount.isEmpty else { return } + let messageId = "svc_\(UUID().uuidString)" + let timestamp = Int64(Date().timeIntervalSince1970 * 1000) + + var record = MessageRecord( + id: nil, + account: currentAccount, + fromPublicKey: currentAccount, + toPublicKey: dialogKey, + content: "", + chachaKey: "", + text: text, + plainMessage: text, + timestamp: timestamp, + isRead: 1, + readAlias: 1, + fromMe: 1, + deliveryStatus: DeliveryStatus.delivered.rawValue, + deliveredAlias: DeliveryStatus.delivered.rawValue, + messageId: messageId, + replyToMessageId: nil, + dialogKey: DatabaseManager.dialogKey(account: currentAccount, opponentKey: dialogKey), + attachments: "[]", + attachmentPassword: nil + ) + + do { + try db.writeSync { db in + try record.insert(db, onConflict: .ignore) + } + } catch { + print("[DB] insertLocalServiceMessage error: \(error)") + } + refreshCacheNow(for: dialogKey) + } + // MARK: - Stress Test (Debug only) func insertStressTestMessage(_ message: ChatMessage, dialogKey: String) { diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index d837c7f..26600d0 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -98,6 +98,7 @@ struct MessageCellLayout: Sendable { case forward case emojiOnly case groupInvite + case service } } @@ -193,7 +194,9 @@ extension MessageCellLayout { // Classify message type var messageType: MessageType - if config.isForward { + if config.text.hasPrefix("$a=") { + messageType = .service + } else if config.isForward { messageType = .forward } else if config.imageCount > 0 && !config.text.isEmpty { messageType = .photoWithCaption @@ -208,6 +211,58 @@ extension MessageCellLayout { } else { messageType = .text } + // Service messages ($a= prefix): centered pill, no bubble, fixed height + if messageType == .service { + let serviceHeight: CGFloat = 30 + let dateH: CGFloat = config.showsDateHeader ? 38 : 0 + let layout = MessageCellLayout( + totalHeight: serviceHeight + dateH, + groupGap: 0, + isOutgoing: false, + position: .single, + messageType: .service, + bubbleFrame: CGRect(x: 0, y: dateH, width: config.maxBubbleWidth, height: serviceHeight), + bubbleSize: CGSize(width: config.maxBubbleWidth, height: serviceHeight), + mergeType: .none, + hasTail: false, + textFrame: .zero, + textSize: .zero, + timestampInline: false, + timestampFrame: .zero, + checkSentFrame: .zero, + checkReadFrame: .zero, + clockFrame: .zero, + showsDeliveryFailedIndicator: false, + deliveryFailedInset: 0, + hasReplyQuote: false, + replyContainerFrame: .zero, + replyBarFrame: .zero, + replyNameFrame: .zero, + replyTextFrame: .zero, + hasPhoto: false, + photoFrame: .zero, + photoCollageHeight: 0, + hasFile: false, + fileFrame: .zero, + hasGroupInvite: false, + groupInviteTitle: "", + groupInviteGroupId: "", + isForward: false, + forwardHeaderFrame: .zero, + forwardAvatarFrame: .zero, + forwardNameFrame: .zero, + showsDateHeader: config.showsDateHeader, + dateHeaderText: config.dateHeaderText, + dateHeaderHeight: dateH, + showsSenderName: false, + showsSenderAvatar: false, + senderName: "", + senderKey: "", + isGroupAdmin: false + ) + return (layout, nil) + } + // Emoji-only: single emoji without other text → large font, no bubble if messageType == .text && !config.text.isEmpty && EmojiParser.isEmojiOnly(config.text) { messageType = .emojiOnly diff --git a/Rosetta/Core/Services/GroupService.swift b/Rosetta/Core/Services/GroupService.swift index ee7b54a..3749776 100644 --- a/Rosetta/Core/Services/GroupService.swift +++ b/Rosetta/Core/Services/GroupService.swift @@ -119,6 +119,13 @@ final class GroupService { myPublicKey: account ) + // Step 8: Insert service message (Desktop parity: $a=Group created). + MessageRepository.shared.insertLocalServiceMessage( + text: "$a=Group created", + dialogKey: dialogKey + ) + dialogRepo.updateDialogFromMessages(opponentKey: dialogKey) + Self.logger.info("Group created successfully: \(dialogKey)") return ChatRoute(groupDialogKey: dialogKey, title: title, description: description) } @@ -178,6 +185,13 @@ final class GroupService { myPublicKey: account ) + // Insert service message (Desktop parity: $a=Group joined). + MessageRepository.shared.insertLocalServiceMessage( + text: "$a=Group joined", + dialogKey: dialogKey + ) + dialogRepo.updateDialogFromMessages(opponentKey: dialogKey) + Self.logger.info("Joined group: \(dialogKey)") return ChatRoute(groupDialogKey: dialogKey, title: parsed.title, description: parsed.description) } diff --git a/Rosetta/Core/Utils/DarkMode+Helpers.swift b/Rosetta/Core/Utils/DarkMode+Helpers.swift index cdb0565..10224db 100644 --- a/Rosetta/Core/Utils/DarkMode+Helpers.swift +++ b/Rosetta/Core/Utils/DarkMode+Helpers.swift @@ -1,8 +1,8 @@ import SwiftUI // MARK: - Dark Mode System -// DarkModeWrapper: applies overrideUserInterfaceStyle on all windows via @AppStorage. -// DarkModeButton: toggles theme mode (dark ↔ light). Instant switch, no animation. +// DarkModeWrapper: applies overrideUserInterfaceStyle via @AppStorage. +// DarkModeButton: toggles theme (no animation — all snapshot approaches cause white/black flash). // MARK: - DarkModeWrapper @@ -21,27 +21,29 @@ struct DarkModeWrapper: View { } } .onChange(of: themeModeRaw, initial: true) { _, newValue in - if let windowScene = activeWindowScene { - let style: UIUserInterfaceStyle - switch newValue { - case "light": style = .light - case "system": style = .unspecified - default: style = .dark - } - let bgColor: UIColor - switch style { - case .light: bgColor = .white - case .dark: bgColor = .black - default: bgColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white } - } - for window in windowScene.windows { - window.overrideUserInterfaceStyle = style - window.backgroundColor = bgColor - } - } + applyTheme(newValue) } } + private func applyTheme(_ mode: String) { + guard let windowScene = activeWindowScene else { return } + let style: UIUserInterfaceStyle + switch mode { + case "light": style = .light + case "system": style = .unspecified + default: style = .dark + } + // CRITICAL: use dynamic UIColor — auto-resolves on trait change. + // Static colors (.white / .black) cause white/black screen flash because + // rootViewController.view.backgroundColor stays stale during SwiftUI re-render. + let bgColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white } + for window in windowScene.windows { + window.overrideUserInterfaceStyle = style + window.backgroundColor = bgColor + window.rootViewController?.view.backgroundColor = bgColor + } + } + private var activeWindowScene: UIWindowScene? { let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } return scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first @@ -51,21 +53,26 @@ struct DarkModeWrapper: View { // MARK: - DarkModeButton struct DarkModeButton: View { - @State private var showMoonIcon: Bool = true @AppStorage("rosetta_theme_mode") private var themeModeRaw: String = "system" + private var isMoon: Bool { + switch themeModeRaw { + case "light": return false + case "dark": return true + default: return UIScreen.main.traitCollection.userInterfaceStyle == .dark + } + } + var body: some View { Button { - showMoonIcon.toggle() - themeModeRaw = showMoonIcon ? "dark" : "light" + // Theme switching temporarily disabled } label: { - Image(systemName: showMoonIcon ? "moon.fill" : "sun.max.fill") + Image(systemName: isMoon ? "moon.fill" : "sun.max.fill") .font(.system(size: 16, weight: .medium)) .foregroundStyle(RosettaColors.Adaptive.text) - .symbolEffect(.bounce, value: showMoonIcon) + .symbolEffect(.bounce, value: isMoon) .frame(width: 44, height: 44) } .buttonStyle(.plain) - .onAppear { showMoonIcon = themeModeRaw == "dark" || themeModeRaw == "system" } } } diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index af1f40e..80f6732 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -938,61 +938,8 @@ private extension ChatDetailView { @ViewBuilder func messagesList(maxBubbleWidth: CGFloat) -> some View { - // Skeleton loading is now handled inside NativeMessageListController (UIKit) - if route.isSystemAccount && messages.isEmpty { - emptyStateView - } else { - messagesScrollView(maxBubbleWidth: maxBubbleWidth) - } - } - - private var emptyStateView: some View { - VStack(spacing: 0) { - Spacer() - - VStack(spacing: 16) { - AvatarView( - initials: avatarInitials, - colorIndex: avatarColorIndex, - size: 80, - isOnline: dialog?.isOnline ?? false, - isSavedMessages: route.isSavedMessages, - image: opponentAvatar - ) - - 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) - } - .padding(.horizontal, 24) - .padding(.vertical, 20) - .background { - glass(shape: .rounded(20), strokeOpacity: 0.18) - } - - Spacer() - - // Reserve space for compositor so content centers above it. - Color.clear.frame(height: composerHeight) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .contentShape(Rectangle()) - .onTapGesture { isInputFocused = false } + // Skeleton + empty state are handled inside NativeMessageListController (UIKit) + messagesScrollView(maxBubbleWidth: maxBubbleWidth) } @ViewBuilder @@ -1013,15 +960,16 @@ private extension ChatDetailView { hasMoreMessages: viewModel.hasMoreMessages, firstUnreadMessageId: firstUnreadMessageId, useUIKitComposer: useComposer, - emptyChatInfo: useComposer ? EmptyChatInfo( + emptyChatInfo: EmptyChatInfo( title: titleText, subtitle: subtitleText, initials: avatarInitials, colorIndex: avatarColorIndex, isOnline: dialog?.isOnline ?? false, isSavedMessages: route.isSavedMessages, - avatarImage: opponentAvatar - ) : nil, + avatarImage: opponentAvatar, + isGroup: route.isGroup + ), scrollToMessageId: scrollToMessageId, shouldScrollToBottom: shouldScrollOnNextMessage, scrollToBottomTrigger: scrollToBottomTrigger, diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift index d3df8f3..1160666 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift @@ -456,15 +456,21 @@ final class ChatDetailViewController: UIViewController { controller.hasMoreMessages = viewModel.hasMoreMessages controller.hasNewerMessages = viewModel.hasNewerMessages + // Filter out service messages ($a=) for display — they're only for chat list + let visibleMessages = messages.filter { !$0.text.hasPrefix("$a=") } + + // Empty state: based on visible (non-service) messages + controller.updateEmptyState(isEmpty: visibleMessages.isEmpty, info: makeEmptyChatInfo()) + let fingerprint = messageFingerprint(messages) guard fingerprint != lastMessageFingerprint else { return } let wasAtBottom = isAtBottom - let oldNewestId = lastNewestMessageId - let newNewestId = messages.last?.id - let lastIsOutgoing = messages.last?.isFromMe(myPublicKey: currentPublicKey) == true + let oldNewestId = visibleMessages.last?.id + let newNewestId = visibleMessages.last?.id + let lastIsOutgoing = visibleMessages.last?.isFromMe(myPublicKey: currentPublicKey) == true - controller.update(messages: messages) + controller.update(messages: visibleMessages) lastMessageFingerprint = fingerprint lastNewestMessageId = newNewestId @@ -487,6 +493,40 @@ final class ChatDetailViewController: UIViewController { return "\(messages.count)|\(first.id)|\(last.id)|\(last.deliveryStatus.rawValue)|\(last.isRead)" } + private func makeEmptyChatInfo() -> EmptyChatInfo { + // Use GroupMetadata title for groups (DB is authoritative, route.title may be stale) + let title: String + if route.isGroup, + let meta = GroupRepository.shared.groupMetadata( + account: SessionManager.shared.currentPublicKey, + groupDialogKey: route.publicKey + ), !meta.title.isEmpty { + title = meta.title + } else { + title = dialog?.opponentTitle ?? route.title + } + let initials: String + if route.isGroup { + initials = RosettaColors.groupInitial(name: title, publicKey: route.publicKey) + } else { + initials = RosettaColors.initials(name: title, publicKey: route.publicKey) + } + let colorIndex = RosettaColors.avatarColorIndex(for: title, publicKey: route.publicKey) + let avatar = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey) + let subtitle = dialog?.isOnline == true ? "online" : "" + + return EmptyChatInfo( + title: title, + subtitle: subtitle, + initials: initials, + colorIndex: colorIndex, + isOnline: dialog?.isOnline ?? false, + isSavedMessages: route.isSavedMessages, + avatarImage: avatar, + isGroup: route.isGroup + ) + } + // MARK: - Cell Actions private func wireCellActions() { @@ -673,12 +713,19 @@ final class ChatDetailViewController: UIViewController { if route.isGroup { let groupInfo = GroupInfoView(groupDialogKey: route.publicKey) let hosting = UIHostingController(rootView: groupInfo) - hosting.navigationItem.hidesBackButton = true // prevent system "< Back" flash + hosting.navigationItem.hidesBackButton = true + // Glass back button — matches ChatDetailBackButton style, visible during push transition + let backView = ChatDetailBackButton() + backView.addTarget(hosting, action: #selector(UIViewController.rosettaPopSelf), for: .touchUpInside) + hosting.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backView) navigationController?.pushViewController(hosting, animated: true) } else if !route.isSystemAccount { let profile = OpponentProfileView(route: route) let hosting = UIHostingController(rootView: profile) - hosting.navigationItem.hidesBackButton = true // prevent system "< Back" flash + hosting.navigationItem.hidesBackButton = true + let backView = ChatDetailBackButton() + backView.addTarget(hosting, action: #selector(UIViewController.rosettaPopSelf), for: .touchUpInside) + hosting.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backView) navigationController?.pushViewController(hosting, animated: true) } } @@ -1132,7 +1179,7 @@ final class ChatDetailViewController: UIViewController { /// Back button — 44×44 glass capsule with Telegram SVG chevron (filled, not stroked). /// Uses exact `TelegramIconPath.backChevron` SVG path data via `SVGPathParser`. -private final class ChatDetailBackButton: UIControl { +final class ChatDetailBackButton: UIControl { private let glassView = TelegramGlassUIView(frame: .zero) private let chevronLayer = CAShapeLayer() @@ -1435,10 +1482,21 @@ private final class ChatDetailAvatarButton: UIControl { } else { avatarImageView.isHidden = true initialsLabel.isHidden = false - let title = route.title.isEmpty ? String(route.publicKey.prefix(8)) : route.title - initialsLabel.text = route.isSavedMessages ? "S" + + // Use GroupMetadata title (DB) for groups — route.title may be stale + var title = route.title.isEmpty ? String(route.publicKey.prefix(8)) : route.title + if route.isGroup, + let meta = GroupRepository.shared.groupMetadata( + account: SessionManager.shared.currentPublicKey, + groupDialogKey: route.publicKey + ), !meta.title.isEmpty { + title = meta.title + } + + let displayInitial: String = route.isSavedMessages ? "S" : route.isGroup ? RosettaColors.groupInitial(name: title, publicKey: route.publicKey) : RosettaColors.initials(name: title, publicKey: route.publicKey) + initialsLabel.text = displayInitial // Mantine "light" variant (ChatListCell parity) let colorIndex = RosettaColors.avatarColorIndex(for: title, publicKey: route.publicKey) @@ -1450,8 +1508,14 @@ private final class ChatDetailAvatarButton: UIControl { let tintAlpha: CGFloat = isDark ? 0.15 : 0.10 avatarBackgroundView.backgroundColor = baseColor.chatDetailBlended(with: tintUIColor, alpha: tintAlpha) - // Font: bold rounded, 38 * 0.38 ≈ 14.4pt - initialsLabel.font = UIFont.systemFont(ofSize: 38 * 0.38, weight: .bold).chatDetailRounded() + // Emoji as first char → larger font, no bold rounded + if displayInitial.unicodeScalars.first?.properties.isEmojiPresentation == true + || (displayInitial.unicodeScalars.first?.properties.isEmoji == true + && displayInitial.unicodeScalars.count > 1) { + initialsLabel.font = .systemFont(ofSize: 38 * 0.5) + } else { + initialsLabel.font = UIFont.systemFont(ofSize: 38 * 0.38, weight: .bold).chatDetailRounded() + } initialsLabel.textColor = isDark ? UIColor(colorPair.text) : tintUIColor } } @@ -1527,3 +1591,11 @@ extension ChatDetailViewController: UIGestureRecognizerDelegate { false } } + +// MARK: - UIViewController Pop Helper (for UIBarButtonItem target/action) + +extension UIViewController { + @objc func rosettaPopSelf() { + navigationController?.popViewController(animated: true) + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 143413a..fd9fe9b 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -230,6 +230,10 @@ final class NativeMessageCell: UICollectionViewCell { case notJoined, joined, invalid, banned } + // Service message (centered pill — Telegram parity for $a= messages) + private let serviceLabel = UILabel() + private let servicePillView = UIView() + // Highlight overlay (scroll-to-message flash) private let highlightOverlay = UIView() @@ -309,6 +313,24 @@ final class NativeMessageCell: UICollectionViewCell { dateHeaderContainer.addSubview(dateHeaderLabel) contentView.addSubview(dateHeaderContainer) + // Service message pill (centered, no bubble — like Telegram "Group created") + servicePillView.backgroundColor = UIColor { $0.userInterfaceStyle == .dark + ? UIColor(white: 1.0, alpha: 0.08) + : UIColor(white: 0.0, alpha: 0.08) + } + servicePillView.layer.cornerRadius = 12 + servicePillView.layer.cornerCurve = .continuous + servicePillView.isHidden = true + contentView.addSubview(servicePillView) + + serviceLabel.font = .systemFont(ofSize: 14, weight: .regular) + serviceLabel.textColor = UIColor { $0.userInterfaceStyle == .dark + ? UIColor.white.withAlphaComponent(0.6) + : UIColor.black.withAlphaComponent(0.5) + } + serviceLabel.textAlignment = .center + servicePillView.addSubview(serviceLabel) + // Bubble — CAShapeLayer for shadow (index 0), then outline, then raster image on top bubbleLayer.fillColor = UIColor.clear.cgColor bubbleLayer.fillRule = .nonZero @@ -683,6 +705,17 @@ final class NativeMessageCell: UICollectionViewCell { // MARK: - Configure + Apply Layout + /// Human-readable label for service messages (strips $a= prefix). + private static func serviceMessageDisplayText(_ text: String) -> String { + guard text.hasPrefix("$a=") else { return text } + let action = String(text.dropFirst(3)) + switch action { + case "Group created": return "Group created" + case "Group joined": return "You joined the group" + default: return action + } + } + /// Configure cell data (content). Does NOT trigger layout. /// `textLayout` is pre-computed during `calculateLayouts()` — no double CoreText work. func configure( @@ -700,6 +733,16 @@ final class NativeMessageCell: UICollectionViewCell { self.actions = actions self.replyMessageId = replyMessageId + // Service messages: show centered pill, hide everything else + let isService = message.text.hasPrefix("$a=") + servicePillView.isHidden = !isService + if isService { + serviceLabel.text = Self.serviceMessageDisplayText(message.text) + bubbleView.isHidden = true + return + } + bubbleView.isHidden = false + let isOutgoing = currentLayout?.isOutgoing ?? false let isMediaStatus: Bool = { guard let type = currentLayout?.messageType else { return false } @@ -1173,6 +1216,25 @@ final class NativeMessageCell: UICollectionViewCell { override func layoutSubviews() { super.layoutSubviews() guard let layout = currentLayout else { return } + + // Service message: centered pill, skip all bubble layout + if layout.messageType == .service { + serviceLabel.sizeToFit() + let labelW = serviceLabel.bounds.width + let labelH = serviceLabel.bounds.height + let pillW = labelW + 24 + let pillH = labelH + 8 + let cellW = contentView.bounds.width + servicePillView.frame = CGRect( + x: (cellW - pillW) / 2, + y: (layout.bubbleSize.height - pillH) / 2, + width: pillW, + height: pillH + ) + serviceLabel.frame = CGRect(x: 12, y: 4, width: labelW, height: labelH) + return + } + Self.ensureBubbleImages(for: traitCollection) let cellW = contentView.bounds.width diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 1a4c50f..b1a8f33 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -154,8 +154,8 @@ final class NativeMessageListController: UIViewController { return v }() - // MARK: - Empty State (UIKit-managed, animates with keyboard) - private var emptyStateHosting: UIHostingController? + // MARK: - Empty State (pure UIKit, animates with keyboard) + private var emptyStateContainer: UIView? private var emptyStateGuide: UILayoutGuide? // MARK: - Multi-Select @@ -1032,47 +1032,359 @@ final class NativeMessageListController: UIViewController { func updateEmptyState(isEmpty: Bool, info: EmptyChatInfo) { if isEmpty { - if let hosting = emptyStateHosting { - hosting.rootView = EmptyChatContent(info: info) - hosting.view.isHidden = false + if let container = emptyStateContainer { + if !info.isGroup { + updateEmptyStateContent(container, info: info) + } + container.isHidden = false } else { setupEmptyState(info: info) } } else { - emptyStateHosting?.view.isHidden = true + emptyStateContainer?.isHidden = true + } + } + + private func updateEmptyStateContent(_ container: UIView, info: EmptyChatInfo) { + // Tags: 701=avatarContainer, 702=avatarImage, 703=initialsLabel, + // 704=titleLabel, 705=subtitleLabel, 706=descLabel, 707=avatarBg + let avatarSize: CGFloat = 80 + + // Avatar image vs initials + if let avatarImg = container.viewWithTag(702) as? UIImageView, + let initialsLbl = container.viewWithTag(703) as? UILabel, + let avatarBg = container.viewWithTag(707) { + if let photo = info.avatarImage { + avatarImg.image = photo + avatarImg.isHidden = false + initialsLbl.isHidden = true + avatarBg.isHidden = true + } else if info.isSavedMessages { + avatarImg.isHidden = true + initialsLbl.isHidden = false + initialsLbl.text = "" + avatarBg.backgroundColor = UIColor(RosettaColors.primaryBlue) + avatarBg.isHidden = false + // Bookmark icon via SF Symbol + if let bookmarkImg = container.viewWithTag(708) as? UIImageView { + bookmarkImg.isHidden = false + } + } else { + avatarImg.isHidden = true + initialsLbl.isHidden = false + initialsLbl.text = info.initials + avatarBg.isHidden = false + if let bookmarkImg = container.viewWithTag(708) as? UIImageView { + bookmarkImg.isHidden = true + } + + let isDark = traitCollection.userInterfaceStyle == .dark + let colorPair = RosettaColors.avatarColors[info.colorIndex % RosettaColors.avatarColors.count] + let mantineDarkBody = UIColor(red: 0x1A / 255, green: 0x1B / 255, blue: 0x1E / 255, alpha: 1) + let baseColor = isDark ? mantineDarkBody : .white + let tintUIColor = UIColor(colorPair.tint) + let tintAlpha: CGFloat = isDark ? 0.15 : 0.10 + avatarBg.backgroundColor = baseColor.emptyStateBlended(with: tintUIColor, alpha: tintAlpha) + + let fontSize = avatarSize * 0.38 + initialsLbl.font = UIFont.systemFont(ofSize: fontSize, weight: .bold).emptyStateRounded() + initialsLbl.textColor = isDark ? UIColor(colorPair.text) : tintUIColor + } + } + + // Title + if let titleLbl = container.viewWithTag(704) as? UILabel { + titleLbl.text = info.title + } + // Subtitle + if let subtitleLbl = container.viewWithTag(705) as? UILabel { + subtitleLbl.text = info.subtitle + subtitleLbl.isHidden = info.isSavedMessages || info.subtitle.isEmpty + } + // Description + if let descLbl = container.viewWithTag(706) as? UILabel { + descLbl.text = info.isSavedMessages + ? "Save messages here for quick access" + : "No messages yet" } } private func setupEmptyState(info: EmptyChatInfo) { - let hosting = UIHostingController(rootView: EmptyChatContent(info: info)) - hosting.view.backgroundColor = .clear - hosting.view.translatesAutoresizingMaskIntoConstraints = false - - addChild(hosting) - if let cv = collectionView { - view.insertSubview(hosting.view, aboveSubview: cv) + let card: UIView + if info.isGroup { + card = buildGroupEmptyCard() } else { - view.addSubview(hosting.view) + card = buildPersonalEmptyCard(info: info) } - hosting.didMove(toParent: self) - // Layout guide spans from safe area top to composer top. - // When keyboard moves the composer, this guide shrinks and - // the empty state re-centers — all in the same UIKit animation block. + // --- Insert into view --- + if let cv = collectionView { + view.insertSubview(card, aboveSubview: cv) + } else { + view.addSubview(card) + } + + // Layout guide: safe area top → composer top (centers card in available space) let guide = UILayoutGuide() view.addLayoutGuide(guide) - - let bottomAnchor = composerView?.topAnchor ?? view.safeAreaLayoutGuide.bottomAnchor + let bottom = composerView?.topAnchor ?? view.safeAreaLayoutGuide.bottomAnchor NSLayoutConstraint.activate([ guide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - guide.bottomAnchor.constraint(equalTo: bottomAnchor), - hosting.view.centerXAnchor.constraint(equalTo: view.centerXAnchor), - hosting.view.centerYAnchor.constraint(equalTo: guide.centerYAnchor), + guide.bottomAnchor.constraint(equalTo: bottom), + card.centerXAnchor.constraint(equalTo: view.centerXAnchor), + card.centerYAnchor.constraint(equalTo: guide.centerYAnchor), ]) - emptyStateHosting = hosting + emptyStateContainer = card emptyStateGuide = guide + + // Populate content (personal chats only — group card is static) + if !info.isGroup { + updateEmptyStateContent(card, info: info) + } + } + + // MARK: - Group "You created a group" Card (Telegram parity) + + private func buildGroupEmptyCard() -> UIView { + let card = UIView() + card.translatesAutoresizingMaskIntoConstraints = false + card.backgroundColor = .clear + + // Glass background + let glass = TelegramGlassUIView(frame: .zero) + glass.fixedCornerRadius = 20 + glass.translatesAutoresizingMaskIntoConstraints = false + card.addSubview(glass) + NSLayoutConstraint.activate([ + glass.topAnchor.constraint(equalTo: card.topAnchor), + glass.leadingAnchor.constraint(equalTo: card.leadingAnchor), + glass.trailingAnchor.constraint(equalTo: card.trailingAnchor), + glass.bottomAnchor.constraint(equalTo: card.bottomAnchor), + ]) + + let textColor = UIColor { $0.userInterfaceStyle == .dark + ? UIColor(RosettaColors.Dark.text) + : UIColor(RosettaColors.Light.text) + } + let secondaryColor = UIColor { $0.userInterfaceStyle == .dark + ? UIColor(RosettaColors.Dark.textSecondary) + : UIColor(RosettaColors.Light.textSecondary) + } + + // Title: "You created a group" + let titleLabel = UILabel() + titleLabel.text = "You created a group" + titleLabel.font = .systemFont(ofSize: 17, weight: .bold) + titleLabel.textColor = textColor + titleLabel.textAlignment = .natural + + // Subtitle: "Groups can have:" + let subtitleLabel = UILabel() + subtitleLabel.text = "Groups can have:" + subtitleLabel.font = .systemFont(ofSize: 15, weight: .regular) + subtitleLabel.textColor = textColor + subtitleLabel.textAlignment = .natural + + // Checklist items + let features = [ + "Up to 200,000 members", + "Persistent chat history", + "Invite links", + "Admins with different rights", + ] + + let checkmarkConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold) + let checkmarkImage = UIImage(systemName: "checkmark", withConfiguration: checkmarkConfig) + + var featureViews: [UIView] = [] + for feature in features { + let row = UIView() + row.translatesAutoresizingMaskIntoConstraints = false + + let icon = UIImageView(image: checkmarkImage) + icon.tintColor = secondaryColor + icon.translatesAutoresizingMaskIntoConstraints = false + icon.contentMode = .center + row.addSubview(icon) + + let label = UILabel() + label.text = feature + label.font = .systemFont(ofSize: 15, weight: .regular) + label.textColor = textColor + label.translatesAutoresizingMaskIntoConstraints = false + row.addSubview(label) + + NSLayoutConstraint.activate([ + icon.leadingAnchor.constraint(equalTo: row.leadingAnchor), + icon.centerYAnchor.constraint(equalTo: row.centerYAnchor), + icon.widthAnchor.constraint(equalToConstant: 20), + label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 8), + label.trailingAnchor.constraint(equalTo: row.trailingAnchor), + label.topAnchor.constraint(equalTo: row.topAnchor), + label.bottomAnchor.constraint(equalTo: row.bottomAnchor), + ]) + + featureViews.append(row) + } + + // Checklist stack + let checkStack = UIStackView(arrangedSubviews: featureViews) + checkStack.axis = .vertical + checkStack.spacing = 6 + + // Main stack + let mainStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel, checkStack]) + mainStack.axis = .vertical + mainStack.spacing = 10 + mainStack.alignment = .fill + mainStack.setCustomSpacing(16, after: subtitleLabel) + mainStack.translatesAutoresizingMaskIntoConstraints = false + + card.addSubview(mainStack) + NSLayoutConstraint.activate([ + mainStack.topAnchor.constraint(equalTo: card.topAnchor, constant: 20), + mainStack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 24), + mainStack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -24), + mainStack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -20), + ]) + + return card + } + + // MARK: - Personal Chat Empty Card (avatar + title + "No messages yet") + + private func buildPersonalEmptyCard(info: EmptyChatInfo) -> UIView { + let avatarSize: CGFloat = 80 + let card = UIView() + card.translatesAutoresizingMaskIntoConstraints = false + card.backgroundColor = .clear + + // Glass background + let glass = TelegramGlassUIView(frame: .zero) + glass.fixedCornerRadius = 20 + glass.translatesAutoresizingMaskIntoConstraints = false + card.addSubview(glass) + NSLayoutConstraint.activate([ + glass.topAnchor.constraint(equalTo: card.topAnchor), + glass.leadingAnchor.constraint(equalTo: card.leadingAnchor), + glass.trailingAnchor.constraint(equalTo: card.trailingAnchor), + glass.bottomAnchor.constraint(equalTo: card.bottomAnchor), + ]) + + // --- Avatar --- + let avatarContainer = UIView() + avatarContainer.tag = 701 + avatarContainer.translatesAutoresizingMaskIntoConstraints = false + avatarContainer.clipsToBounds = true + avatarContainer.layer.cornerRadius = avatarSize / 2 + + let avatarBg = UIView() + avatarBg.tag = 707 + avatarBg.translatesAutoresizingMaskIntoConstraints = false + avatarBg.clipsToBounds = true + avatarBg.layer.cornerRadius = avatarSize / 2 + avatarContainer.addSubview(avatarBg) + NSLayoutConstraint.activate([ + avatarBg.topAnchor.constraint(equalTo: avatarContainer.topAnchor), + avatarBg.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor), + avatarBg.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor), + avatarBg.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor), + ]) + + let avatarImage = UIImageView() + avatarImage.tag = 702 + avatarImage.translatesAutoresizingMaskIntoConstraints = false + avatarImage.contentMode = .scaleAspectFill + avatarImage.clipsToBounds = true + avatarImage.layer.cornerRadius = avatarSize / 2 + avatarContainer.addSubview(avatarImage) + NSLayoutConstraint.activate([ + avatarImage.topAnchor.constraint(equalTo: avatarContainer.topAnchor), + avatarImage.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor), + avatarImage.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor), + avatarImage.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor), + ]) + + let initialsLabel = UILabel() + initialsLabel.tag = 703 + initialsLabel.translatesAutoresizingMaskIntoConstraints = false + initialsLabel.textAlignment = .center + initialsLabel.lineBreakMode = .byTruncatingTail + avatarContainer.addSubview(initialsLabel) + NSLayoutConstraint.activate([ + initialsLabel.centerXAnchor.constraint(equalTo: avatarContainer.centerXAnchor), + initialsLabel.centerYAnchor.constraint(equalTo: avatarContainer.centerYAnchor), + ]) + + let bookmarkIcon = UIImageView() + bookmarkIcon.tag = 708 + bookmarkIcon.translatesAutoresizingMaskIntoConstraints = false + bookmarkIcon.contentMode = .center + bookmarkIcon.tintColor = .white + let bookmarkFontSize = avatarSize * 0.38 + bookmarkIcon.image = UIImage( + systemName: "bookmark.fill", + withConfiguration: UIImage.SymbolConfiguration(pointSize: bookmarkFontSize, weight: .semibold) + ) + bookmarkIcon.isHidden = true + avatarContainer.addSubview(bookmarkIcon) + NSLayoutConstraint.activate([ + bookmarkIcon.centerXAnchor.constraint(equalTo: avatarContainer.centerXAnchor), + bookmarkIcon.centerYAnchor.constraint(equalTo: avatarContainer.centerYAnchor), + ]) + + // --- Labels --- + let titleLabel = UILabel() + titleLabel.tag = 704 + titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) + titleLabel.textColor = UIColor { $0.userInterfaceStyle == .dark + ? UIColor(RosettaColors.Dark.text) + : UIColor(RosettaColors.Light.text) + } + titleLabel.textAlignment = .center + + let subtitleLabel = UILabel() + subtitleLabel.tag = 705 + subtitleLabel.font = .systemFont(ofSize: 14, weight: .regular) + subtitleLabel.textColor = UIColor { $0.userInterfaceStyle == .dark + ? UIColor(RosettaColors.Dark.textSecondary) + : UIColor(RosettaColors.Light.textSecondary) + } + subtitleLabel.textAlignment = .center + + let descLabel = UILabel() + descLabel.tag = 706 + descLabel.font = .systemFont(ofSize: 15, weight: .regular) + descLabel.textColor = UIColor { $0.userInterfaceStyle == .dark + ? UIColor(RosettaColors.Dark.textSecondary).withAlphaComponent(0.7) + : UIColor(RosettaColors.Light.textSecondary).withAlphaComponent(0.7) + } + descLabel.textAlignment = .center + + let titleStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + titleStack.axis = .vertical + titleStack.spacing = 4 + titleStack.alignment = .center + + let mainStack = UIStackView(arrangedSubviews: [avatarContainer, titleStack, descLabel]) + mainStack.axis = .vertical + mainStack.spacing = 16 + mainStack.alignment = .center + mainStack.translatesAutoresizingMaskIntoConstraints = false + + card.addSubview(mainStack) + NSLayoutConstraint.activate([ + avatarContainer.widthAnchor.constraint(equalToConstant: avatarSize), + avatarContainer.heightAnchor.constraint(equalToConstant: avatarSize), + mainStack.topAnchor.constraint(equalTo: card.topAnchor, constant: 20), + mainStack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 24), + mainStack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -24), + mainStack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -20), + ]) + + return card } // MARK: - Multi-Select Methods @@ -1117,7 +1429,7 @@ final class NativeMessageListController: UIViewController { /// Called from SwiftUI when messages array changes. func update(messages: [ChatMessage], animated: Bool = false) { // Defer skeleton dismiss until after snapshot is applied (cells must exist for fly-in animation) - let shouldDismissSkeleton = isShowingSkeleton && !messages.isEmpty + let shouldDismissSkeleton = isShowingSkeleton let oldIds = Set(self.messages.map(\.id)) let oldNewestId = self.messages.last?.id @@ -2063,15 +2375,18 @@ struct NativeMessageListView: UIViewControllerRepresentable { controller.composerView?.layoutIfNeeded() } + // Filter out service messages ($a=) — only for chat list display + let visibleMessages = messages.filter { !$0.text.hasPrefix("$a=") } + // Update messages let messagesChanged = coordinator.lastMessageFingerprint != messageFingerprint if messagesChanged { let wasAtBottom = coordinator.isAtBottom let lastMessageId = coordinator.lastNewestMessageId - let newNewestId = messages.last?.id - let lastIsOutgoing = messages.last?.isFromMe(myPublicKey: currentPublicKey) == true + let newNewestId = visibleMessages.last?.id + let lastIsOutgoing = visibleMessages.last?.isFromMe(myPublicKey: currentPublicKey) == true - controller.update(messages: messages) + controller.update(messages: visibleMessages) coordinator.lastMessageFingerprint = messageFingerprint coordinator.lastNewestMessageId = newNewestId @@ -2107,9 +2422,9 @@ struct NativeMessageListView: UIViewControllerRepresentable { controller.animateHighlight(messageId: highlightedMessageId) } - // Empty state (iOS < 26 — UIKit-managed for keyboard animation parity) + // Empty state (pure UIKit — animates with keyboard) if let info = emptyChatInfo { - controller.updateEmptyState(isEmpty: messages.isEmpty, info: info) + controller.updateEmptyState(isEmpty: visibleMessages.isEmpty, info: info) } } @@ -2183,7 +2498,7 @@ struct NativeMessageListView: UIViewControllerRepresentable { } } -// MARK: - Empty Chat State (UIKit-hosted SwiftUI content) +// MARK: - Empty Chat State struct EmptyChatInfo { let title: String @@ -2193,45 +2508,30 @@ struct EmptyChatInfo { let isOnline: Bool let isSavedMessages: Bool let avatarImage: UIImage? + let isGroup: Bool } -/// SwiftUI content rendered inside a UIHostingController so it participates -/// in the UIKit keyboard animation block (same timing as the composer). -struct EmptyChatContent: View { - let info: EmptyChatInfo +// MARK: - UIFont/UIColor Helpers (Empty State) - var body: some View { - VStack(spacing: 16) { - AvatarView( - initials: info.initials, - colorIndex: info.colorIndex, - size: 80, - isOnline: info.isOnline, - isSavedMessages: info.isSavedMessages, - image: info.avatarImage - ) - - VStack(spacing: 4) { - Text(info.title) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(RosettaColors.Adaptive.text) - - if !info.isSavedMessages && !info.subtitle.isEmpty { - Text(info.subtitle) - .font(.system(size: 14, weight: .regular)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) - } - } - - Text(info.isSavedMessages - ? "Save messages here for quick access" - : "No messages yet") - .font(.system(size: 15)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7)) - .padding(.top, 4) - } - .padding(.horizontal, 24) - .padding(.vertical, 20) - .background { TelegramGlassRoundedRect(cornerRadius: 20) } +private extension UIFont { + func emptyStateRounded() -> UIFont { + guard let descriptor = fontDescriptor.withDesign(.rounded) else { return self } + return UIFont(descriptor: descriptor, size: 0) + } +} + +private extension UIColor { + func emptyStateBlended(with color: UIColor, alpha: CGFloat) -> UIColor { + var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0 + var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0 + getRed(&r1, green: &g1, blue: &b1, alpha: &a1) + color.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) + let inv = 1.0 - alpha + return UIColor( + red: r1 * inv + r2 * alpha, + green: g1 * inv + g2 * alpha, + blue: b1 * inv + b2 * alpha, + alpha: a1 * inv + a2 * alpha + ) } } diff --git a/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift b/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift index fae9c24..d997340 100644 --- a/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift +++ b/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift @@ -81,6 +81,7 @@ struct OpponentProfileView: View { ToolbarItem(placement: .navigationBarLeading) { Button { dismiss() } label: { backButtonLabel } .buttonStyle(.plain) + .padding(.leading, -8) // align with ChatDetail back button (8pt from edge) } } .toolbarBackground(.hidden, for: .navigationBar) diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift index a073f99..978ac9e 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift @@ -822,6 +822,9 @@ final class ChatListRootViewController: UIViewController, UINavigationController let hideNavBar = viewController === self || viewController is ChatDetailViewController || viewController is RequestChatsUIKitShellController + || viewController is ComposeViewController + || viewController is GroupContactPickerViewController + || viewController is GroupSetupViewController navigationController.setNavigationBarHidden(hideNavBar, animated: animated) if let coordinator = navigationController.transitionCoordinator, coordinator.isInteractive { diff --git a/Rosetta/Features/Compose/ComposeGlassComponents.swift b/Rosetta/Features/Compose/ComposeGlassComponents.swift new file mode 100644 index 0000000..aa81e02 --- /dev/null +++ b/Rosetta/Features/Compose/ComposeGlassComponents.swift @@ -0,0 +1,413 @@ +import UIKit +import SwiftUI + +// MARK: - Glass Back Button (Chevron) + +/// Circular glass button with Telegram SVG back chevron. +/// Reuses exact `TelegramIconPath.backChevron` path data. +final class ComposeGlassBackButton: UIControl { + + private let glassView = TelegramGlassUIView(frame: .zero) + private let chevronLayer = CAShapeLayer() + private static let viewBox = CGSize(width: 10.7, height: 19.63) + + override init(frame: CGRect) { + super.init(frame: frame) + glassView.isUserInteractionEnabled = false + addSubview(glassView) + chevronLayer.fillColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }.cgColor + layer.addSublayer(chevronLayer) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override var intrinsicContentSize: CGSize { CGSize(width: 44, height: 44) } + + override func layoutSubviews() { + super.layoutSubviews() + glassView.frame = bounds + glassView.fixedCornerRadius = bounds.height * 0.5 + glassView.updateGlass() + updateChevronPath() + } + + private func updateChevronPath() { + guard bounds.width > 0 else { return } + let iconSize = CGSize(width: 11, height: 20) + let origin = CGPoint( + x: (bounds.width - iconSize.width) / 2, + y: (bounds.height - iconSize.height) / 2 + ) + var parser = SVGPathParser(pathData: TelegramIconPath.backChevron) + let rawPath = parser.parse() + let vb = Self.viewBox + var transform = CGAffineTransform(translationX: origin.x, y: origin.y) + .scaledBy(x: iconSize.width / vb.width, y: iconSize.height / vb.height) + let scaledPath = rawPath.copy(using: &transform) + chevronLayer.path = scaledPath + chevronLayer.frame = bounds + } + + override var isHighlighted: Bool { + didSet { alpha = isHighlighted ? 0.6 : 1.0 } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle { + chevronLayer.fillColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }.cgColor + } + } +} + +// MARK: - Glass Close Button (X) + +/// Circular glass button with X icon for dismissing modal-like screens. +final class ComposeGlassCloseButton: UIControl { + + private let glassView = TelegramGlassUIView(frame: .zero) + private let xLayer = CAShapeLayer() + + override init(frame: CGRect) { + super.init(frame: frame) + glassView.isUserInteractionEnabled = false + addSubview(glassView) + + xLayer.strokeColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }.cgColor + xLayer.fillColor = UIColor.clear.cgColor + xLayer.lineWidth = 2.0 + xLayer.lineCap = .round + layer.addSublayer(xLayer) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override var intrinsicContentSize: CGSize { CGSize(width: 44, height: 44) } + + override func layoutSubviews() { + super.layoutSubviews() + glassView.frame = bounds + glassView.fixedCornerRadius = bounds.height * 0.5 + glassView.updateGlass() + updateXPath() + } + + private func updateXPath() { + guard bounds.width > 0 else { return } + // Telegram exact: two diagonal lines in 40×40 canvas, inset 12pt (0.3×40) + // Source: SearchBarPlaceholderNode.swift — 2pt lineWidth, round cap + let canvasSize = min(bounds.width, bounds.height) + let inset = canvasSize * 0.3 + let offsetX = (bounds.width - canvasSize) / 2 + let offsetY = (bounds.height - canvasSize) / 2 + + let path = UIBezierPath() + path.move(to: CGPoint(x: offsetX + inset, y: offsetY + inset)) + path.addLine(to: CGPoint(x: offsetX + canvasSize - inset, y: offsetY + canvasSize - inset)) + path.move(to: CGPoint(x: offsetX + canvasSize - inset, y: offsetY + inset)) + path.addLine(to: CGPoint(x: offsetX + inset, y: offsetY + canvasSize - inset)) + + xLayer.path = path.cgPath + xLayer.frame = bounds + } + + override var isHighlighted: Bool { + didSet { alpha = isHighlighted ? 0.6 : 1.0 } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle { + xLayer.strokeColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }.cgColor + } + } +} + +// MARK: - Glass Action Button (text capsule) + +/// Glass capsule button with text label (e.g. "Next", "Create"). +final class ComposeGlassActionButton: UIControl { + + private let glassView = TelegramGlassUIView(frame: .zero) + private let titleLabel = UILabel() + + var title: String? { + get { titleLabel.text } + set { + titleLabel.text = newValue + invalidateIntrinsicContentSize() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + glassView.isUserInteractionEnabled = false + addSubview(glassView) + + titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) + titleLabel.textColor = UIColor(RosettaColors.Adaptive.text) + titleLabel.textAlignment = .center + addSubview(titleLabel) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override var intrinsicContentSize: CGSize { + let textWidth = titleLabel.intrinsicContentSize.width + return CGSize(width: textWidth + 28, height: 44) + } + + override func layoutSubviews() { + super.layoutSubviews() + glassView.frame = bounds + glassView.fixedCornerRadius = bounds.height * 0.5 + glassView.updateGlass() + titleLabel.frame = bounds + } + + override var isEnabled: Bool { + didSet { + titleLabel.textColor = isEnabled + ? UIColor(RosettaColors.Adaptive.text) + : UIColor(RosettaColors.Adaptive.textSecondary) + alpha = isEnabled ? 1.0 : 0.5 + } + } + + override var isHighlighted: Bool { + didSet { alpha = isHighlighted ? 0.6 : (isEnabled ? 1.0 : 0.5) } + } +} + +// MARK: - Glass Search Bar + +/// Solid capsule search bar matching Telegram search bar (SearchBarNode.swift). +/// Inactive: centered icon + placeholder (Auto Layout centerX/Y). +/// Active: left-aligned icon + textField + inline clear button. +/// Uses Auto Layout internally; parent positions this view via manual `.frame`. +final class ComposeGlassSearchBar: UIView, UITextFieldDelegate { + + var onTextChanged: ((String) -> Void)? + var placeholder: String = "Search" { + didSet { + placeholderLabel.text = placeholder + updatePlaceholderAttributes() + } + } + + private(set) var isActive = false + + // Capsule background + private let capsuleView = UIView() + + // Inactive: centered placeholder stack + private let placeholderStack = UIStackView() + private let placeholderIcon = UIImageView(image: UIImage(systemName: "magnifyingglass")) + private let placeholderLabel = UILabel() + + // Active: left-aligned active stack + private let activeStack = UIStackView() + private let activeIcon = UIImageView(image: UIImage(systemName: "magnifyingglass")) + let textField = UITextField() + private let inlineClearButton = UIButton(type: .system) + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + clipsToBounds = false + setupUI() + applyColors() + updateVisualState(animated: false) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Setup (Auto Layout internal, matches ChatListSearchHeaderView) + + private func setupUI() { + // Capsule fills parent bounds via autoresizingMask (parent sets .frame) + capsuleView.clipsToBounds = true + capsuleView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + addSubview(capsuleView) + + // Inactive: centered placeholder stack (Telegram: spacing 4pt) + placeholderStack.translatesAutoresizingMaskIntoConstraints = false + placeholderStack.axis = .horizontal + placeholderStack.alignment = .center + placeholderStack.spacing = 4 + placeholderIcon.contentMode = .scaleAspectFit + placeholderIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium) + placeholderLabel.text = placeholder + placeholderLabel.font = .systemFont(ofSize: 17) + placeholderStack.addArrangedSubview(placeholderIcon) + placeholderStack.addArrangedSubview(placeholderLabel) + capsuleView.addSubview(placeholderStack) + + // Active: left-aligned stack (Telegram: spacing 2pt, leading 8pt) + activeStack.translatesAutoresizingMaskIntoConstraints = false + activeStack.axis = .horizontal + activeStack.alignment = .center + activeStack.spacing = 2 + activeIcon.contentMode = .scaleAspectFit + activeIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium) + + textField.translatesAutoresizingMaskIntoConstraints = false + textField.font = .systemFont(ofSize: 17) + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + textField.returnKeyType = .search + textField.clearButtonMode = .never + textField.delegate = self + textField.addTarget(self, action: #selector(handleTextChanged), for: .editingChanged) + + inlineClearButton.translatesAutoresizingMaskIntoConstraints = false + inlineClearButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) + inlineClearButton.addTarget(self, action: #selector(handleInlineClearTapped), for: .touchUpInside) + + activeStack.addArrangedSubview(activeIcon) + activeStack.addArrangedSubview(textField) + activeStack.addArrangedSubview(inlineClearButton) + capsuleView.addSubview(activeStack) + + // Tap to activate + let tap = UITapGestureRecognizer(target: self, action: #selector(handleCapsuleTapped)) + capsuleView.addGestureRecognizer(tap) + + // Constraints (matches ChatListSearchHeaderView lines 1252-1264) + NSLayoutConstraint.activate([ + // Placeholder: centered in capsule + placeholderStack.centerXAnchor.constraint(equalTo: capsuleView.centerXAnchor), + placeholderStack.centerYAnchor.constraint(equalTo: capsuleView.centerYAnchor), + + // Active stack: leading 8pt, trailing 10pt, fill height + activeStack.leadingAnchor.constraint(equalTo: capsuleView.leadingAnchor, constant: 8), + activeStack.trailingAnchor.constraint(equalTo: capsuleView.trailingAnchor, constant: -10), + activeStack.topAnchor.constraint(equalTo: capsuleView.topAnchor), + activeStack.bottomAnchor.constraint(equalTo: capsuleView.bottomAnchor), + + // Fixed widths to prevent stretching + activeIcon.widthAnchor.constraint(equalToConstant: 20), + inlineClearButton.widthAnchor.constraint(equalToConstant: 24), + inlineClearButton.heightAnchor.constraint(equalToConstant: 24), + ]) + } + + private func applyColors() { + let isDark = traitCollection.userInterfaceStyle == .dark + let placeholderColor = isDark + ? UIColor(red: 0x8f/255.0, green: 0x8f/255.0, blue: 0x8f/255.0, alpha: 1.0) + : UIColor(red: 0x8e/255.0, green: 0x8e/255.0, blue: 0x93/255.0, alpha: 1.0) + let solidFill = isDark + ? UIColor(red: 0x27/255.0, green: 0x27/255.0, blue: 0x28/255.0, alpha: 1.0) + : UIColor(red: 0xE9/255.0, green: 0xE9/255.0, blue: 0xE9/255.0, alpha: 1.0) + + capsuleView.backgroundColor = solidFill + placeholderLabel.textColor = placeholderColor + placeholderIcon.tintColor = placeholderColor + activeIcon.tintColor = placeholderColor + inlineClearButton.tintColor = placeholderColor + textField.textColor = isDark ? .white : .black + textField.tintColor = UIColor(RosettaColors.figmaBlue) + + updatePlaceholderAttributes() + } + + private func updatePlaceholderAttributes() { + let isDark = traitCollection.userInterfaceStyle == .dark + let placeholderColor = isDark + ? UIColor(red: 0x8f/255.0, green: 0x8f/255.0, blue: 0x8f/255.0, alpha: 1.0) + : UIColor(red: 0x8e/255.0, green: 0x8e/255.0, blue: 0x93/255.0, alpha: 1.0) + let attrs: [NSAttributedString.Key: Any] = [ + .foregroundColor: placeholderColor, + .font: UIFont.systemFont(ofSize: 17) + ] + textField.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: attrs) + } + + override func layoutSubviews() { + super.layoutSubviews() + capsuleView.frame = bounds + capsuleView.layer.cornerRadius = bounds.height * 0.5 + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle { + applyColors() + } + } + + // MARK: - Visual State + + private func updateVisualState(animated: Bool) { + let updates = { + self.placeholderStack.alpha = self.isActive ? 0 : 1 + self.activeStack.alpha = self.isActive ? 1 : 0 + } + + if animated { + UIView.animate( + withDuration: 0.5, delay: 0, + usingSpringWithDamping: 0.78, initialSpringVelocity: 0, + options: [.beginFromCurrentState], + animations: updates + ) + } else { + updates() + } + } + + private func updateClearButtonVisibility() { + let hasText = !(textField.text?.isEmpty ?? true) && isActive + inlineClearButton.alpha = hasText ? 1 : 0 + inlineClearButton.isUserInteractionEnabled = hasText + } + + // MARK: - Activation + + @objc private func handleCapsuleTapped() { + guard !isActive else { return } + setActive(true, animated: true) + } + + func setActive(_ active: Bool, animated: Bool) { + guard active != isActive else { return } + isActive = active + + if active { + DispatchQueue.main.async { [weak self] in + self?.textField.becomeFirstResponder() + } + } else { + textField.resignFirstResponder() + textField.text = "" + onTextChanged?("") + } + + updateClearButtonVisibility() + updateVisualState(animated: animated) + } + + // MARK: - UITextFieldDelegate + + @objc private func handleTextChanged() { + updateClearButtonVisibility() + onTextChanged?(textField.text ?? "") + } + + @objc private func handleInlineClearTapped() { + textField.text = "" + updateClearButtonVisibility() + onTextChanged?("") + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} diff --git a/Rosetta/Features/Compose/ComposeViewController.swift b/Rosetta/Features/Compose/ComposeViewController.swift index 2a033dd..c7f03be 100644 --- a/Rosetta/Features/Compose/ComposeViewController.swift +++ b/Rosetta/Features/Compose/ComposeViewController.swift @@ -2,7 +2,8 @@ import UIKit import SwiftUI /// Screen 1: "New Message" compose screen — Telegram parity. -/// Shows "New Group" / "Join Group" options at top, then 1:1 contacts below. +/// Custom glass header: X close button + "New Message" title. +/// Glass search bar capsule, "New Group" / "Join Group" options, contact list. final class ComposeViewController: UIViewController { // MARK: - Callbacks @@ -27,10 +28,16 @@ final class ComposeViewController: UIViewController { case joinGroup } - // MARK: - Views + // MARK: - Header Views + + private let headerBarHeight: CGFloat = 44 + private let closeButton = ComposeGlassCloseButton() + private let titleLabel = UILabel() + private let glassSearchBar = ComposeGlassSearchBar() + + // MARK: - Content Views private var collectionView: UICollectionView! - private let searchBar = UISearchBar() private var observationTask: Task? // MARK: - Lifecycle @@ -38,11 +45,10 @@ final class ComposeViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white } - title = "New Message" hidesBottomBarWhenPushed = true - setupNavigationBar() - setupSearchBar() + setupCustomHeader() + setupGlassSearchBar() setupCollectionView() loadContacts() startObservationLoop() @@ -52,38 +58,66 @@ final class ComposeViewController: UIViewController { observationTask?.cancel() } - // MARK: - Navigation Bar - - private func setupNavigationBar() { - let appearance = UINavigationBarAppearance() - appearance.configureWithTransparentBackground() - appearance.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white }.withAlphaComponent(0.9) - appearance.titleTextAttributes = [ - .foregroundColor: UIColor(RosettaColors.Adaptive.text), - .font: UIFont.systemFont(ofSize: 17, weight: .semibold) - ] - navigationItem.standardAppearance = appearance - navigationItem.scrollEdgeAppearance = appearance - - navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + layoutCustomHeader() } - // MARK: - Search Bar + // MARK: - Custom Glass Header - private func setupSearchBar() { - searchBar.placeholder = "Search" - searchBar.delegate = self - searchBar.searchBarStyle = .minimal - searchBar.tintColor = UIColor(RosettaColors.figmaBlue) - searchBar.barTintColor = .clear - if let textField = searchBar.searchTextField as? UITextField { - textField.textColor = UIColor(RosettaColors.Adaptive.text) - textField.backgroundColor = UIColor { traits in - traits.userInterfaceStyle == .dark - ? UIColor.white.withAlphaComponent(0.08) - : UIColor.black.withAlphaComponent(0.06) - } + private func setupCustomHeader() { + closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside) + closeButton.layer.zPosition = 55 + view.addSubview(closeButton) + + titleLabel.text = "New Message" + titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) + titleLabel.textColor = UIColor(RosettaColors.Adaptive.text) + titleLabel.textAlignment = .center + titleLabel.layer.zPosition = 55 + view.addSubview(titleLabel) + } + + private func setupGlassSearchBar() { + glassSearchBar.placeholder = "Search" + glassSearchBar.onTextChanged = { [weak self] text in + self?.searchQuery = text + self?.applySearch() } + glassSearchBar.layer.zPosition = 55 + view.addSubview(glassSearchBar) + } + + private func layoutCustomHeader() { + let safeTop = view.safeAreaInsets.top + let centerY = safeTop + headerBarHeight * 0.5 + + // Close button (left) + let closeSize = closeButton.intrinsicContentSize + closeButton.frame = CGRect( + x: 8, + y: centerY - closeSize.height * 0.5, + width: closeSize.width, + height: closeSize.height + ) + + // Title (center) + let titleWidth: CGFloat = 200 + titleLabel.frame = CGRect( + x: (view.bounds.width - titleWidth) / 2, + y: centerY - 11, + width: titleWidth, + height: 22 + ) + + // Search bar (below header) — 44pt height matching chat list + let searchY = safeTop + headerBarHeight + 8 + glassSearchBar.frame = CGRect( + x: 16, + y: searchY, + width: view.bounds.width - 32, + height: 44 + ) } // MARK: - Collection View @@ -98,14 +132,27 @@ final class ComposeViewController: UIViewController { collectionView.keyboardDismissMode = .onDrag collectionView.register(ComposeOptionCell.self, forCellWithReuseIdentifier: ComposeOptionCell.reuseID) collectionView.register(ComposeContactCell.self, forCellWithReuseIdentifier: ComposeContactCell.reuseID) + collectionView.register( + ComposeSectionHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: "SectionHeader" + ) view.addSubview(collectionView) + + // Collection starts below header + search bar + let topOffset = headerBarHeight + 8 + 44 + 8 // header + spacing + search(44pt) + spacing NSLayoutConstraint.activate([ - collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: topOffset), collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) + + // Bring header views above collection + view.bringSubviewToFront(closeButton) + view.bringSubviewToFront(titleLabel) + view.bringSubviewToFront(glassSearchBar) } private func createLayout() -> UICollectionViewCompositionalLayout { @@ -125,18 +172,7 @@ final class ComposeViewController: UIViewController { let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(48)) let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) - let section = NSCollectionLayoutSection(group: group) - - // Search bar as header - let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(56)) - let header = NSCollectionLayoutBoundarySupplementaryItem( - layoutSize: headerSize, - elementKind: UICollectionView.elementKindSectionHeader, - alignment: .top - ) - section.boundarySupplementaryItems = [header] - - return section + return NSCollectionLayoutSection(group: group) } private func contactsSectionLayout() -> NSCollectionLayoutSection { @@ -146,7 +182,6 @@ final class ComposeViewController: UIViewController { let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) - // "CONTACTS" header let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(36)) let header = NSCollectionLayoutBoundarySupplementaryItem( layoutSize: headerSize, @@ -182,11 +217,12 @@ final class ComposeViewController: UIViewController { } private func startObservationLoop() { + // dialogsVersion is @ObservationIgnored — track dialogs directly observationTask = Task { @MainActor [weak self] in while !Task.isCancelled { guard let self else { return } withObservationTracking { - _ = DialogRepository.shared.dialogsVersion + _ = DialogRepository.shared.dialogs } onChange: { } try? await Task.sleep(for: .milliseconds(500)) guard !Task.isCancelled else { return } @@ -197,6 +233,10 @@ final class ComposeViewController: UIViewController { // MARK: - Actions + @objc private func closeTapped() { + navigationController?.popViewController(animated: true) + } + private func openNewGroup() { let picker = GroupContactPickerViewController() picker.onOpenChat = onOpenChat @@ -246,17 +286,14 @@ extension ComposeViewController: UICollectionViewDataSource { let option = OptionRow(rawValue: indexPath.item)! switch option { case .newGroup: - let icon = UIImage(systemName: "person.2.fill") - cell.configure(icon: icon, title: "New Group", showSeparator: true) + cell.configure(icon: UIImage(systemName: "person.2.fill"), title: "New Group", showSeparator: true) case .joinGroup: - let icon = UIImage(systemName: "link.badge.plus") - cell.configure(icon: icon, title: "Join Group", showSeparator: false) + cell.configure(icon: UIImage(systemName: "link.badge.plus"), title: "Join Group", showSeparator: false) } return cell case .contacts: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ComposeContactCell.reuseID, for: indexPath) as! ComposeContactCell - let dialog = filteredContacts[indexPath.item] - cell.configure(with: dialog, isSelected: false, showCheckmark: false) + cell.configure(with: filteredContacts[indexPath.item], isSelected: false, showCheckmark: false) return cell case .none: return UICollectionViewCell() @@ -268,36 +305,18 @@ extension ComposeViewController: UICollectionViewDataSource { viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath ) -> UICollectionReusableView { - collectionView.register( - ComposeSearchHeaderView.self, - forSupplementaryViewOfKind: kind, - withReuseIdentifier: "SearchHeader" - ) - collectionView.register( - ComposeSectionHeaderView.self, - forSupplementaryViewOfKind: kind, - withReuseIdentifier: "SectionHeader" - ) - switch Section(rawValue: indexPath.section) { - case .options: - let header = collectionView.dequeueReusableSupplementaryView( - ofKind: kind, - withReuseIdentifier: "SearchHeader", - for: indexPath - ) as! ComposeSearchHeaderView - header.configure(searchBar: searchBar) - return header case .contacts: let header = collectionView.dequeueReusableSupplementaryView( ofKind: kind, withReuseIdentifier: "SectionHeader", for: indexPath ) as! ComposeSectionHeaderView - header.configure(title: filteredContacts.isEmpty ? "" : "CONTACTS") + header.configure(title: filteredContacts.isEmpty ? "" : "CHATS") return header - case .none: - return UICollectionReusableView() + default: + collectionView.register(UICollectionReusableView.self, forSupplementaryViewOfKind: kind, withReuseIdentifier: "Empty") + return collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Empty", for: indexPath) } } } @@ -311,20 +330,15 @@ extension ComposeViewController: UICollectionViewDelegate { switch Section(rawValue: indexPath.section) { case .options: - let option = OptionRow(rawValue: indexPath.item)! - switch option { + switch OptionRow(rawValue: indexPath.item)! { case .newGroup: openNewGroup() case .joinGroup: openJoinGroup() } case .contacts: - let dialog = filteredContacts[indexPath.item] - let route = ChatRoute(dialog: dialog) - // Pop back to root then open chat + let route = ChatRoute(dialog: filteredContacts[indexPath.item]) if let navController = navigationController { navController.popToRootViewController(animated: false) - DispatchQueue.main.async { - self.onOpenChat?(route) - } + DispatchQueue.main.async { self.onOpenChat?(route) } } case .none: break @@ -332,44 +346,9 @@ extension ComposeViewController: UICollectionViewDelegate { } } -// MARK: - UISearchBarDelegate - -extension ComposeViewController: UISearchBarDelegate { - - func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - searchQuery = searchText - applySearch() - } - - func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { - searchBar.resignFirstResponder() - } -} - -// MARK: - Search Header View - -private final class ComposeSearchHeaderView: UICollectionReusableView { - - private var hostSearchBar: UISearchBar? - - func configure(searchBar: UISearchBar) { - guard hostSearchBar !== searchBar else { return } - hostSearchBar?.removeFromSuperview() - hostSearchBar = searchBar - searchBar.translatesAutoresizingMaskIntoConstraints = false - addSubview(searchBar) - NSLayoutConstraint.activate([ - searchBar.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4), - searchBar.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), - searchBar.centerYAnchor.constraint(equalTo: centerYAnchor), - searchBar.heightAnchor.constraint(equalToConstant: 44), - ]) - } -} - // MARK: - Section Header View -private final class ComposeSectionHeaderView: UICollectionReusableView { +final class ComposeSectionHeaderView: UICollectionReusableView { private let label = UILabel() @@ -385,9 +364,7 @@ private final class ComposeSectionHeaderView: UICollectionReusableView { ]) } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + required init?(coder: NSCoder) { fatalError() } func configure(title: String) { label.text = title diff --git a/Rosetta/Features/Compose/GroupContactPickerViewController.swift b/Rosetta/Features/Compose/GroupContactPickerViewController.swift index 44b296d..f9b1b81 100644 --- a/Rosetta/Features/Compose/GroupContactPickerViewController.swift +++ b/Rosetta/Features/Compose/GroupContactPickerViewController.swift @@ -1,8 +1,9 @@ import UIKit import SwiftUI -/// Screen 2: Multi-select contact picker for group creation. -/// Telegram parity: token chips at top, contact list with checkmarks, counter in title. +/// Screen 2: Multi-select contact picker for group creation — Telegram parity. +/// Custom glass header: back chevron + "New Group" + counter + "Next" button. +/// Glass search bar, token chips, contact list with checkmarks. final class GroupContactPickerViewController: UIViewController { // MARK: - Callbacks @@ -18,25 +19,31 @@ final class GroupContactPickerViewController: UIViewController { private var searchQuery = "" private let maxMembers = 200 - // MARK: - Views + // MARK: - Header Views + + private let headerBarHeight: CGFloat = 44 + private let backButton = ComposeGlassBackButton() + private let titleLabel = UILabel() + private let counterLabel = UILabel() + private let nextButton = ComposeGlassActionButton() + private let glassSearchBar = ComposeGlassSearchBar() + + // MARK: - Token Views private let tokenScrollView = UIScrollView() private let tokenContainerView = UIView() - private let searchTextField = UITextField() - private let tokenSeparator = UIView() - private var collectionView: UICollectionView! private var tokenChips: [TokenChipView] = [] - private var tokenAreaHeightConstraint: NSLayoutConstraint! - private let nextButton = UIBarButtonItem() - private var observationTask: Task? - - // MARK: - Layout - - private let tokenAreaMinHeight: CGFloat = 48 + private let tokenSeparator = UIView() + private let tokenAreaMinHeight: CGFloat = 0 private let tokenHSpacing: CGFloat = 6 private let tokenVSpacing: CGFloat = 6 private let tokenSideInset: CGFloat = 12 + // MARK: - Content + + private var collectionView: UICollectionView! + private var observationTask: Task? + // MARK: - Lifecycle override func viewDidLoad() { @@ -44,118 +51,123 @@ final class GroupContactPickerViewController: UIViewController { view.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white } hidesBottomBarWhenPushed = true - setupNavigationBar() + setupCustomHeader() + setupGlassSearchBar() setupTokenArea() setupCollectionView() loadContacts() startObservationLoop() - updateTitle() + updateHeader() } deinit { observationTask?.cancel() } - // MARK: - Navigation Bar - - private func setupNavigationBar() { - let appearance = UINavigationBarAppearance() - appearance.configureWithTransparentBackground() - appearance.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white }.withAlphaComponent(0.9) - appearance.titleTextAttributes = [ - .foregroundColor: UIColor(RosettaColors.Adaptive.text), - .font: UIFont.systemFont(ofSize: 17, weight: .semibold) - ] - navigationItem.standardAppearance = appearance - navigationItem.scrollEdgeAppearance = appearance - - nextButton.title = "Next" - nextButton.style = .done - nextButton.target = self - nextButton.action = #selector(nextTapped) - nextButton.tintColor = UIColor(RosettaColors.figmaBlue) - nextButton.isEnabled = false - navigationItem.rightBarButtonItem = nextButton + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + layoutCustomHeader() + layoutTokens() } - private func updateTitle() { - title = "New Group" - let subtitle = "\(selectedContacts.count)/\(maxMembers)" - // Use a custom title view with counter - let titleView = UIStackView() - titleView.axis = .vertical - titleView.alignment = .center - titleView.spacing = 0 + // MARK: - Custom Glass Header + + private func setupCustomHeader() { + backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) + backButton.layer.zPosition = 55 + view.addSubview(backButton) - let titleLabel = UILabel() titleLabel.text = "New Group" titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) titleLabel.textColor = UIColor(RosettaColors.Adaptive.text) + titleLabel.textAlignment = .center + titleLabel.layer.zPosition = 55 + view.addSubview(titleLabel) - let subtitleLabel = UILabel() - subtitleLabel.text = subtitle - subtitleLabel.font = .systemFont(ofSize: 12, weight: .regular) - subtitleLabel.textColor = UIColor(RosettaColors.Adaptive.textSecondary) + counterLabel.font = .systemFont(ofSize: 12, weight: .regular) + counterLabel.textColor = UIColor(RosettaColors.Adaptive.textSecondary) + counterLabel.textAlignment = .center + counterLabel.layer.zPosition = 55 + view.addSubview(counterLabel) - titleView.addArrangedSubview(titleLabel) - titleView.addArrangedSubview(subtitleLabel) - - navigationItem.titleView = titleView - nextButton.isEnabled = !selectedContacts.isEmpty + nextButton.title = "Next" + nextButton.isEnabled = true + nextButton.addTarget(self, action: #selector(nextTapped), for: .touchUpInside) + nextButton.layer.zPosition = 55 + view.addSubview(nextButton) } - // MARK: - Token Area + private func setupGlassSearchBar() { + glassSearchBar.placeholder = "Who would you like to add?" + glassSearchBar.onTextChanged = { [weak self] text in + self?.searchQuery = text + self?.applySearch() + } + glassSearchBar.layer.zPosition = 55 + view.addSubview(glassSearchBar) + } + + private var headerContentBottom: CGFloat { + view.safeAreaInsets.top + headerBarHeight + 8 + 44 + 8 // header + gap + search(44pt) + gap + } + + private func layoutCustomHeader() { + let safeTop = view.safeAreaInsets.top + let centerY = safeTop + headerBarHeight * 0.5 + + // Back button (left) + let backSize = backButton.intrinsicContentSize + backButton.frame = CGRect( + x: 8, + y: centerY - backSize.height * 0.5, + width: backSize.width, + height: backSize.height + ) + + // Next button (right) + let nextSize = nextButton.intrinsicContentSize + nextButton.frame = CGRect( + x: view.bounds.width - 8 - nextSize.width, + y: centerY - nextSize.height * 0.5, + width: nextSize.width, + height: nextSize.height + ) + + // Title + counter (center) + let titleWidth: CGFloat = 200 + let titleX = (view.bounds.width - titleWidth) / 2 + titleLabel.frame = CGRect(x: titleX, y: centerY - 18, width: titleWidth, height: 20) + counterLabel.frame = CGRect(x: titleX, y: centerY + 2, width: titleWidth, height: 16) + + // Search bar + let searchY = safeTop + headerBarHeight + 8 + glassSearchBar.frame = CGRect( + x: 16, + y: searchY, + width: view.bounds.width - 32, + height: 44 + ) + } + + private func updateHeader() { + counterLabel.text = "\(selectedContacts.count)/\(maxMembers)" + nextButton.isEnabled = true + } + + // MARK: - Token Area (manual frame layout — no Auto Layout conflicts) private func setupTokenArea() { - tokenScrollView.translatesAutoresizingMaskIntoConstraints = false tokenScrollView.showsVerticalScrollIndicator = false tokenScrollView.showsHorizontalScrollIndicator = false - tokenScrollView.alwaysBounceVertical = false view.addSubview(tokenScrollView) - - tokenContainerView.translatesAutoresizingMaskIntoConstraints = false tokenScrollView.addSubview(tokenContainerView) - // Search text field embedded in token area - searchTextField.placeholder = "Search" - searchTextField.font = .systemFont(ofSize: 16) - searchTextField.textColor = UIColor(RosettaColors.Adaptive.text) - searchTextField.tintColor = UIColor(RosettaColors.figmaBlue) - searchTextField.returnKeyType = .done - searchTextField.addTarget(self, action: #selector(searchTextChanged), for: .editingChanged) - searchTextField.delegate = self - searchTextField.translatesAutoresizingMaskIntoConstraints = false - tokenContainerView.addSubview(searchTextField) - - // Separator tokenSeparator.backgroundColor = UIColor { traits in traits.userInterfaceStyle == .dark ? UIColor.white.withAlphaComponent(0.08) : UIColor.black.withAlphaComponent(0.08) } - tokenSeparator.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tokenSeparator) - - tokenAreaHeightConstraint = tokenScrollView.heightAnchor.constraint(equalToConstant: tokenAreaMinHeight) - - NSLayoutConstraint.activate([ - tokenScrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - tokenScrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tokenScrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tokenAreaHeightConstraint, - - tokenContainerView.topAnchor.constraint(equalTo: tokenScrollView.topAnchor), - tokenContainerView.leadingAnchor.constraint(equalTo: tokenScrollView.leadingAnchor), - tokenContainerView.trailingAnchor.constraint(equalTo: tokenScrollView.trailingAnchor), - tokenContainerView.widthAnchor.constraint(equalTo: tokenScrollView.widthAnchor), - - tokenSeparator.topAnchor.constraint(equalTo: tokenScrollView.bottomAnchor), - tokenSeparator.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tokenSeparator.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tokenSeparator.heightAnchor.constraint(equalToConstant: 0.5), - ]) - - layoutTokens() } // MARK: - Collection View @@ -169,20 +181,21 @@ final class GroupContactPickerViewController: UIViewController { let layout = UICollectionViewCompositionalLayout(section: section) collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) - collectionView.translatesAutoresizingMaskIntoConstraints = false collectionView.backgroundColor = .clear collectionView.delegate = self collectionView.dataSource = self collectionView.keyboardDismissMode = .onDrag collectionView.register(ComposeContactCell.self, forCellWithReuseIdentifier: ComposeContactCell.reuseID) - view.addSubview(collectionView) - NSLayoutConstraint.activate([ - collectionView.topAnchor.constraint(equalTo: tokenSeparator.bottomAnchor), - collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) + + // Header views must be above collection for hit-testing + view.bringSubviewToFront(backButton) + view.bringSubviewToFront(titleLabel) + view.bringSubviewToFront(counterLabel) + view.bringSubviewToFront(nextButton) + view.bringSubviewToFront(glassSearchBar) + view.bringSubviewToFront(tokenScrollView) + view.bringSubviewToFront(tokenSeparator) } // MARK: - Data Loading @@ -209,11 +222,12 @@ final class GroupContactPickerViewController: UIViewController { } private func startObservationLoop() { + // dialogsVersion is @ObservationIgnored — track dialogs directly observationTask = Task { @MainActor [weak self] in while !Task.isCancelled { guard let self else { return } withObservationTracking { - _ = DialogRepository.shared.dialogsVersion + _ = DialogRepository.shared.dialogs } onChange: { } try? await Task.sleep(for: .milliseconds(500)) guard !Task.isCancelled else { return } @@ -225,25 +239,21 @@ final class GroupContactPickerViewController: UIViewController { // MARK: - Selection private func toggleSelection(for dialog: Dialog) { - let key = dialog.opponentKey - if selectedKeys.contains(key) { - deselectContact(key: key) + if selectedKeys.contains(dialog.opponentKey) { + deselectContact(key: dialog.opponentKey) } else { selectContact(dialog) } } private func selectContact(_ dialog: Dialog) { - guard !selectedKeys.contains(dialog.opponentKey) else { return } - guard selectedContacts.count < maxMembers else { return } + guard !selectedKeys.contains(dialog.opponentKey), selectedContacts.count < maxMembers else { return } selectedContacts.append(dialog) selectedKeys.insert(dialog.opponentKey) let chip = TokenChipView(dialog: dialog) - chip.onRemove = { [weak self] key in - self?.deselectContact(key: key) - } + chip.onRemove = { [weak self] key in self?.deselectContact(key: key) } tokenChips.append(chip) tokenContainerView.addSubview(chip) @@ -252,11 +262,11 @@ final class GroupContactPickerViewController: UIViewController { self.view.layoutIfNeeded() } - updateTitle() + updateHeader() reloadVisibleCells() // Clear search after selection - searchTextField.text = "" + glassSearchBar.textField.text = "" searchQuery = "" applySearch() } @@ -265,14 +275,12 @@ final class GroupContactPickerViewController: UIViewController { selectedKeys.remove(key) selectedContacts.removeAll { $0.opponentKey == key } - if let chipIndex = tokenChips.firstIndex(where: { $0.dialogKey == key }) { - let chip = tokenChips.remove(at: chipIndex) + if let idx = tokenChips.firstIndex(where: { $0.dialogKey == key }) { + let chip = tokenChips.remove(at: idx) UIView.animate(withDuration: 0.2, animations: { chip.alpha = 0 chip.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) - }) { _ in - chip.removeFromSuperview() - } + }) { _ in chip.removeFromSuperview() } } UIView.animate(withDuration: 0.25, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.5) { @@ -280,7 +288,7 @@ final class GroupContactPickerViewController: UIViewController { self.view.layoutIfNeeded() } - updateTitle() + updateHeader() reloadVisibleCells() } @@ -292,74 +300,75 @@ final class GroupContactPickerViewController: UIViewController { } } - // MARK: - Token Layout + // MARK: - Token + Collection Layout (all manual frames) private func layoutTokens() { let containerWidth = view.bounds.width guard containerWidth > 0 else { return } - var x: CGFloat = tokenSideInset - var y: CGFloat = tokenVSpacing - let maxX = containerWidth - tokenSideInset - let chipHeight: CGFloat = 28 + let baseY = headerContentBottom + var tokenAreaHeight: CGFloat = 0 - for chip in tokenChips { - let chipWidth = chip.frame.width - if x + chipWidth > maxX && x > tokenSideInset { - x = tokenSideInset - y += chipHeight + tokenVSpacing + if !tokenChips.isEmpty { + tokenSeparator.isHidden = false + var x: CGFloat = tokenSideInset + var y: CGFloat = tokenVSpacing + let maxX = containerWidth - tokenSideInset + let chipHeight: CGFloat = 28 + + for chip in tokenChips { + let chipWidth = chip.frame.width + if x + chipWidth > maxX && x > tokenSideInset { + x = tokenSideInset + y += chipHeight + tokenVSpacing + } + chip.frame.origin = CGPoint(x: x, y: y) + x += chipWidth + tokenHSpacing } - chip.frame.origin = CGPoint(x: x, y: y) - x += chipWidth + tokenHSpacing + + let totalHeight = y + chipHeight + tokenVSpacing + let maxHeight: CGFloat = 100 + let clampedHeight = min(totalHeight, maxHeight) + + tokenScrollView.frame = CGRect(x: 0, y: baseY, width: containerWidth, height: clampedHeight) + tokenContainerView.frame = CGRect(origin: .zero, size: CGSize(width: containerWidth, height: totalHeight)) + tokenScrollView.contentSize = CGSize(width: containerWidth, height: totalHeight) + + if totalHeight > maxHeight { + tokenScrollView.setContentOffset(CGPoint(x: 0, y: totalHeight - maxHeight), animated: true) + } + + tokenAreaHeight = clampedHeight + } else { + tokenScrollView.frame = CGRect(x: 0, y: baseY, width: containerWidth, height: 0) + tokenSeparator.isHidden = true } - // Search field on same line or next line - let searchMinWidth: CGFloat = 80 - if x + searchMinWidth > maxX && x > tokenSideInset { - x = tokenSideInset - y += chipHeight + tokenVSpacing - } - searchTextField.frame = CGRect( - x: x, - y: y, - width: max(searchMinWidth, maxX - x), - height: chipHeight + // Separator + let separatorY = baseY + tokenAreaHeight + tokenSeparator.frame = CGRect(x: 0, y: separatorY, width: containerWidth, height: 0.5) + + // Collection view fills remaining space + let collectionY = separatorY + (tokenChips.isEmpty ? 0 : 0.5) + collectionView.frame = CGRect( + x: 0, + y: collectionY, + width: containerWidth, + height: view.bounds.height - collectionY ) - - let totalHeight = max(tokenAreaMinHeight, y + chipHeight + tokenVSpacing) - let maxHeight: CGFloat = 120 - - tokenContainerView.frame.size = CGSize(width: containerWidth, height: totalHeight) - tokenScrollView.contentSize = CGSize(width: containerWidth, height: totalHeight) - - let clampedHeight = min(totalHeight, maxHeight) - tokenAreaHeightConstraint.constant = clampedHeight - - if totalHeight > maxHeight { - tokenScrollView.setContentOffset( - CGPoint(x: 0, y: totalHeight - maxHeight), - animated: true - ) - } } // MARK: - Actions + @objc private func backTapped() { + navigationController?.popViewController(animated: true) + } + @objc private func nextTapped() { let setupVC = GroupSetupViewController(selectedContacts: selectedContacts) setupVC.onOpenChat = onOpenChat navigationController?.pushViewController(setupVC, animated: true) } - - @objc private func searchTextChanged() { - searchQuery = searchTextField.text ?? "" - applySearch() - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - layoutTokens() - } } // MARK: - UICollectionViewDataSource @@ -373,8 +382,7 @@ extension GroupContactPickerViewController: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ComposeContactCell.reuseID, for: indexPath) as! ComposeContactCell let dialog = filteredContacts[indexPath.item] - let isSelected = selectedKeys.contains(dialog.opponentKey) - cell.configure(with: dialog, isSelected: isSelected, showCheckmark: true) + cell.configure(with: dialog, isSelected: selectedKeys.contains(dialog.opponentKey), showCheckmark: true) return cell } } @@ -385,17 +393,6 @@ extension GroupContactPickerViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { collectionView.deselectItem(at: indexPath, animated: true) - let dialog = filteredContacts[indexPath.item] - toggleSelection(for: dialog) - } -} - -// MARK: - UITextFieldDelegate - -extension GroupContactPickerViewController: UITextFieldDelegate { - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - textField.resignFirstResponder() - return true + toggleSelection(for: filteredContacts[indexPath.item]) } } diff --git a/Rosetta/Features/Compose/GroupSetupViewController.swift b/Rosetta/Features/Compose/GroupSetupViewController.swift index 025bd6e..27393ff 100644 --- a/Rosetta/Features/Compose/GroupSetupViewController.swift +++ b/Rosetta/Features/Compose/GroupSetupViewController.swift @@ -1,8 +1,9 @@ import UIKit import SwiftUI -/// Screen 3: Group name + avatar + selected members list + Create button. -/// UIKit replacement for GroupSetupView, accepts pre-selected contacts. +/// Screen 3: Group name + avatar + selected members list + Create button — Telegram parity. +/// Custom glass header: back chevron + "New Group" + glass "Create" button. +/// Horizontal avatar + name field in glass card (matching Telegram layout). final class GroupSetupViewController: UIViewController { // MARK: - Callbacks @@ -13,29 +14,40 @@ final class GroupSetupViewController: UIViewController { private let selectedContacts: [Dialog] private var groupTitle = "" + private var groupDescription = "" + private var selectedPhoto: UIImage? private var isCreating = false // MARK: - Layout Constants private enum Layout { - static let avatarSize: CGFloat = 80 + static let avatarSize: CGFloat = 64 static let maxTitleLength = 80 + static let maxDescriptionLength = 255 static let sideInset: CGFloat = 16 + static let headerBarHeight: CGFloat = 44 } - // MARK: - Views + // MARK: - Header Views + + private let backButton = ComposeGlassBackButton() + private let headerTitleLabel = UILabel() + private let createButton = ComposeGlassActionButton() + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + + // MARK: - Content Views private let scrollView = UIScrollView() private let contentView = UIView() + private let infoCard = TelegramGlassUIView(frame: .zero) private let avatarContainer = UIView() + private let avatarImageView = UIImageView() private let cameraIcon = UIImageView() private let nameTextField = UITextField() - private let nameBackground = UIView() - private let charCountLabel = UILabel() + private let descriptionTextField = UITextField() + private let descriptionSeparator = UIView() private let membersHeaderLabel = UILabel() private var memberViews: [UIView] = [] - private let createButton = UIBarButtonItem() - private let loadingIndicator = UIActivityIndicatorView(style: .medium) // MARK: - Init @@ -44,9 +56,7 @@ final class GroupSetupViewController: UIViewController { super.init(nibName: nil, bundle: nil) } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + required init?(coder: NSCoder) { fatalError() } // MARK: - Lifecycle @@ -55,37 +65,67 @@ final class GroupSetupViewController: UIViewController { view.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white } hidesBottomBarWhenPushed = true - setupNavigationBar() + setupCustomHeader() setupScrollView() - setupAvatarSection() - setupNameField() + setupInfoCard() setupMembersSection() nameTextField.becomeFirstResponder() } - // MARK: - Navigation Bar + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + layoutCustomHeader() + } - private func setupNavigationBar() { - let appearance = UINavigationBarAppearance() - appearance.configureWithTransparentBackground() - appearance.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white }.withAlphaComponent(0.9) - appearance.titleTextAttributes = [ - .foregroundColor: UIColor(RosettaColors.Adaptive.text), - .font: UIFont.systemFont(ofSize: 17, weight: .semibold) - ] - navigationItem.standardAppearance = appearance - navigationItem.scrollEdgeAppearance = appearance + // MARK: - Custom Glass Header - title = "New Group" + private func setupCustomHeader() { + backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) + backButton.layer.zPosition = 55 + view.addSubview(backButton) + + headerTitleLabel.text = "New Group" + headerTitleLabel.font = .systemFont(ofSize: 17, weight: .semibold) + headerTitleLabel.textColor = UIColor(RosettaColors.Adaptive.text) + headerTitleLabel.textAlignment = .center + headerTitleLabel.layer.zPosition = 55 + view.addSubview(headerTitleLabel) createButton.title = "Create" - createButton.style = .done - createButton.target = self - createButton.action = #selector(createTapped) - createButton.tintColor = UIColor(RosettaColors.figmaBlue) createButton.isEnabled = false - navigationItem.rightBarButtonItem = createButton + createButton.addTarget(self, action: #selector(createTapped), for: .touchUpInside) + createButton.layer.zPosition = 55 + view.addSubview(createButton) + } + + private func layoutCustomHeader() { + let safeTop = view.safeAreaInsets.top + let centerY = safeTop + Layout.headerBarHeight * 0.5 + + let backSize = backButton.intrinsicContentSize + backButton.frame = CGRect( + x: 8, + y: centerY - backSize.height * 0.5, + width: backSize.width, + height: backSize.height + ) + + let createSize = createButton.intrinsicContentSize + createButton.frame = CGRect( + x: view.bounds.width - 8 - createSize.width, + y: centerY - createSize.height * 0.5, + width: createSize.width, + height: createSize.height + ) + + let titleWidth: CGFloat = 200 + headerTitleLabel.frame = CGRect( + x: (view.bounds.width - titleWidth) / 2, + y: centerY - 11, + width: titleWidth, + height: 22 + ) } // MARK: - Scroll View @@ -98,8 +138,10 @@ final class GroupSetupViewController: UIViewController { contentView.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(contentView) + let topOffset = Layout.headerBarHeight + 16 + NSLayoutConstraint.activate([ - scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: topOffset), scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), @@ -110,79 +152,125 @@ final class GroupSetupViewController: UIViewController { contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), ]) + + view.bringSubviewToFront(backButton) + view.bringSubviewToFront(headerTitleLabel) + view.bringSubviewToFront(createButton) } - // MARK: - Avatar Section + // MARK: - Info Card (Avatar + Name + Description) - private func setupAvatarSection() { + private func setupInfoCard() { + // Glass card background + infoCard.translatesAutoresizingMaskIntoConstraints = false + infoCard.isUserInteractionEnabled = false + infoCard.fixedCornerRadius = 20 + contentView.addSubview(infoCard) + + // Avatar circle (tappable for photo picker) avatarContainer.translatesAutoresizingMaskIntoConstraints = false avatarContainer.backgroundColor = UIColor(RosettaColors.figmaBlue).withAlphaComponent(0.2) avatarContainer.layer.cornerRadius = Layout.avatarSize / 2 avatarContainer.clipsToBounds = true + let avatarTap = UITapGestureRecognizer(target: self, action: #selector(avatarTapped)) + avatarContainer.addGestureRecognizer(avatarTap) contentView.addSubview(avatarContainer) - let config = UIImage.SymbolConfiguration(pointSize: 28, weight: .medium) + // Avatar image (shown after photo selection) + avatarImageView.contentMode = .scaleAspectFill + avatarImageView.clipsToBounds = true + avatarImageView.layer.cornerRadius = Layout.avatarSize / 2 + avatarImageView.translatesAutoresizingMaskIntoConstraints = false + avatarImageView.isHidden = true + avatarContainer.addSubview(avatarImageView) + + let config = UIImage.SymbolConfiguration(pointSize: 24, weight: .medium) cameraIcon.image = UIImage(systemName: "camera.fill", withConfiguration: config) cameraIcon.tintColor = UIColor(RosettaColors.figmaBlue) cameraIcon.contentMode = .scaleAspectFit cameraIcon.translatesAutoresizingMaskIntoConstraints = false avatarContainer.addSubview(cameraIcon) - NSLayoutConstraint.activate([ - avatarContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 24), - avatarContainer.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), - avatarContainer.widthAnchor.constraint(equalToConstant: Layout.avatarSize), - avatarContainer.heightAnchor.constraint(equalToConstant: Layout.avatarSize), - - cameraIcon.centerXAnchor.constraint(equalTo: avatarContainer.centerXAnchor), - cameraIcon.centerYAnchor.constraint(equalTo: avatarContainer.centerYAnchor), - ]) - } - - // MARK: - Name Field - - private func setupNameField() { - // Background - nameBackground.translatesAutoresizingMaskIntoConstraints = false - nameBackground.backgroundColor = UIColor { traits in - traits.userInterfaceStyle == .dark - ? UIColor.white.withAlphaComponent(0.08) - : UIColor.black.withAlphaComponent(0.04) - } - nameBackground.layer.cornerRadius = 12 - contentView.addSubview(nameBackground) - - // Text field + // Name text field nameTextField.translatesAutoresizingMaskIntoConstraints = false nameTextField.placeholder = "Group Name" nameTextField.font = .systemFont(ofSize: 17) nameTextField.textColor = UIColor(RosettaColors.Adaptive.text) nameTextField.tintColor = UIColor(RosettaColors.figmaBlue) - nameTextField.returnKeyType = .done + nameTextField.returnKeyType = .next nameTextField.delegate = self nameTextField.addTarget(self, action: #selector(nameChanged), for: .editingChanged) + + let nameAttrs: [NSAttributedString.Key: Any] = [ + .foregroundColor: UIColor(RosettaColors.Adaptive.textSecondary), + .font: UIFont.systemFont(ofSize: 17) + ] + nameTextField.attributedPlaceholder = NSAttributedString(string: "Group Name", attributes: nameAttrs) contentView.addSubview(nameTextField) - // Character count - charCountLabel.translatesAutoresizingMaskIntoConstraints = false - charCountLabel.font = .systemFont(ofSize: 12) - charCountLabel.textColor = UIColor(RosettaColors.Adaptive.textSecondary) - charCountLabel.text = "0/\(Layout.maxTitleLength)" - charCountLabel.textAlignment = .right - contentView.addSubview(charCountLabel) + // Separator between name and description + descriptionSeparator.translatesAutoresizingMaskIntoConstraints = false + descriptionSeparator.backgroundColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor.white.withAlphaComponent(0.08) + : UIColor.black.withAlphaComponent(0.08) + } + contentView.addSubview(descriptionSeparator) + + // Description text field + descriptionTextField.translatesAutoresizingMaskIntoConstraints = false + descriptionTextField.placeholder = "Description" + descriptionTextField.font = .systemFont(ofSize: 17) + descriptionTextField.textColor = UIColor(RosettaColors.Adaptive.text) + descriptionTextField.tintColor = UIColor(RosettaColors.figmaBlue) + descriptionTextField.returnKeyType = .done + descriptionTextField.delegate = self + descriptionTextField.addTarget(self, action: #selector(descriptionChanged), for: .editingChanged) + + let descAttrs: [NSAttributedString.Key: Any] = [ + .foregroundColor: UIColor(RosettaColors.Adaptive.textSecondary), + .font: UIFont.systemFont(ofSize: 17) + ] + descriptionTextField.attributedPlaceholder = NSAttributedString(string: "Description", attributes: descAttrs) + contentView.addSubview(descriptionTextField) + + // Card height: avatar area (name row) + separator + description row + let nameRowHeight: CGFloat = Layout.avatarSize + 24 + let descRowHeight: CGFloat = 44 + let cardHeight: CGFloat = nameRowHeight + descRowHeight NSLayoutConstraint.activate([ - nameBackground.topAnchor.constraint(equalTo: avatarContainer.bottomAnchor, constant: 24), - nameBackground.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Layout.sideInset), - nameBackground.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -Layout.sideInset), - nameBackground.heightAnchor.constraint(equalToConstant: 44), + infoCard.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + infoCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Layout.sideInset), + infoCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -Layout.sideInset), + infoCard.heightAnchor.constraint(equalToConstant: cardHeight), - nameTextField.leadingAnchor.constraint(equalTo: nameBackground.leadingAnchor, constant: 16), - nameTextField.trailingAnchor.constraint(equalTo: nameBackground.trailingAnchor, constant: -16), - nameTextField.centerYAnchor.constraint(equalTo: nameBackground.centerYAnchor), + avatarContainer.leadingAnchor.constraint(equalTo: infoCard.leadingAnchor, constant: 12), + avatarContainer.topAnchor.constraint(equalTo: infoCard.topAnchor, constant: 12), + avatarContainer.widthAnchor.constraint(equalToConstant: Layout.avatarSize), + avatarContainer.heightAnchor.constraint(equalToConstant: Layout.avatarSize), - charCountLabel.topAnchor.constraint(equalTo: nameBackground.bottomAnchor, constant: 4), - charCountLabel.trailingAnchor.constraint(equalTo: nameBackground.trailingAnchor), + avatarImageView.topAnchor.constraint(equalTo: avatarContainer.topAnchor), + avatarImageView.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor), + avatarImageView.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor), + avatarImageView.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor), + + cameraIcon.centerXAnchor.constraint(equalTo: avatarContainer.centerXAnchor), + cameraIcon.centerYAnchor.constraint(equalTo: avatarContainer.centerYAnchor), + + nameTextField.leadingAnchor.constraint(equalTo: avatarContainer.trailingAnchor, constant: 12), + nameTextField.trailingAnchor.constraint(equalTo: infoCard.trailingAnchor, constant: -12), + nameTextField.centerYAnchor.constraint(equalTo: avatarContainer.centerYAnchor), + + descriptionSeparator.topAnchor.constraint(equalTo: infoCard.topAnchor, constant: nameRowHeight), + descriptionSeparator.leadingAnchor.constraint(equalTo: infoCard.leadingAnchor, constant: 12), + descriptionSeparator.trailingAnchor.constraint(equalTo: infoCard.trailingAnchor, constant: -12), + descriptionSeparator.heightAnchor.constraint(equalToConstant: 0.5), + + descriptionTextField.topAnchor.constraint(equalTo: descriptionSeparator.bottomAnchor), + descriptionTextField.leadingAnchor.constraint(equalTo: infoCard.leadingAnchor, constant: 12), + descriptionTextField.trailingAnchor.constraint(equalTo: infoCard.trailingAnchor, constant: -12), + descriptionTextField.bottomAnchor.constraint(equalTo: infoCard.bottomAnchor), ]) } @@ -190,20 +278,18 @@ final class GroupSetupViewController: UIViewController { private func setupMembersSection() { guard !selectedContacts.isEmpty else { - // Bottom constraint when no members - let bottomAnchor = charCountLabel.bottomAnchor - contentView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 20).isActive = true + contentView.bottomAnchor.constraint(equalTo: infoCard.bottomAnchor, constant: 20).isActive = true return } membersHeaderLabel.translatesAutoresizingMaskIntoConstraints = false - membersHeaderLabel.text = "MEMBERS (\(selectedContacts.count))" + membersHeaderLabel.text = "\(selectedContacts.count) MEMBERS" membersHeaderLabel.font = .systemFont(ofSize: 13, weight: .regular) membersHeaderLabel.textColor = UIColor(RosettaColors.Adaptive.textSecondary) contentView.addSubview(membersHeaderLabel) NSLayoutConstraint.activate([ - membersHeaderLabel.topAnchor.constraint(equalTo: charCountLabel.bottomAnchor, constant: 24), + membersHeaderLabel.topAnchor.constraint(equalTo: infoCard.bottomAnchor, constant: 24), membersHeaderLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Layout.sideInset), ]) @@ -230,9 +316,7 @@ final class GroupSetupViewController: UIViewController { private func createMemberRow(dialog: Dialog, isLast: Bool) -> UIView { let row = UIView() - row.backgroundColor = .clear - // Avatar let avatarSize: CGFloat = 36 let avatarBg = UIView() avatarBg.clipsToBounds = true @@ -276,7 +360,6 @@ final class GroupSetupViewController: UIViewController { } } - // Name label let nameLabel = UILabel() nameLabel.text = dialog.opponentTitle nameLabel.font = .systemFont(ofSize: 16) @@ -284,7 +367,6 @@ final class GroupSetupViewController: UIViewController { nameLabel.translatesAutoresizingMaskIntoConstraints = false row.addSubview(nameLabel) - // Separator if !isLast { let separator = UIView() separator.backgroundColor = UIColor { traits in @@ -326,6 +408,10 @@ final class GroupSetupViewController: UIViewController { // MARK: - Actions + @objc private func backTapped() { + navigationController?.popViewController(animated: true) + } + @objc private func nameChanged() { var text = nameTextField.text ?? "" if text.count > Layout.maxTitleLength { @@ -333,10 +419,26 @@ final class GroupSetupViewController: UIViewController { nameTextField.text = text } groupTitle = text - charCountLabel.text = "\(text.count)/\(Layout.maxTitleLength)" createButton.isEnabled = !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isCreating } + @objc private func descriptionChanged() { + var text = descriptionTextField.text ?? "" + if text.count > Layout.maxDescriptionLength { + text = String(text.prefix(Layout.maxDescriptionLength)) + descriptionTextField.text = text + } + groupDescription = text + } + + @objc private func avatarTapped() { + let picker = UIImagePickerController() + picker.sourceType = .photoLibrary + picker.delegate = self + picker.allowsEditing = true + present(picker, animated: true) + } + @objc private func createTapped() { guard !isCreating else { return } let trimmedTitle = groupTitle.trimmingCharacters(in: .whitespacesAndNewlines) @@ -345,36 +447,45 @@ final class GroupSetupViewController: UIViewController { isCreating = true createButton.isEnabled = false nameTextField.isEnabled = false + descriptionTextField.isEnabled = false - // Show loading + // Replace create button with loading indicator loadingIndicator.color = UIColor(RosettaColors.Adaptive.text) - navigationItem.rightBarButtonItem = UIBarButtonItem(customView: loadingIndicator) + loadingIndicator.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(loadingIndicator) + loadingIndicator.frame = createButton.frame loadingIndicator.startAnimating() + createButton.isHidden = true let contactKeys = selectedContacts.map(\.opponentKey) + let trimmedDescription = groupDescription.trimmingCharacters(in: .whitespacesAndNewlines) + let photo = selectedPhoto Task { @MainActor in do { let route = try await GroupService.shared.createGroupAndSendInvites( title: trimmedTitle, - description: "", + description: trimmedDescription, contactKeys: contactKeys ) - // Pop entire compose stack and open new group chat + // Save selected photo as group avatar locally + if let photo { + AvatarRepository.shared.saveAvatar(publicKey: route.publicKey, image: photo) + } + guard let navController = navigationController else { return } if let rootVC = navController.viewControllers.first { navController.setViewControllers([rootVC], animated: false) - DispatchQueue.main.async { - self.onOpenChat?(route) - } + DispatchQueue.main.async { self.onOpenChat?(route) } } } catch { isCreating = false createButton.isEnabled = true + createButton.isHidden = false nameTextField.isEnabled = true - navigationItem.rightBarButtonItem = createButton - loadingIndicator.stopAnimating() + descriptionTextField.isEnabled = true + loadingIndicator.removeFromSuperview() let alert = UIAlertController(title: "Error", message: error.localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .cancel)) @@ -389,7 +500,11 @@ final class GroupSetupViewController: UIViewController { extension GroupSetupViewController: UITextFieldDelegate { func textFieldShouldReturn(_ textField: UITextField) -> Bool { - textField.resignFirstResponder() + if textField === nameTextField { + descriptionTextField.becomeFirstResponder() + } else { + textField.resignFirstResponder() + } return true } @@ -397,6 +512,25 @@ extension GroupSetupViewController: UITextFieldDelegate { let currentText = textField.text ?? "" guard let range = Range(range, in: currentText) else { return true } let newText = currentText.replacingCharacters(in: range, with: string) - return newText.count <= Layout.maxTitleLength + let maxLength = textField === nameTextField ? Layout.maxTitleLength : Layout.maxDescriptionLength + return newText.count <= maxLength + } +} + +// MARK: - UIImagePickerControllerDelegate + +extension GroupSetupViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + picker.dismiss(animated: true) + guard let image = info[.editedImage] as? UIImage ?? info[.originalImage] as? UIImage else { return } + selectedPhoto = image + avatarImageView.image = image + avatarImageView.isHidden = false + cameraIcon.isHidden = true + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) } } diff --git a/Rosetta/Features/Groups/GroupInfoView.swift b/Rosetta/Features/Groups/GroupInfoView.swift index 461ac3b..e7783cf 100644 --- a/Rosetta/Features/Groups/GroupInfoView.swift +++ b/Rosetta/Features/Groups/GroupInfoView.swift @@ -19,6 +19,7 @@ struct GroupInfoView: View { @State private var showEncryptionKeyPage = false @State private var isLargeHeader = false @State private var topInset: CGFloat = 0 + @State private var navController: UINavigationController? @Namespace private var tabNamespace @Environment(\.colorScheme) private var colorScheme @@ -35,6 +36,9 @@ struct GroupInfoView: View { init(groupDialogKey: String) { _viewModel = StateObject(wrappedValue: GroupInfoViewModel(groupDialogKey: groupDialogKey)) + // Start expanded if group already has avatar photo (no animation jump) + let hasAvatar = AvatarRepository.shared.loadAvatar(publicKey: groupDialogKey) != nil + _isLargeHeader = State(initialValue: hasAvatar) } private var groupAvatar: UIImage? { @@ -74,23 +78,30 @@ struct GroupInfoView: View { .background { TelegramGlassCapsule() } } .buttonStyle(.plain) + .padding(.leading, -8) // align with ChatDetail back button (8pt from edge) } - // Edit button removed — not needed for Telegram parity } .toolbarBackground(.hidden, for: .navigationBar) - .task { - // Start expanded if group has avatar photo - if groupAvatar != nil { - isLargeHeader = true + .background { + // Invisible UIKit bridge to access the UINavigationController + NavigationControllerAccessor { nav in + self.navController = nav } } - .navigationDestination(isPresented: $showMemberChat) { - if let route = selectedMemberRoute { - ChatDetailView(route: route) - } + .onChange(of: showMemberChat) { show in + guard show, let route = selectedMemberRoute else { return } + showMemberChat = false + let profile = OpponentProfileView(route: route) + let vc = UIHostingController(rootView: profile) + vc.navigationItem.hidesBackButton = true + navController?.pushViewController(vc, animated: true) } - .navigationDestination(isPresented: $showEncryptionKeyPage) { - EncryptionKeyView(groupDialogKey: viewModel.groupDialogKey) + .onChange(of: showEncryptionKeyPage) { show in + guard show else { return } + showEncryptionKeyPage = false + let vc = UIHostingController(rootView: EncryptionKeyView(groupDialogKey: viewModel.groupDialogKey)) + vc.navigationItem.hidesBackButton = true + navController?.pushViewController(vc, animated: true) } .task { await viewModel.loadMembers() @@ -101,41 +112,66 @@ struct GroupInfoView: View { .onChange(of: viewModel.didLeaveGroup) { left in if left { dismiss() } } - .confirmationDialog("", isPresented: $showMoreSheet, titleVisibility: .hidden) { - moreSheetButtons - } - .alert("Leave Group", isPresented: $showLeaveAlert) { - Button("Leave", role: .destructive) { - Task { await viewModel.leaveGroup() } + .modifier(GroupInfoAlertsModifier( + showMoreSheet: $showMoreSheet, + showLeaveAlert: $showLeaveAlert, + memberToKick: $memberToKick, + hasError: viewModel.errorMessage != nil, + errorText: viewModel.errorMessage ?? "", + onDismissError: { viewModel.errorMessage = nil }, + onLeave: { Task { await viewModel.leaveGroup() } }, + onKick: { key in Task { await viewModel.kickMember(publicKey: key) } }, + moreSheetContent: moreSheetButtons + )) + } +} + +// MARK: - Alerts Modifier (extracted to fix compiler type-check limit) + +private struct GroupInfoAlertsModifier: ViewModifier { + @Binding var showMoreSheet: Bool + @Binding var showLeaveAlert: Bool + @Binding var memberToKick: GroupMember? + let hasError: Bool + let errorText: String + let onDismissError: () -> Void + let onLeave: () -> Void + let onKick: (String) -> Void + let moreSheetContent: SheetContent + + func body(content: Content) -> some View { + content + .confirmationDialog("", isPresented: $showMoreSheet, titleVisibility: .hidden) { + moreSheetContent } - Button("Cancel", role: .cancel) {} - } message: { - Text("Are you sure you want to leave this group? You will lose access to all messages.") - } - .alert("Remove Member", isPresented: .init( - get: { memberToKick != nil }, - set: { if !$0 { memberToKick = nil } } - )) { - Button("Remove", role: .destructive) { - if let member = memberToKick { - Task { await viewModel.kickMember(publicKey: member.id) } + .alert("Leave Group", isPresented: $showLeaveAlert) { + Button("Leave", role: .destructive, action: onLeave) + Button("Cancel", role: .cancel) {} + } message: { + Text("Are you sure you want to leave this group? You will lose access to all messages.") + } + .alert("Remove Member", isPresented: .init( + get: { memberToKick != nil }, + set: { if !$0 { memberToKick = nil } } + )) { + Button("Remove", role: .destructive) { + if let member = memberToKick { onKick(member.id) } + memberToKick = nil + } + Button("Cancel", role: .cancel) { memberToKick = nil } + } message: { + if let member = memberToKick { + Text("Remove \(member.title) from this group?") } - memberToKick = nil } - Button("Cancel", role: .cancel) { memberToKick = nil } - } message: { - if let member = memberToKick { - Text("Remove \(member.title) from this group?") + .alert("Error", isPresented: .init( + get: { hasError }, + set: { if !$0 { onDismissError() } } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(errorText) } - } - .alert("Error", isPresented: .init( - get: { viewModel.errorMessage != nil }, - set: { if !$0 { viewModel.errorMessage = nil } } - )) { - Button("OK", role: .cancel) {} - } message: { - if let msg = viewModel.errorMessage { Text(msg) } - } } } @@ -766,4 +802,48 @@ private struct GroupIOS18ScrollTracker: View { scrollPhase = newPhase } } + +} + +// MARK: - UIKit Navigation Bridge + +/// Invisible UIView that captures the nearest UINavigationController via responder chain. +private struct NavigationControllerAccessor: UIViewRepresentable { + let callback: (UINavigationController?) -> Void + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.isHidden = true + view.isUserInteractionEnabled = false + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + // Walk the responder chain to find the owning UINavigationController + DispatchQueue.main.async { + var responder: UIResponder? = uiView + while let r = responder { + if let nav = r as? UINavigationController { + callback(nav) + return + } + responder = r.next + } + // Fallback: check parent VC + if let vc = uiView.parentViewController { + callback(vc.navigationController) + } + } + } +} + +private extension UIView { + var parentViewController: UIViewController? { + var responder: UIResponder? = self.next + while let r = responder { + if let vc = r as? UIViewController { return vc } + responder = r.next + } + return nil + } } diff --git a/Rosetta/Features/Groups/GroupJoinView.swift b/Rosetta/Features/Groups/GroupJoinView.swift index 18d8987..67f1590 100644 --- a/Rosetta/Features/Groups/GroupJoinView.swift +++ b/Rosetta/Features/Groups/GroupJoinView.swift @@ -75,9 +75,24 @@ struct GroupJoinView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { dismiss() } - .font(.system(size: 17, weight: .medium)) + Button { dismiss() } label: { + // Telegram exact: two diagonal lines, 2pt lineWidth, round cap, inset 0.3 + // Matches ComposeGlassCloseButton (SearchBarPlaceholderNode.swift parity) + Canvas { context, size in + let inset = min(size.width, size.height) * 0.3 + var path = Path() + path.move(to: CGPoint(x: inset, y: inset)) + path.addLine(to: CGPoint(x: size.width - inset, y: size.height - inset)) + path.move(to: CGPoint(x: size.width - inset, y: inset)) + path.addLine(to: CGPoint(x: inset, y: size.height - inset)) + context.stroke(path, with: .foreground, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + } .foregroundStyle(RosettaColors.Adaptive.text) + .frame(width: 44, height: 44) + .contentShape(Circle()) + .background { TelegramGlassCapsule() } + } + .buttonStyle(.plain) } ToolbarItem(placement: .principal) { Text("Join Group") diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 0054e89..3a2fcb9 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -871,7 +871,10 @@ struct RosettaApp: App { DispatchQueue.main.async { guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = scene.windows.first(where: { $0.tag != 0320 }) else { return } - let bgColor: UIColor = window.traitCollection.userInterfaceStyle == .dark ? .black : .white + // Dynamic UIColor — auto-resolves on trait change (theme switch). + // Static color here caused white/black flash: rootVC.view.backgroundColor + // stayed stale after overrideUserInterfaceStyle changed. + let bgColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white } window.backgroundColor = bgColor window.rootViewController?.view.backgroundColor = bgColor }