diff --git a/submodules/TelegramCore/Sources/Account/AccountManager.swift b/submodules/TelegramCore/Sources/Account/AccountManager.swift index e9ea0821e5..a4c2fd8d77 100644 --- a/submodules/TelegramCore/Sources/Account/AccountManager.swift +++ b/submodules/TelegramCore/Sources/Account/AccountManager.swift @@ -102,6 +102,7 @@ private var declaredEncodables: Void = { declareEncodable(RegularChatState.self, f: { RegularChatState(decoder: $0) }) declareEncodable(InlineBotMessageAttribute.self, f: { InlineBotMessageAttribute(decoder: $0) }) declareEncodable(InlineBusinessBotMessageAttribute.self, f: { InlineBusinessBotMessageAttribute(decoder: $0) }) + declareEncodable(TGEventAttribute.self, f: { TGEventAttribute(decoder: $0) }) declareEncodable(TextEntitiesMessageAttribute.self, f: { TextEntitiesMessageAttribute(decoder: $0) }) declareEncodable(ReplyMessageAttribute.self, f: { ReplyMessageAttribute(decoder: $0) }) declareEncodable(QuotedReplyMessageAttribute.self, f: { QuotedReplyMessageAttribute(decoder: $0) }) diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TGEventAttribute.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TGEventAttribute.swift new file mode 100644 index 0000000000..46179e1c4e --- /dev/null +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TGEventAttribute.swift @@ -0,0 +1,41 @@ +import Foundation +import Postbox + +public final class TGEventAttribute: MessageAttribute { + public let eventId: String + public let title: String + public let startTimestamp: Double + public let endTimestamp: Double + public let location: String? + + public var associatedPeerIds: [PeerId] { [] } + public var associatedMessageIds: [MessageId] { [] } + + public init(eventId: String, title: String, startTimestamp: Double, endTimestamp: Double, location: String?) { + self.eventId = eventId + self.title = title + self.startTimestamp = startTimestamp + self.endTimestamp = endTimestamp + self.location = location + } + + required public init(decoder: PostboxDecoder) { + self.eventId = decoder.decodeStringForKey("eid", orElse: UUID().uuidString) + self.title = decoder.decodeStringForKey("t", orElse: "") + self.startTimestamp = decoder.decodeDoubleForKey("s", orElse: 0) + self.endTimestamp = decoder.decodeDoubleForKey("e", orElse: 0) + self.location = decoder.decodeOptionalStringForKey("l") + } + + public func encode(_ encoder: PostboxEncoder) { + encoder.encodeString(eventId, forKey: "eid") + encoder.encodeString(title, forKey: "t") + encoder.encodeDouble(startTimestamp, forKey: "s") + encoder.encodeDouble(endTimestamp, forKey: "e") + if let location { + encoder.encodeString(location, forKey: "l") + } else { + encoder.encodeNil(forKey: "l") + } + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat List/Tabs/IconEvents.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat List/Tabs/IconEvents.imageset/Contents.json new file mode 100644 index 0000000000..dbf8bc7d29 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat List/Tabs/IconEvents.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "ic_tb_events.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat List/Tabs/IconEvents.imageset/ic_tb_events.pdf b/submodules/TelegramUI/Images.xcassets/Chat List/Tabs/IconEvents.imageset/ic_tb_events.pdf new file mode 100644 index 0000000000..34d8cf38ce --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat List/Tabs/IconEvents.imageset/ic_tb_events.pdf @@ -0,0 +1,29 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 30 30] /Contents 4 0 R /Resources << >> >> +endobj +4 0 obj +<< /Filter /FlateDecode /Length 224 >> +stream +xmK 9'@>9FπTuA1(g[.s>+TQ>qBMcF#9R5k.·4S1QhIʢN"k4wG#d3I%1d7ҧ(*7:m"I˪瓐1괸cqqq;-dOl +^R5 +endstream +endobj +xref +0 5 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000217 00000 n +trailer +<< /Size 5 /Root 1 0 R >> +startxref +513 +%%EOF diff --git a/submodules/TelegramUI/Resources/Animations/TabEvents.json b/submodules/TelegramUI/Resources/Animations/TabEvents.json new file mode 100644 index 0000000000..fb402c3d45 --- /dev/null +++ b/submodules/TelegramUI/Resources/Animations/TabEvents.json @@ -0,0 +1 @@ +{"v":"5.5.7","meta":{"g":"TabEvents","a":"","k":"","d":"","tc":""},"fr":60,"ip":0,"op":35,"w":512,"h":512,"nm":"TabEvents","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Events","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"i":{"x":[0.3,0.3,0.3],"y":[1,1,1]},"o":{"x":[0.15,0.15,0.15],"y":[0,0,0]},"t":0,"s":[68.0,68.0,100]},{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.6,0.6,0.6],"y":[0,0,0]},"t":8,"s":[59.160000000000004,59.160000000000004,100]},{"t":25,"s":[68.0,68.0,100]}]}},"ao":0,"shapes":[{"ty":"gr","nm":"Calendar","it":[{"ty":"rc","nm":"Body","d":1,"p":{"a":0,"k":[0,51]},"s":{"a":0,"k":[409,341]},"r":{"a":0,"k":51}},{"ty":"rc","nm":"LeftPeg","d":1,"p":{"a":0,"k":[-85,-145]},"s":{"a":0,"k":[68,120]},"r":{"a":0,"k":26}},{"ty":"rc","nm":"RightPeg","d":1,"p":{"a":0,"k":[85,-145]},"s":{"a":0,"k":[68,120]},"r":{"a":0,"k":26}},{"ty":"fl","nm":"Fill","o":{"a":0,"k":100},"c":{"a":0,"k":[1,1,1,1]},"r":1},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}]}],"ip":0,"op":35,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/submodules/TelegramUI/Sources/ChatControllerEventButton.swift b/submodules/TelegramUI/Sources/ChatControllerEventButton.swift index 91d05f83e0..a9eb1725ee 100644 --- a/submodules/TelegramUI/Sources/ChatControllerEventButton.swift +++ b/submodules/TelegramUI/Sources/ChatControllerEventButton.swift @@ -19,16 +19,11 @@ final class EventFloatingButton: UIView { required init?(coder: NSCoder) { fatalError() } private func setupUI() { - layer.cornerRadius = 26 - backgroundColor = UIColor.systemOrange.withAlphaComponent(0.88) - alpha = 0.65 - layer.shadowColor = UIColor.black.cgColor - layer.shadowOffset = CGSize(width: 0, height: 3) - layer.shadowOpacity = 0.25 - layer.shadowRadius = 6 + backgroundColor = .clear + alpha = 0.8 iconLabel.text = "📅" - iconLabel.font = .systemFont(ofSize: 22) + iconLabel.font = .systemFont(ofSize: 34) iconLabel.textAlignment = .center iconLabel.translatesAutoresizingMaskIntoConstraints = false addSubview(iconLabel) @@ -81,7 +76,7 @@ final class EventFloatingButton: UIView { switch gesture.state { case .began: lastTouchWasDrag = false - UIView.animate(withDuration: 0.15) { self.alpha = 0.85 } + UIView.animate(withDuration: 0.15) { self.alpha = 0.4 } case .changed: let t = gesture.translation(in: superview) if abs(t.x) > 4 || abs(t.y) > 4 { lastTouchWasDrag = true } @@ -90,7 +85,7 @@ final class EventFloatingButton: UIView { clampToSuperview(superview) case .ended, .cancelled: snapToEdge(superview) - UIView.animate(withDuration: 0.2) { self.alpha = 0.65 } + UIView.animate(withDuration: 0.2) { self.alpha = 0.8 } DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in self?.lastTouchWasDrag = false } default: break @@ -152,7 +147,7 @@ extension ChatControllerImpl { let button = EventFloatingButton { [weak self] in guard let self else { return } - let nav = UINavigationController(rootViewController: EventCardNavigatorController(chatId: chatId)) + let nav = UINavigationController(rootViewController: EventCardNavigatorController(chatId: chatId, context: self.context)) nav.modalPresentationStyle = .pageSheet if #available(iOS 15.0, *) { if let sheet = nav.sheetPresentationController { diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift index e41c316d2c..52524a750b 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift @@ -2343,8 +2343,16 @@ extension ChatControllerImpl { text += "🕒 \(dateStr) · \(timeFmt.string(from: event.startDate))–\(timeFmt.string(from: event.endDate))" if let loc = event.location, !loc.isEmpty { text += "\n📍 \(loc)" } + // TGEventAttribute stores event data invisibly so other fork devices can discover it. + let eventAttr = TGEventAttribute( + eventId: event.id.uuidString, title: event.title, + startTimestamp: event.startDate.timeIntervalSince1970, + endTimestamp: event.endDate.timeIntervalSince1970, + location: event.location.flatMap { $0.isEmpty ? nil : $0 } + ) + let message: EnqueueMessage = .message( - text: text, attributes: [], inlineStickers: [:], mediaReference: nil, + text: text, attributes: [eventAttr], inlineStickers: [:], mediaReference: nil, threadId: chatLocation.threadId, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [] ) @@ -2355,7 +2363,7 @@ extension ChatControllerImpl { id: event.id, title: event.title, startDate: event.startDate, endDate: event.endDate, participants: event.participants, location: event.location, - chatId: chatId + chatId: chatId, chatIsGroup: true ) var stored = (try? JSONDecoder().decode([TGEvent].self, from: UserDefaults.standard.data(forKey: TGEventStorage.eventsKey) ?? Data())) ?? [] diff --git a/submodules/TelegramUI/Sources/EventCardNavigatorController.swift b/submodules/TelegramUI/Sources/EventCardNavigatorController.swift index d92cc2d3db..c550daf822 100644 --- a/submodules/TelegramUI/Sources/EventCardNavigatorController.swift +++ b/submodules/TelegramUI/Sources/EventCardNavigatorController.swift @@ -1,8 +1,20 @@ import Foundation import UIKit import AccountContext +import SwiftSignalKit +import Postbox +import TelegramCore -// MARK: - Vote storage helpers +// MARK: - Vote models and storage + +struct TGVoteEntry: Codable { + let userId: Int64 + let displayName: String + let vote: String // "yes" or "no" + let date: Date +} + +private let votesV2Key = "tg_event_votes_v2" private func loadVotes() -> [String: String] { guard let data = UserDefaults.standard.data(forKey: TGEventStorage.votesKey), @@ -16,6 +28,18 @@ private func saveVotes(_ dict: [String: String]) { } } +private func loadVotesV2() -> [String: [TGVoteEntry]] { + guard let data = UserDefaults.standard.data(forKey: votesV2Key), + let dict = try? JSONDecoder().decode([String: [TGVoteEntry]].self, from: data) else { return [:] } + return dict +} + +private func saveVotesV2(_ dict: [String: [TGVoteEntry]]) { + if let data = try? JSONEncoder().encode(dict) { + UserDefaults.standard.set(data, forKey: votesV2Key) + } +} + private func loadStoredEvents() -> [TGEvent] { guard let data = UserDefaults.standard.data(forKey: TGEventStorage.eventsKey), let events = try? JSONDecoder().decode([TGEvent].self, from: data) else { return [] } @@ -143,15 +167,12 @@ private final class EventCardView: UIView { }() func configure(event: TGEvent, vote: String?) { - let dateFmt = Self.dateFmt - let timeFmt = Self.timeFmt - - var dateStr = dateFmt.string(from: event.startDate) + var dateStr = Self.dateFmt.string(from: event.startDate) if let first = dateStr.first { dateStr = first.uppercased() + dateStr.dropFirst() } titleLabel.text = event.title dateLabel.text = "📅 \(dateStr)" - timeLabel.text = "⏰ \(timeFmt.string(from: event.startDate)) – \(timeFmt.string(from: event.endDate))" + timeLabel.text = "⏰ \(Self.timeFmt.string(from: event.startDate)) – \(Self.timeFmt.string(from: event.endDate))" if let loc = event.location, !loc.isEmpty { locationLabel.text = "📍 \(loc)" @@ -197,31 +218,45 @@ private final class EventCardView: UIView { public final class EventCardNavigatorController: UIViewController { private let chatId: Int64 + private let context: AccountContext private var events: [TGEvent] = [] private var currentIndex: Int = 0 + private var scanDisposable: Disposable? + private var currentUserId: Int64 = 0 + private var currentUserName: String = "Вы" - private let cardView = EventCardView() + // Layout + private let scrollView = UIScrollView() + private let contentView = UIView() private let counterLabel = UILabel() + private let cardView = EventCardView() private let prevButton = UIButton(type: .system) private let nextButton = UIButton(type: .system) private let emptyLabel = UILabel() + private let participantsStack = UIStackView() - public init(chatId: Int64) { + public init(chatId: Int64, context: AccountContext) { self.chatId = chatId + self.context = context super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError() } + deinit { + scanDisposable?.dispose() + } + public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemGroupedBackground title = "События" - navigationItem.rightBarButtonItem = UIBarButtonItem( barButtonSystemItem: .close, target: self, action: #selector(closeTapped)) setupLayout() + loadCurrentUser() reload() + scanMessageHistory() } public override func viewWillAppear(_ animated: Bool) { @@ -229,7 +264,29 @@ public final class EventCardNavigatorController: UIViewController { reload() } + // MARK: - Layout + private func setupLayout() { + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.alwaysBounceVertical = true + view.addSubview(scrollView) + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + contentView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(contentView) + NSLayoutConstraint.activate([ + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), + ]) + counterLabel.font = .systemFont(ofSize: 14, weight: .medium) counterLabel.textColor = .secondaryLabel counterLabel.textAlignment = .center @@ -253,34 +310,61 @@ public final class EventCardNavigatorController: UIViewController { emptyLabel.textAlignment = .center emptyLabel.translatesAutoresizingMaskIntoConstraints = false - for v in [counterLabel, cardView, prevButton, nextButton, emptyLabel] as [UIView] { - view.addSubview(v) + participantsStack.axis = .vertical + participantsStack.spacing = 0 + participantsStack.translatesAutoresizingMaskIntoConstraints = false + + for v in [counterLabel, cardView, prevButton, nextButton, emptyLabel, participantsStack] as [UIView] { + contentView.addSubview(v) } NSLayoutConstraint.activate([ - counterLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), - counterLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + counterLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + counterLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), - cardView.topAnchor.constraint(equalTo: counterLabel.bottomAnchor, constant: 16), - cardView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - cardView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + cardView.topAnchor.constraint(equalTo: counterLabel.bottomAnchor, constant: 12), + cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), - prevButton.topAnchor.constraint(equalTo: cardView.bottomAnchor, constant: 24), - prevButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + prevButton.topAnchor.constraint(equalTo: cardView.bottomAnchor, constant: 20), + prevButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), - nextButton.topAnchor.constraint(equalTo: cardView.bottomAnchor, constant: 24), - nextButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + nextButton.topAnchor.constraint(equalTo: cardView.bottomAnchor, constant: 20), + nextButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), - emptyLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), - emptyLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + participantsStack.topAnchor.constraint(equalTo: prevButton.bottomAnchor, constant: 24), + participantsStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + participantsStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + participantsStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -32), + + emptyLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + emptyLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 120), ]) } + // MARK: - Current user + + private func loadCurrentUser() { + let accountPeerId = context.account.peerId + let _ = (context.account.postbox.transaction { transaction -> (Int64, String) in + let userId = accountPeerId.toInt64() + if let user = transaction.getPeer(accountPeerId) as? TelegramUser { + let name = [user.firstName, user.lastName].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ") + return (userId, name.isEmpty ? "Пользователь" : name) + } + return (userId, "Пользователь") + } |> deliverOnMainQueue).startStandalone { [weak self] (userId, name) in + self?.currentUserId = userId + self?.currentUserName = name + } + } + + // MARK: - Data + private func reload() { let all = loadStoredEvents() let newEvents = all.filter { $0.chatId == chatId } .sorted { $0.startDate > $1.startDate } - // Preserve current position if the event list hasn't changed. if newEvents.map(\.id) != events.map(\.id) { currentIndex = 0 } @@ -295,6 +379,7 @@ public final class EventCardNavigatorController: UIViewController { prevButton.isHidden = !hasEvents nextButton.isHidden = !hasEvents emptyLabel.isHidden = hasEvents + participantsStack.isHidden = !hasEvents guard hasEvents else { return } @@ -307,16 +392,148 @@ public final class EventCardNavigatorController: UIViewController { cardView.onYes = { [weak self] in self?.vote("yes", for: event) } cardView.onNo = { [weak self] in self?.vote("no", for: event) } + + rebuildParticipants(for: event) } + // MARK: - Participant list + + private static let voteDateFmt: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "ru_RU") + f.dateFormat = "d MMM, HH:mm" + return f + }() + + private func rebuildParticipants(for event: TGEvent) { + participantsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + + let allVotesV2 = loadVotesV2() + let entries = (allVotesV2[event.id.uuidString] ?? []) + .sorted { $0.date < $1.date } + + let accepted = entries.filter { $0.vote == "yes" } + let declined = entries.filter { $0.vote == "no" } + + let topDivider = makeDivider() + participantsStack.addArrangedSubview(topDivider) + + let headerLabel = UILabel() + headerLabel.text = "Участники" + headerLabel.font = .systemFont(ofSize: 17, weight: .semibold) + headerLabel.textColor = .label + headerLabel.translatesAutoresizingMaskIntoConstraints = false + let headerWrap = wrapWithPadding(headerLabel, top: 20, bottom: 8, leading: 20, trailing: 20) + participantsStack.addArrangedSubview(headerWrap) + + addParticipantSection(title: "✅ Идут (\(accepted.count))", + entries: accepted, + highlightColor: .systemGreen) + addParticipantSection(title: "❌ Не идут (\(declined.count))", + entries: declined, + highlightColor: .systemRed) + + if accepted.isEmpty && declined.isEmpty { + let noVotesLabel = UILabel() + noVotesLabel.text = "Пока никто не ответил" + noVotesLabel.font = .systemFont(ofSize: 14) + noVotesLabel.textColor = .tertiaryLabel + let wrap = wrapWithPadding(noVotesLabel, top: 8, bottom: 8, leading: 20, trailing: 20) + participantsStack.addArrangedSubview(wrap) + } + } + + private func addParticipantSection(title: String, entries: [TGVoteEntry], highlightColor: UIColor) { + let sectionHeader = UILabel() + sectionHeader.text = title + sectionHeader.font = .systemFont(ofSize: 14, weight: .semibold) + sectionHeader.textColor = highlightColor + let sectionWrap = wrapWithPadding(sectionHeader, top: 12, bottom: 4, leading: 20, trailing: 20) + participantsStack.addArrangedSubview(sectionWrap) + + if entries.isEmpty { + let emptyRowLabel = UILabel() + emptyRowLabel.text = " —" + emptyRowLabel.font = .systemFont(ofSize: 14) + emptyRowLabel.textColor = .tertiaryLabel + let wrap = wrapWithPadding(emptyRowLabel, top: 2, bottom: 2, leading: 20, trailing: 20) + participantsStack.addArrangedSubview(wrap) + } else { + for entry in entries { + let row = makeParticipantRow(entry: entry) + participantsStack.addArrangedSubview(row) + } + } + } + + private func makeParticipantRow(entry: TGVoteEntry) -> UIView { + let nameLabel = UILabel() + nameLabel.text = entry.displayName + nameLabel.font = .systemFont(ofSize: 15) + nameLabel.textColor = .label + nameLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + + let dateLabel = UILabel() + dateLabel.text = Self.voteDateFmt.string(from: entry.date) + dateLabel.font = .systemFont(ofSize: 13) + dateLabel.textColor = .tertiaryLabel + dateLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + let row = UIStackView(arrangedSubviews: [nameLabel, dateLabel]) + row.axis = .horizontal + row.spacing = 8 + row.alignment = .center + row.layoutMargins = UIEdgeInsets(top: 6, left: 20, bottom: 6, right: 20) + row.isLayoutMarginsRelativeArrangement = true + return row + } + + private func makeDivider() -> UIView { + let v = UIView() + v.backgroundColor = .separator + v.heightAnchor.constraint(equalToConstant: 0.5).isActive = true + return v + } + + private func wrapWithPadding(_ child: UIView, top: CGFloat, bottom: CGFloat, leading: CGFloat, trailing: CGFloat) -> UIView { + let wrap = UIView() + child.translatesAutoresizingMaskIntoConstraints = false + wrap.addSubview(child) + NSLayoutConstraint.activate([ + child.topAnchor.constraint(equalTo: wrap.topAnchor, constant: top), + child.bottomAnchor.constraint(equalTo: wrap.bottomAnchor, constant: -bottom), + child.leadingAnchor.constraint(equalTo: wrap.leadingAnchor, constant: leading), + child.trailingAnchor.constraint(equalTo: wrap.trailingAnchor, constant: -trailing), + ]) + return wrap + } + + // MARK: - Voting + private func vote(_ answer: String, for event: TGEvent) { - var votes = loadVotes() let key = event.id.uuidString + + // v1 (for badge counting) + var votes = loadVotes() let prev = votes[key] - votes[key] = (prev == answer) ? nil : answer // toggle off if tapping same + votes[key] = (prev == answer) ? nil : answer saveVotes(votes) - // "Yes" → add event to personal calendar (if not already present). + // v2 (for participant list with names/dates) + var votesV2 = loadVotesV2() + var entries = votesV2[key] ?? [] + entries.removeAll { $0.userId == currentUserId } + if prev != answer { + entries.append(TGVoteEntry( + userId: currentUserId, + displayName: currentUserName, + vote: answer, + date: Date() + )) + } + votesV2[key] = entries + saveVotesV2(votesV2) + if answer == "yes", prev != "yes" { addEventToPersonalCalendar(event) } @@ -326,24 +543,86 @@ public final class EventCardNavigatorController: UIViewController { private func addEventToPersonalCalendar(_ event: TGEvent) { var stored = loadStoredEvents() - // Only add if not already there as a personal (chatId-less) copy. - // Match by title+date to avoid duplicates even across different UUID instances. let alreadyExists = stored.contains { $0.chatId == nil && $0.title == event.title && $0.startDate == event.startDate } guard !alreadyExists else { return } - // Use a fresh UUID so the personal copy is independent from the group event — - // deleting one will not accidentally delete the other. - let personal = TGEvent( + stored.append(TGEvent( id: UUID(), title: event.title, startDate: event.startDate, endDate: event.endDate, participants: event.participants, location: event.location, chatId: nil - ) - stored.append(personal) + )) saveStoredEvents(stored) } + // MARK: - Cross-device event discovery via TGEventAttribute + + private func scanMessageHistory() { + let chatId = self.chatId + let groupPeerId = PeerId(namespace: Namespaces.Peer.CloudGroup, id: PeerId.Id._internalFromInt64Value(chatId)) + let channelPeerId = PeerId(namespace: Namespaces.Peer.CloudChannel, id: PeerId.Id._internalFromInt64Value(chatId)) + + scanDisposable = (context.account.postbox.transaction { transaction -> [TGEvent] in + var resolvedPeerId: PeerId? + for candidate in [groupPeerId, channelPeerId] { + if transaction.getPeer(candidate) != nil { resolvedPeerId = candidate; break } + } + guard let peerId = resolvedPeerId else { return [] } + + let view = transaction.getMessagesHistoryViewState( + input: .single(peerId: peerId, threadId: nil), + ignoreMessagesInTimestampRange: nil, + ignoreMessageIds: Set(), + count: 200, clipHoles: true, + anchor: .upperBound, + namespaces: .just(Set([Namespaces.Message.Cloud])) + ) + + var found: [TGEvent] = [] + for entry in view.entries { + // Primary path: TGEventAttribute (invisible, stored on fork clients) + if let attr = entry.message.attributes.first(where: { $0 is TGEventAttribute }) as? TGEventAttribute, + let uuid = UUID(uuidString: attr.eventId) { + found.append(TGEvent( + id: uuid, title: attr.title, + startDate: Date(timeIntervalSince1970: attr.startTimestamp), + endDate: Date(timeIntervalSince1970: attr.endTimestamp), + participants: [], location: attr.location, chatId: chatId + )) + continue + } + // Fallback: legacy [TGE:{...}] text marker (messages sent before attribute migration) + let text = entry.message.text + guard let start = text.range(of: "[TGE:"), + let end = text.range(of: "]", range: start.upperBound.. deliverOnMainQueue).startStandalone { [weak self] discovered in + guard let self, !discovered.isEmpty else { return } + var stored = loadStoredEvents() + let existingIds = Set(stored.map { $0.id }) + let newEvents = discovered.filter { !existingIds.contains($0.id) } + guard !newEvents.isEmpty else { return } + stored.append(contentsOf: newEvents) + saveStoredEvents(stored) + self.reload() + } + } + + // MARK: - Navigation + @objc private func prevTapped() { guard currentIndex < events.count - 1 else { return } currentIndex += 1 @@ -359,8 +638,6 @@ public final class EventCardNavigatorController: UIViewController { } private func animateTransition(direction: CGFloat) { - // Set start position off-screen, then animate to identity — - // updateUI() has already updated labels so new content slides in. let offset = direction * view.bounds.width * 0.4 cardView.transform = CGAffineTransform(translationX: offset, y: 0) cardView.alpha = 0.4 diff --git a/submodules/TelegramUI/Sources/EventsController.swift b/submodules/TelegramUI/Sources/EventsController.swift index 4e0628abf7..0c6bf46159 100644 --- a/submodules/TelegramUI/Sources/EventsController.swift +++ b/submodules/TelegramUI/Sources/EventsController.swift @@ -25,6 +25,7 @@ public struct TGEvent: Codable { public let participants: [String] public let location: String? public var chatId: Int64? + public var chatIsGroup: Bool? // nil = personal, true = group, false = DM } // MARK: - Mock data @@ -125,13 +126,14 @@ private final class CalendarDayCell: UICollectionViewCell { // MARK: - Event list cell private final class EventCell: UITableViewCell { - private let startLbl = UILabel() // "10:00" orange - private let timeline = UIView() // vertical line - private let endLbl = UILabel() // "11:00" gray - private let titleLbl = UILabel() // event name - private let peopleLbl = UILabel() // participants - private let pinIcon = UIImageView() - private let locLbl = UILabel() // location + private let startLbl = UILabel() + private let timeline = UIView() + private let endLbl = UILabel() + private let titleLbl = UILabel() + private let peopleLbl = UILabel() + private let pinIcon = UIImageView() + private let locLbl = UILabel() + private let sourceIcon = UIImageView() // group vs DM badge private static let tf: DateFormatter = { let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU") @@ -184,6 +186,11 @@ private final class EventCell: UITableViewCell { locLbl.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(locLbl) + sourceIcon.contentMode = .scaleAspectFit + sourceIcon.tintColor = .tertiaryLabel + sourceIcon.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(sourceIcon) + let timeW: CGFloat = 46 NSLayoutConstraint.activate([ startLbl.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), @@ -217,6 +224,11 @@ private final class EventCell: UITableViewCell { locLbl.centerYAnchor.constraint(equalTo: pinIcon.centerYAnchor), locLbl.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), locLbl.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -14), + + sourceIcon.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), + sourceIcon.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), + sourceIcon.widthAnchor.constraint(equalToConstant: 14), + sourceIcon.heightAnchor.constraint(equalToConstant: 14), ]) } @@ -236,6 +248,15 @@ private final class EventCell: UITableViewCell { } else { pinIcon.isHidden = true; locLbl.isHidden = true } + + let srcCfg = UIImage.SymbolConfiguration(pointSize: 10, weight: .light) + if let isGroup = event.chatIsGroup { + let name = isGroup ? "person.2" : "person" + sourceIcon.image = UIImage(systemName: name, withConfiguration: srcCfg) + sourceIcon.isHidden = false + } else { + sourceIcon.isHidden = true + } } } @@ -297,6 +318,9 @@ public final class EventsController: TelegramBaseController { return UICollectionView(frame: .zero, collectionViewLayout: fl) }() + private let searchBar = UISearchBar() + private var searchText: String = "" + private let divider = UIView() private let dateHeaderLabel = UILabel() private let tableView = UITableView(frame: .zero, style: .insetGrouped) @@ -321,11 +345,20 @@ public final class EventsController: TelegramBaseController { self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style self.tabBarItem.title = "События" - self.tabBarItem.image = UIImage(systemName: "calendar") - self.tabBarItem.selectedImage = UIImage(systemName: "calendar") + let tabIcon: UIImage? = UIImage(bundleImageName: "Chat List/Tabs/IconEvents").flatMap { icon in + let size = CGSize(width: 18, height: 18) + return UIGraphicsImageRenderer(size: size).image { _ in + icon.draw(in: CGRect(origin: .zero, size: size)) + }.withRenderingMode(.alwaysTemplate) + } + self.tabBarItem.image = tabIcon + self.tabBarItem.selectedImage = tabIcon + if !self.presentationData.reduceMotion { + self.tabBarItem.animationName = "TabEvents" + } self.navigationItem.title = "События" - let addBtn = UIBarButtonItem(image: UIImage(systemName: "plus"), + let addBtn = UIBarButtonItem(image: UIImage(systemName: "plus.circle.fill"), style: .plain, target: self, action: #selector(addEventTapped)) addBtn.tintColor = .systemOrange @@ -336,6 +369,7 @@ public final class EventsController: TelegramBaseController { guard let self else { return } self.presentationData = pd self.statusBar.statusBarStyle = pd.theme.rootController.statusBarStyle.style + self.tabBarItem.animationName = pd.reduceMotion ? nil : "TabEvents" }) allEvents = loadEvents() @@ -421,6 +455,12 @@ public final class EventsController: TelegramBaseController { emptyView.isHidden = true root.addSubview(emptyView) + // Search bar — always visible + searchBar.placeholder = "Поиск событий" + searchBar.searchBarStyle = .minimal + searchBar.delegate = self + root.addSubview(searchBar) + refreshMonthLabel() updateEventsList() } @@ -441,6 +481,7 @@ public final class EventsController: TelegramBaseController { let calW = w - calPad * 2 let cellW = calW / 7 let cellH: CGFloat = 48 + let isSearching = !searchText.isEmpty // Nav bar bottom — safe fallback let navBottom: CGFloat @@ -450,42 +491,59 @@ public final class EventsController: TelegramBaseController { navBottom = (layout.statusBarHeight ?? 20) + 44 } - // Month header - let headerH: CGFloat = 44 - monthHeaderView.frame = CGRect(x: 0, y: navBottom, width: w, height: headerH) - prevButton.frame = CGRect(x: 4, y: 0, width: 48, height: headerH) - nextButton.frame = CGRect(x: w - 52, y: 0, width: 48, height: headerH) - monthLabel.frame = CGRect(x: 56, y: 0, width: w - 112, height: headerH) + // Search bar — always pinned below nav bar + let searchBarH: CGFloat = 52 + searchBar.frame = CGRect(x: 0, y: navBottom, width: w, height: searchBarH) - // Weekday labels - weekdayRow.frame = CGRect(x: calPad, y: monthHeaderView.frame.maxY, - width: calW, height: 28) + let tableTop: CGFloat + if isSearching { + // Hide calendar components during search + monthHeaderView.isHidden = true + weekdayRow.isHidden = true + collectionView.isHidden = true + divider.isHidden = true + dateHeaderLabel.isHidden = true + tableTop = searchBar.frame.maxY + } else { + monthHeaderView.isHidden = false + weekdayRow.isHidden = false + collectionView.isHidden = false + divider.isHidden = false + dateHeaderLabel.isHidden = false - // Calendar grid - let rows = calendarRowCount() - let gridH = cellH * CGFloat(rows) - let fl = collectionView.collectionViewLayout as! UICollectionViewFlowLayout - let newItemSize = CGSize(width: cellW, height: cellH) - if fl.itemSize != newItemSize { - fl.itemSize = newItemSize + // Month header + let headerH: CGFloat = 44 + monthHeaderView.frame = CGRect(x: 0, y: searchBar.frame.maxY, width: w, height: headerH) + prevButton.frame = CGRect(x: 4, y: 0, width: 48, height: headerH) + nextButton.frame = CGRect(x: w - 52, y: 0, width: 48, height: headerH) + monthLabel.frame = CGRect(x: 56, y: 0, width: w - 112, height: headerH) + + // Weekday labels + weekdayRow.frame = CGRect(x: calPad, y: monthHeaderView.frame.maxY, + width: calW, height: 28) + + // Calendar grid + let rows = calendarRowCount() + let gridH = cellH * CGFloat(rows) + let fl = collectionView.collectionViewLayout as! UICollectionViewFlowLayout + let newItemSize = CGSize(width: cellW, height: cellH) + if fl.itemSize != newItemSize { fl.itemSize = newItemSize } + collectionView.frame = CGRect(x: calPad, y: weekdayRow.frame.maxY, + width: calW, height: gridH) + + // Divider + divider.frame = CGRect(x: 0, y: collectionView.frame.maxY + 4, width: w, height: 0.5) + + // Date header label + let dateHeaderH: CGFloat = 36 + dateHeaderLabel.frame = CGRect(x: 20, y: divider.frame.maxY, + width: w - 40, height: dateHeaderH) + tableTop = dateHeaderLabel.frame.maxY } - collectionView.frame = CGRect(x: calPad, y: weekdayRow.frame.maxY, - width: calW, height: gridH) - - // Divider - divider.frame = CGRect(x: 0, y: collectionView.frame.maxY + 4, width: w, height: 0.5) - - // Date header label - let dateHeaderH: CGFloat = 36 - dateHeaderLabel.frame = CGRect(x: 20, y: divider.frame.maxY, - width: w - 40, height: dateHeaderH) // Table - let tableTop = dateHeaderLabel.frame.maxY let tableH = layout.size.height - tableTop - bottomInset tableView.frame = CGRect(x: 0, y: tableTop, width: w, height: tableH) - - // Empty state emptyView.frame = tableView.frame } @@ -531,9 +589,18 @@ public final class EventsController: TelegramBaseController { } private func reloadEvents() { - displayedEvents = allEvents - .filter { cal.isDate($0.startDate, inSameDayAs: selectedDate) } - .sorted { $0.startDate < $1.startDate } + if searchText.isEmpty { + displayedEvents = allEvents + .filter { cal.isDate($0.startDate, inSameDayAs: selectedDate) } + .sorted { $0.startDate < $1.startDate } + } else { + let q = searchText.lowercased() + displayedEvents = allEvents.filter { + $0.title.lowercased().contains(q) || + $0.location?.lowercased().contains(q) == true || + $0.participants.joined(separator: " ").lowercased().contains(q) + }.sorted { $0.startDate < $1.startDate } + } } private static let dateHeaderFmt: DateFormatter = { @@ -546,10 +613,14 @@ public final class EventsController: TelegramBaseController { tableView.reloadData() emptyView.isHidden = !displayedEvents.isEmpty - var text = Self.dateHeaderFmt.string(from: selectedDate) - if let first = text.first { text = first.uppercased() + text.dropFirst() } - if cal.isDateInToday(selectedDate) { text = "Сегодня, " + text.components(separatedBy: ", ").dropFirst().joined(separator: ", ") } - dateHeaderLabel.text = text + if searchText.isEmpty { + var text = Self.dateHeaderFmt.string(from: selectedDate) + if let first = text.first { text = first.uppercased() + text.dropFirst() } + if cal.isDateInToday(selectedDate) { text = "Сегодня, " + text.components(separatedBy: ", ").dropFirst().joined(separator: ", ") } + dateHeaderLabel.text = text + } else { + dateHeaderLabel.text = "Результаты поиска" + } } private func refreshMonthLabel() { @@ -702,3 +773,35 @@ extension EventsController: UITableViewDataSource, UITableViewDelegate { return UISwipeActionsConfiguration(actions: [action]) } } + +// MARK: - UISearchBarDelegate + +extension EventsController: UISearchBarDelegate { + public func searchBar(_ searchBar: UISearchBar, textDidChange text: String) { + searchText = text + updateEventsList() + if let layout = validLayout { applyLayout(layout) } + } + + public func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + searchBar.resignFirstResponder() + } + + public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + searchBar.text = "" + searchBar.resignFirstResponder() + searchText = "" + updateEventsList() + if let layout = validLayout { applyLayout(layout) } + } + + public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + searchBar.setShowsCancelButton(true, animated: true) + } + + public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { + if searchText.isEmpty { + searchBar.setShowsCancelButton(false, animated: true) + } + } +}