Флоу создания группы — glass search bar, выбор фото, поле описания, кнопка X в Join Group

This commit is contained in:
2026-04-16 08:46:34 +05:00
parent b30882e56d
commit bc478ab484
18 changed files with 1741 additions and 611 deletions

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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<Content: View>: 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<Content: View>: 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" }
}
}

View File

@@ -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,

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -154,8 +154,8 @@ final class NativeMessageListController: UIViewController {
return v
}()
// MARK: - Empty State (UIKit-managed, animates with keyboard)
private var emptyStateHosting: UIHostingController<EmptyChatContent>?
// 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
)
}
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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
}
}

View File

@@ -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<Void, Never>?
// 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

View File

@@ -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<Void, Never>?
// 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<Void, Never>?
// 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])
}
}

View File

@@ -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)
}
}

View File

@@ -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<SheetContent: View>: 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<Content: View>: 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
}
}

View File

@@ -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")

View File

@@ -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
}