Events tab: TGEventAttribute, participant list, source icon, tab icon size

- TGEventAttribute: new MessageAttribute in TelegramCore/SyncCore storing
  event data invisibly (eventId, title, timestamps, location); registered
  via declareEncodable in AccountManager; replaces visible [TGE:...] JSON text

- EventCardNavigatorController: full rewrite with UIScrollView layout;
  participant sections "Идут / Не идут" with names and vote dates;
  TGVoteEntry (v2 vote storage) alongside v1 for badge compat;
  scanMessageHistory() uses TGEventAttribute with legacy text fallback;
  loadCurrentUser() reads voter name from Postbox

- EventsController: TGEvent.chatIsGroup field (nil=personal, true=group,
  false=DM); EventCell shows subtle person.2/person source icon top-right;
  tab icon scaled to 68% via Lottie JSON and PDF asset

- ChatControllerOpenAttachmentMenu: clean message text (no [TGE:...]),
  attaches TGEventAttribute; stores chatIsGroup: true in persisted event

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
akaevmikail17 2026-06-07 17:27:33 +03:00
parent c83d57a7d5
commit 29d2c08102
9 changed files with 561 additions and 94 deletions

View file

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

View file

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

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "ic_tb_events.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

View file

@ -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
xœm<EFBFBD>Kà ÷9'@ø>9FÏ€TuA½ÿ¦‰1<31>ª(<28>‰äg<C3A4>¿[ð.ÜÏý±ûñ¶sË>„àÀçé°+ìÉçTQøŠ>îqBâ³M¤cFõ#9ÒR5ök.·4S1Q÷hIÊ¢N"ø<>ökîãÞÛáÓÈÊ4wGä#êdÂ3Áµ·IÄ%íáÄÜÎ1“d7Ò§È(‡Æî*7¬:¦êm"I˪ç“<C3A7>1ºñê´¸cq‡q‡qÇâŽÅÆÆ;-òdìÉèÓâOËÈl€Ì
èÏRü”5
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

View file

@ -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":[]}

View file

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

View file

@ -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())) ?? []

View file

@ -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..<text.endIndex) else { continue }
struct LegacyMarker: Decodable { let i: String; let t: String; let s: Double; let e: Double; let l: String? }
let jsonStr = String(text[start.upperBound..<end.lowerBound])
guard let data = jsonStr.data(using: .utf8),
let m = try? JSONDecoder().decode(LegacyMarker.self, from: data),
let uuid = UUID(uuidString: m.i) else { continue }
found.append(TGEvent(
id: uuid, title: m.t,
startDate: Date(timeIntervalSince1970: m.s),
endDate: Date(timeIntervalSince1970: m.e),
participants: [], location: m.l, chatId: chatId
))
}
return found
} |> 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

View file

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