Флоу создания группы — glass search bar, выбор фото, поле описания, кнопка X в Join Group
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
413
Rosetta/Features/Compose/ComposeGlassComponents.swift
Normal file
413
Rosetta/Features/Compose/ComposeGlassComponents.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user