Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions submodules/AttachmentUI/Sources/AttachmentController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public enum AttachmentButtonType: Equatable {
case quickReply
case contact
case poll
case event
case app(AttachMenuBot)
case gift
case sticker
Expand All @@ -51,6 +52,8 @@ public enum AttachmentButtonType: Equatable {
return "contact"
case .poll:
return "poll"
case .event:
return "event"
case let .app(bot):
return "app_\(bot.shortName)"
case .gift:
Expand Down Expand Up @@ -110,6 +113,12 @@ public enum AttachmentButtonType: Equatable {
} else {
return false
}
case .event:
if case .event = rhs {
return true
} else {
return false
}
case let .app(lhsBot):
if case let .app(rhsBot) = rhs, lhsBot.peer.id == rhsBot.peer.id {
return true
Expand Down
5 changes: 5 additions & 0 deletions submodules/AttachmentUI/Sources/AttachmentPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ private final class AttachButtonComponent: CombinedComponent {
case .poll:
name = strings.Attachment_Poll
imageName = "Chat/Attach Menu/Poll"
case .event:
name = "Событие"
imageName = "Chat/Attach Menu/Event"
case .gift:
name = strings.Attachment_Gift
imageName = "Chat/Attach Menu/Gift"
Expand Down Expand Up @@ -2163,6 +2166,8 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate, ASGestureRecog
accessibilityTitle = self.presentationData.strings.Attachment_Contact
case .poll:
accessibilityTitle = self.presentationData.strings.Attachment_Poll
case .event:
accessibilityTitle = "Событие"
case .gift:
accessibilityTitle = self.presentationData.strings.Attachment_Gift
case .sticker:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "ic_tb_events.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "calendar_24.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.
1 change: 1 addition & 0 deletions submodules/TelegramUI/Resources/Animations/TabEvents.json
Original file line number Diff line number Diff line change
@@ -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":[]}
4 changes: 3 additions & 1 deletion submodules/TelegramUI/Sources/ChatController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7259,7 +7259,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G

override public func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)


self.setupEventFloatingButtonIfNeeded()

if self.willAppear {
self.chatDisplayNode.historyNode.refreshPollActionsForVisibleMessages()
} else {
Expand Down
188 changes: 188 additions & 0 deletions submodules/TelegramUI/Sources/ChatControllerEventButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import Foundation
import UIKit
import TelegramCore
import AccountContext

// MARK: - Floating button view

final class EventFloatingButton: UIView {
private let iconLabel = UILabel()
private let badge = UIView()
private var onTap: (() -> Void)?
private var lastTouchWasDrag = false

init(onTap: @escaping () -> Void) {
self.onTap = onTap
super.init(frame: CGRect(x: 0, y: 0, width: 52, height: 52))
setupUI()
}
required init?(coder: NSCoder) { fatalError() }

private func setupUI() {
backgroundColor = .clear
alpha = 0.8

iconLabel.text = "📅"
iconLabel.font = .systemFont(ofSize: 34)
iconLabel.textAlignment = .center
iconLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(iconLabel)
NSLayoutConstraint.activate([
iconLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
iconLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
])

let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
addGestureRecognizer(tap)

let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
pan.minimumNumberOfTouches = 1
addGestureRecognizer(pan)
}

func updateEventCount(_ count: Int) {
badge.subviews.forEach { $0.removeFromSuperview() }
badge.removeFromSuperview()
guard count > 0 else { return }

let label = UILabel()
label.text = "\(count)"
label.font = .systemFont(ofSize: 10, weight: .bold)
label.textColor = .white
label.sizeToFit()

let size: CGFloat = max(16, label.frame.width + 8)
badge.frame = CGRect(x: frame.width - size / 2 - 2, y: -4, width: size, height: 16)
badge.backgroundColor = .systemRed
badge.layer.cornerRadius = 8
label.center = CGPoint(x: size / 2, y: 8)
badge.addSubview(label)
addSubview(badge)
}

@objc private func handleTap() {
guard !lastTouchWasDrag else { return }
UIView.animate(withDuration: 0.1, animations: {
self.transform = CGAffineTransform(scaleX: 0.92, y: 0.92)
}) { _ in
UIView.animate(withDuration: 0.15) { self.transform = .identity }
}
onTap?()
}

@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
guard let superview = superview else { return }

switch gesture.state {
case .began:
lastTouchWasDrag = false
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 }
center = CGPoint(x: center.x + t.x, y: center.y + t.y)
gesture.setTranslation(.zero, in: superview)
clampToSuperview(superview)
case .ended, .cancelled:
snapToEdge(superview)
UIView.animate(withDuration: 0.2) { self.alpha = 0.8 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in self?.lastTouchWasDrag = false }
default:
break
}
}

private func clampToSuperview(_ sv: UIView) {
let pad: CGFloat = 8
let halfW = bounds.width / 2
let halfH = bounds.height / 2
// 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))
)
}

private func snapToEdge(_ sv: UIView) {
let pad: CGFloat = 16
let targetX: CGFloat = center.x < sv.bounds.midX
? bounds.width / 2 + pad
: sv.bounds.width - bounds.width / 2 - pad
UIView.animate(withDuration: 0.35, delay: 0,
usingSpringWithDamping: 0.75, initialSpringVelocity: 0.5, options: []) {
self.center.x = targetX
}
}
}

// MARK: - ChatControllerImpl extension

extension ChatControllerImpl {

private static let floatingEventButtonTag = 77421

func setupEventFloatingButtonIfNeeded() {
let peer = presentationInterfaceState.renderedPeer?.peer
let isGroup: Bool
if peer is TelegramGroup {
isGroup = true
} else if let ch = peer as? TelegramChannel, case .group = ch.info {
isGroup = true
} else {
isGroup = false
}

if !isGroup {
view.viewWithTag(Self.floatingEventButtonTag)?.removeFromSuperview()
return
}

// Already installed.
if view.viewWithTag(Self.floatingEventButtonTag) != nil { return }

guard let peerId = chatLocation.peerId else { return }
let chatId = peerId.localStorageId

let button = EventFloatingButton { [weak self] in
guard let self else { return }
let nav = UINavigationController(rootViewController: EventCardNavigatorController(chatId: chatId, context: self.context))
nav.modalPresentationStyle = .pageSheet
if #available(iOS 15.0, *) {
if let sheet = nav.sheetPresentationController {
sheet.detents = [.large()]
sheet.prefersGrabberVisible = true
}
}
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
button.frame = CGRect(
x: view.bounds.width - bw - 16,
y: view.bounds.height / 2,
width: bw, height: bw
)
view.addSubview(button)

refreshEventFloatingButtonBadge(chatId: chatId)
}

func refreshEventFloatingButtonBadge(chatId: Int64) {
guard let button = view.viewWithTag(Self.floatingEventButtonTag) as? EventFloatingButton else { return }
let stored = (try? JSONDecoder().decode([TGEvent].self,
from: UserDefaults.standard.data(forKey: TGEventStorage.eventsKey) ?? Data())) ?? []
let votes = (try? JSONDecoder().decode([String: String].self,
from: UserDefaults.standard.data(forKey: TGEventStorage.votesKey) ?? Data())) ?? [:]
let unvotedCount = stored
.filter { $0.chatId == chatId }
.filter { votes[$0.id.uuidString] == nil }
.count
button.updateEventCount(unvotedCount)
}
}
Loading