Events tab: code quality and correctness fixes

- Extract TGEventStorage enum for shared UserDefaults keys (single source of truth)
- Move DateFormatter instances to static let (avoid recreating on every call)
- Fix floating button safe area: use view's own insets instead of window cast
- Fix weak self capture in DispatchQueue closure (memory leak)
- Add PeerId.localStorageId extension as single canonical conversion
- Add autoresizingMask to floating button for correct rotation behavior
- Fix card navigator: preserve scroll position on reload, fix animation order
- Personal calendar copy gets fresh UUID; duplicate detection by title+date
- Fix participant deletion: reload section to reset button tags after index shift
- Update .gitignore: ignore tg-events/ and sync script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
akaevmikail17 2026-06-07 14:46:28 +03:00
parent faa73a6ec2
commit c83d57a7d5
6 changed files with 87 additions and 56 deletions

5
.gitignore vendored
View file

@ -77,7 +77,8 @@ spm-files
xcode-files
.bsp/**
.sourcekit-lsp/**
/.claude/
**/.claude/settings.local.json
**/.claude/
**/.vscode/launch.json
/buildbox/*
/tg-events/
/sync-tg-events.sh

View file

@ -91,7 +91,7 @@ final class EventFloatingButton: UIView {
case .ended, .cancelled:
snapToEdge(superview)
UIView.animate(withDuration: 0.2) { self.alpha = 0.65 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { self.lastTouchWasDrag = false }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in self?.lastTouchWasDrag = false }
default:
break
}
@ -101,8 +101,9 @@ final class EventFloatingButton: UIView {
let pad: CGFloat = 8
let halfW = bounds.width / 2
let halfH = bounds.height / 2
let safeTop = (sv as? UIWindow)?.safeAreaInsets.top ?? 44
let safeBottom = (sv as? UIWindow)?.safeAreaInsets.bottom ?? 34
// Use the view's own safe area insets (propagated from the window through the hierarchy).
let safeTop = sv.safeAreaInsets.top
let safeBottom = sv.safeAreaInsets.bottom
center = CGPoint(
x: max(halfW + pad, min(sv.bounds.width - halfW - pad, center.x)),
y: max(halfH + safeTop + 8, min(sv.bounds.height - halfH - safeBottom - 80, center.y))
@ -147,7 +148,7 @@ extension ChatControllerImpl {
if view.viewWithTag(Self.floatingEventButtonTag) != nil { return }
guard let peerId = chatLocation.peerId else { return }
let chatId = peerId.id._internalGetInt64Value()
let chatId = peerId.localStorageId
let button = EventFloatingButton { [weak self] in
guard let self else { return }
@ -162,6 +163,8 @@ extension ChatControllerImpl {
self.present(nav, animated: true)
}
button.tag = Self.floatingEventButtonTag
// Keep button on the right edge when the view resizes (rotation).
button.autoresizingMask = [.flexibleLeftMargin, .flexibleTopMargin, .flexibleBottomMargin]
// Initial position: right edge, vertically centered.
let bw: CGFloat = 52
@ -177,11 +180,10 @@ extension ChatControllerImpl {
func refreshEventFloatingButtonBadge(chatId: Int64) {
guard let button = view.viewWithTag(Self.floatingEventButtonTag) as? EventFloatingButton else { return }
let key = "tg_events_v1"
let stored = (try? JSONDecoder().decode([TGEvent].self,
from: UserDefaults.standard.data(forKey: key) ?? Data())) ?? []
from: UserDefaults.standard.data(forKey: TGEventStorage.eventsKey) ?? Data())) ?? []
let votes = (try? JSONDecoder().decode([String: String].self,
from: UserDefaults.standard.data(forKey: "tg_event_votes_v1") ?? Data())) ?? [:]
from: UserDefaults.standard.data(forKey: TGEventStorage.votesKey) ?? Data())) ?? [:]
let unvotedCount = stored
.filter { $0.chatId == chatId }
.filter { votes[$0.id.uuidString] == nil }

View file

@ -38,6 +38,13 @@ import ComposePollScreen
import Photos
import AttachmentFileController
// Stable Int64 encoding of a peer ID for on-device event storage only.
// Uses the same namespace|id packing as Postbox not for network use.
// Internal (not private) so ChatControllerEventButton in the same module can use it.
extension PeerId {
var localStorageId: Int64 { id._internalGetInt64Value() }
}
extension ChatControllerImpl {
enum AttachMenuSubject {
case `default`
@ -134,9 +141,6 @@ extension ChatControllerImpl {
availableButtons.append(.event)
if "".isEmpty {
availableButtons.insert(.audio, at: max(0, availableButtons.count - 1))
}
let presentationData = self.presentationData
@ -2314,16 +2318,23 @@ extension ChatControllerImpl {
self.push(controller)
}
// MARK: - Event helpers
private static let eventDateFmt: DateFormatter = {
let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU")
f.dateFormat = "EEEE, d MMMM"; return f
}()
private static let eventTimeFmt: DateFormatter = {
let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU")
f.dateFormat = "HH:mm"; return f
}()
func sendEventToGroup(event: TGEvent) {
guard let peerId = chatLocation.peerId else { return }
let chatId = peerId.id._internalGetInt64Value()
let chatId = peerId.localStorageId
let dateFmt = DateFormatter()
dateFmt.locale = Locale(identifier: "ru_RU")
dateFmt.dateFormat = "EEEE, d MMMM"
let timeFmt = DateFormatter()
timeFmt.locale = Locale(identifier: "ru_RU")
timeFmt.dateFormat = "HH:mm"
let dateFmt = Self.eventDateFmt
let timeFmt = Self.eventTimeFmt
var dateStr = dateFmt.string(from: event.startDate)
if let first = dateStr.first { dateStr = first.uppercased() + dateStr.dropFirst() }
@ -2346,12 +2357,11 @@ extension ChatControllerImpl {
participants: event.participants, location: event.location,
chatId: chatId
)
let key = "tg_events_v1"
var stored = (try? JSONDecoder().decode([TGEvent].self,
from: UserDefaults.standard.data(forKey: key) ?? Data())) ?? []
from: UserDefaults.standard.data(forKey: TGEventStorage.eventsKey) ?? Data())) ?? []
stored = stored.map { $0.id == event.id ? eventWithChat : $0 }
if let data = try? JSONEncoder().encode(stored) {
UserDefaults.standard.set(data, forKey: key)
UserDefaults.standard.set(data, forKey: TGEventStorage.eventsKey)
}
}
}

View file

@ -190,15 +190,15 @@ final class CreateEventController: UIViewController {
location: eventLocation.isEmpty ? nil : eventLocation)
// Always persist directly so events created from any context (chat, events tab) are saved
let key = "tg_events_v1"
var stored = (try? JSONDecoder().decode([TGEvent].self, from: UserDefaults.standard.data(forKey: key) ?? Data())) ?? []
var stored = (try? JSONDecoder().decode([TGEvent].self,
from: UserDefaults.standard.data(forKey: TGEventStorage.eventsKey) ?? Data())) ?? []
if editingEvent != nil {
stored = stored.map { $0.id == event.id ? event : $0 }
} else {
stored.append(event)
}
if let data = try? JSONEncoder().encode(stored) {
UserDefaults.standard.set(data, forKey: key)
UserDefaults.standard.set(data, forKey: TGEventStorage.eventsKey)
}
onSave?(event)
@ -432,6 +432,8 @@ extension CreateEventController: UITableViewDelegate {
if editingStyle == .delete {
participants.remove(at: ip.row)
tv.deleteRows(at: [ip], with: .automatic)
// Reload remaining rows to reset button tags after index shift.
tv.reloadSections(IndexSet(integer: 2), with: .none)
}
}
}

View file

@ -4,29 +4,27 @@ import AccountContext
// MARK: - Vote storage helpers
private let votesKey = "tg_event_votes_v1"
private func loadVotes() -> [String: String] {
guard let data = UserDefaults.standard.data(forKey: votesKey),
guard let data = UserDefaults.standard.data(forKey: TGEventStorage.votesKey),
let dict = try? JSONDecoder().decode([String: String].self, from: data) else { return [:] }
return dict
}
private func saveVotes(_ dict: [String: String]) {
if let data = try? JSONEncoder().encode(dict) {
UserDefaults.standard.set(data, forKey: votesKey)
UserDefaults.standard.set(data, forKey: TGEventStorage.votesKey)
}
}
private func loadStoredEvents() -> [TGEvent] {
guard let data = UserDefaults.standard.data(forKey: "tg_events_v1"),
guard let data = UserDefaults.standard.data(forKey: TGEventStorage.eventsKey),
let events = try? JSONDecoder().decode([TGEvent].self, from: data) else { return [] }
return events
}
private func saveStoredEvents(_ events: [TGEvent]) {
if let data = try? JSONEncoder().encode(events) {
UserDefaults.standard.set(data, forKey: "tg_events_v1")
UserDefaults.standard.set(data, forKey: TGEventStorage.eventsKey)
}
}
@ -135,13 +133,18 @@ private final class EventCardView: UIView {
])
}
private static let dateFmt: DateFormatter = {
let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU")
f.dateFormat = "EEEE, d MMMM"; return f
}()
private static let timeFmt: DateFormatter = {
let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU")
f.dateFormat = "HH:mm"; return f
}()
func configure(event: TGEvent, vote: String?) {
let dateFmt = DateFormatter()
dateFmt.locale = Locale(identifier: "ru_RU")
dateFmt.dateFormat = "EEEE, d MMMM"
let timeFmt = DateFormatter()
timeFmt.locale = Locale(identifier: "ru_RU")
timeFmt.dateFormat = "HH:mm"
let dateFmt = Self.dateFmt
let timeFmt = Self.timeFmt
var dateStr = dateFmt.string(from: event.startDate)
if let first = dateStr.first { dateStr = first.uppercased() + dateStr.dropFirst() }
@ -275,10 +278,13 @@ public final class EventCardNavigatorController: UIViewController {
private func reload() {
let all = loadStoredEvents()
// Show events for this group, newest first.
events = all.filter { $0.chatId == chatId }
let newEvents = all.filter { $0.chatId == chatId }
.sorted { $0.startDate > $1.startDate }
currentIndex = 0
// Preserve current position if the event list hasn't changed.
if newEvents.map(\.id) != events.map(\.id) {
currentIndex = 0
}
events = newEvents
updateUI()
}
@ -321,10 +327,15 @@ 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.
let alreadyExists = stored.contains { $0.id == event.id && $0.chatId == nil }
// 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(
id: event.id, title: event.title,
id: UUID(), title: event.title,
startDate: event.startDate, endDate: event.endDate,
participants: event.participants, location: event.location,
chatId: nil
@ -336,18 +347,20 @@ public final class EventCardNavigatorController: UIViewController {
@objc private func prevTapped() {
guard currentIndex < events.count - 1 else { return }
currentIndex += 1
animateTransition(direction: -1)
updateUI()
animateTransition(direction: -1)
}
@objc private func nextTapped() {
guard currentIndex > 0 else { return }
currentIndex -= 1
animateTransition(direction: 1)
updateUI()
animateTransition(direction: 1)
}
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

@ -8,6 +8,13 @@ import TelegramPresentationData
import AccountContext
import TelegramBaseController
// MARK: - Shared storage keys
enum TGEventStorage {
static let eventsKey = "tg_events_v1"
static let votesKey = "tg_event_votes_v1"
}
// MARK: - Model
public struct TGEvent: Codable {
@ -343,14 +350,12 @@ public final class EventsController: TelegramBaseController {
override public func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Reload in case events were created from a chat (without going through our delegate)
let fresh = loadEvents()
if fresh.map(\.id) != allEvents.map(\.id) {
allEvents = fresh
if isNodeLoaded {
collectionView.reloadData()
updateEventsList()
}
// Always reload: events may be added from chat without touching our delegate,
// or an existing event's chatId may be stamped after creation.
allEvents = loadEvents()
if isNodeLoaded {
collectionView.reloadData()
updateEventsList()
}
}
@ -511,10 +516,8 @@ public final class EventsController: TelegramBaseController {
// MARK: Persistence
private static let storageKey = "tg_events_v1"
private func loadEvents() -> [TGEvent] {
guard let data = UserDefaults.standard.data(forKey: Self.storageKey),
guard let data = UserDefaults.standard.data(forKey: TGEventStorage.eventsKey),
let events = try? JSONDecoder().decode([TGEvent].self, from: data) else {
return seedEvents
}
@ -523,7 +526,7 @@ public final class EventsController: TelegramBaseController {
private func saveEvents() {
if let data = try? JSONEncoder().encode(allEvents) {
UserDefaults.standard.set(data, forKey: Self.storageKey)
UserDefaults.standard.set(data, forKey: TGEventStorage.eventsKey)
}
}