Skip to content

Commit f6636d7

Browse files
authored
feat: show a toast when reactions or forwarding are restricted (#69)
* feat: add an animated banned toast to the chat * feat: hide reactions and show a toast when sending reactions is forbidden * feat: explain the disabled Forward button when content is protected * fix: render the star reaction highlight as a soft glow like iOS * fix: allow reactions for members who unlocked restrictions with boosts
1 parent 39b0fb3 commit f6636d7

10 files changed

Lines changed: 121 additions & 27 deletions

File tree

‎Resources/tgs/menu/banned.tgs‎

2.2 KB
Binary file not shown.

‎Telegram.xcodeproj/project.pbxproj‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@
136136
2733671F27620BA40027441E /* menu_retract_vote.tgs in Resources */ = {isa = PBXBuildFile; fileRef = 2733671C27620BA20027441E /* menu_retract_vote.tgs */; };
137137
2733672027620BA40027441E /* menu_report.tgs in Resources */ = {isa = PBXBuildFile; fileRef = 2733671D27620BA30027441E /* menu_report.tgs */; };
138138
2733672127620BA40027441E /* menu_restrict.tgs in Resources */ = {isa = PBXBuildFile; fileRef = 2733671E27620BA40027441E /* menu_restrict.tgs */; };
139+
AB00BA0000000000000000A2 /* banned.tgs in Resources */ = {isa = PBXBuildFile; fileRef = AB00BA0000000000000000A1 /* banned.tgs */; };
139140
2733672327620BAC0027441E /* menu_reply.tgs in Resources */ = {isa = PBXBuildFile; fileRef = 2733672227620BAC0027441E /* menu_reply.tgs */; };
140141
2733672527620BB40027441E /* menu_open_with.tgs in Resources */ = {isa = PBXBuildFile; fileRef = 2733672427620BB30027441E /* menu_open_with.tgs */; };
141142
2733672927620C5A0027441E /* menu_save_as.tgs in Resources */ = {isa = PBXBuildFile; fileRef = 2733672827620C5A0027441E /* menu_save_as.tgs */; };
@@ -1159,6 +1160,7 @@
11591160
2733671C27620BA20027441E /* menu_retract_vote.tgs */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = menu_retract_vote.tgs; sourceTree = "<group>"; };
11601161
2733671D27620BA30027441E /* menu_report.tgs */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = menu_report.tgs; sourceTree = "<group>"; };
11611162
2733671E27620BA40027441E /* menu_restrict.tgs */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = menu_restrict.tgs; sourceTree = "<group>"; };
1163+
AB00BA0000000000000000A1 /* banned.tgs */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = banned.tgs; sourceTree = "<group>"; };
11621164
2733672227620BAC0027441E /* menu_reply.tgs */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = menu_reply.tgs; sourceTree = "<group>"; };
11631165
2733672427620BB30027441E /* menu_open_with.tgs */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = menu_open_with.tgs; sourceTree = "<group>"; };
11641166
2733672827620C5A0027441E /* menu_save_as.tgs */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = menu_save_as.tgs; sourceTree = "<group>"; };
@@ -2712,6 +2714,7 @@
27122714
2733672227620BAC0027441E /* menu_reply.tgs */,
27132715
2733671D27620BA30027441E /* menu_report.tgs */,
27142716
2733671E27620BA40027441E /* menu_restrict.tgs */,
2717+
AB00BA0000000000000000A1 /* banned.tgs */,
27152718
2733671C27620BA20027441E /* menu_retract_vote.tgs */,
27162719
2733671A27620B980027441E /* menu_stop_poll.tgs */,
27172720
2733671827620B7A0027441E /* menu_leave.tgs */,
@@ -4397,6 +4400,7 @@
43974400
2733672527620BB40027441E /* menu_open_with.tgs in Resources */,
43984401
27803C0327A3E944007B0FA9 /* menu_folder_home.tgs in Resources */,
43994402
2733672127620BA40027441E /* menu_restrict.tgs in Resources */,
4403+
AB00BA0000000000000000A2 /* banned.tgs in Resources */,
44004404
270FD5982C516A9B000070E6 /* browser_close_to_back.tgs in Resources */,
44014405
27CE1570297966D8001F8CE8 /* emoji_category_neutral.tgs in Resources */,
44024406
2733674727625A270027441E /* menu_show_message.tgs in Resources */,

‎packages/ChatUI/Sources/ChatUI/AddReactionManager.swift‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,16 @@ public final class ContextAddReactionsListView : View, StickerFramesCollector {
6565
if case .stars = reaction.value {
6666

6767
if !reaction.isSelected {
68-
starBackground = .init(frame: frameRect.size.bounds.insetBy(dx: -2, dy: -2))
68+
starBackground = .init(frame: frameRect.size.bounds.insetBy(dx: -4, dy: -4))
6969
self.layer?.addSublayer(starBackground!)
7070

71-
starBackground?.colors = [NSColor(0xFFAC04).withAlphaComponent(0.15),
72-
NSColor(0xFFCA35).withAlphaComponent(0.1),
73-
NSColor(0xffffff).withAlphaComponent(0.0)].map { $0.cgColor }
71+
starBackground?.type = .radial
72+
starBackground?.colors = [NSColor(0xFFAC04).withAlphaComponent(0.28),
73+
NSColor(0xFFCA35).withAlphaComponent(0.14),
74+
NSColor(0xFFCA35).withAlphaComponent(0.0)].map { $0.cgColor }
7475

75-
starBackground?.startPoint = NSMakePoint(0, 0.5)
76-
starBackground?.endPoint = NSMakePoint(1, 0.5)
76+
starBackground?.startPoint = NSMakePoint(0.5, 0.5)
77+
starBackground?.endPoint = NSMakePoint(1, 1)
7778
}
7879

7980

‎packages/ChatUI/Sources/ChatUI/ChatController.swift‎

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1679,7 +1679,11 @@ public class ChatControllerView : View, ChatInputDelegate {
16791679
return frame.focusX(size, y: y)
16801680
}
16811681

1682-
func showToaster(text: NSAttributedString, action: (() -> Void)? = nil, timeout: Double, animated: Bool) {
1682+
var presentationTextColor: NSColor {
1683+
return presentation.colors.text
1684+
}
1685+
1686+
func showToaster(text: NSAttributedString, animation: LocalAnimatedSticker? = nil, context: (any AccountContextProtocol)? = nil, action: (() -> Void)? = nil, timeout: Double, animated: Bool) {
16831687
// Tear down any in-flight toaster before installing the new one —
16841688
// `dismissImmediately` only kills the timer, so we also have to
16851689
// remove the view from the hierarchy or it sticks around forever.
@@ -1691,7 +1695,7 @@ public class ChatControllerView : View, ChatInputDelegate {
16911695

16921696
let view = ChatToasterView(frame: .zero)
16931697
let maxWidth = max(80, frame.width - 24)
1694-
view.update(text: text, maxWidth: maxWidth)
1698+
view.update(text: text, animation: animation, context: context, maxWidth: maxWidth)
16951699
let size = view.contentSize(maxWidth: maxWidth)
16961700
let y = ChatHeaderBubblesView.totalHeight + header.state.height + 8
16971701
view.frame = frame.focusX(size, y: y)
@@ -1737,6 +1741,10 @@ public class ChatControllerView : View, ChatInputDelegate {
17371741
final class ChatToasterView: Control {
17381742
private let glass: LiquidGlassView
17391743
private let textView = TextView()
1744+
private let animationView: MediaAnimatedStickerView = MediaAnimatedStickerView(frame: NSMakeRect(0, 0, 26, 26))
1745+
private var hasAnimation: Bool = false
1746+
private let animationSize: CGFloat = 26
1747+
private let animationSpacing: CGFloat = 8
17401748
/// Auto-dismiss timer owned by the view itself, so the view manages its
17411749
/// own lifecycle. Previously a separate `ChatToaster` model held both a
17421750
/// strong reference to the view AND the disposable, which made the
@@ -1752,6 +1760,9 @@ final class ChatToasterView: Control {
17521760
super.init(frame: frameRect)
17531761
addSubview(glass)
17541762
addSubview(textView)
1763+
addSubview(animationView)
1764+
animationView.userInteractionEnabled = false
1765+
animationView.isHidden = true
17551766
textView.isSelectable = false
17561767
textView.userInteractionEnabled = false
17571768
textView.disableBackgroundDrawing = true
@@ -1766,9 +1777,22 @@ final class ChatToasterView: Control {
17661777
dismissDisposable.dispose()
17671778
}
17681779

1769-
func update(text: NSAttributedString, maxWidth: CGFloat) {
1780+
func update(text: NSAttributedString, animation: LocalAnimatedSticker? = nil, context: (any AccountContextProtocol)? = nil, maxWidth: CGFloat) {
1781+
let iconWidth: CGFloat
1782+
if let animation, let context {
1783+
hasAnimation = true
1784+
animationView.isHidden = false
1785+
let color = (text.length > 0 ? text.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor : nil) ?? NSColor.white
1786+
let parameters = ChatAnimatedStickerMediaLayoutParameters(playPolicy: .once, alwaysAccept: true, cache: .temporaryLZ4(.thumb), hidePlayer: false, media: animation.file, colors: [.init(keyPath: "", color: color)], shimmer: false)
1787+
animationView.update(with: animation.file, size: NSMakeSize(animationSize, animationSize), context: context, table: nil, parameters: parameters, animated: false)
1788+
iconWidth = animationSize + animationSpacing
1789+
} else {
1790+
hasAnimation = false
1791+
animationView.isHidden = true
1792+
iconWidth = 0
1793+
}
17701794
let layout = TextViewLayout(text, maximumNumberOfLines: 3, truncationType: .end, alignment: .center)
1771-
layout.measure(width: max(40, maxWidth - horizontalPadding * 2))
1795+
layout.measure(width: max(40, maxWidth - horizontalPadding * 2 - iconWidth))
17721796
textView.update(layout)
17731797
}
17741798

@@ -1788,7 +1812,8 @@ final class ChatToasterView: Control {
17881812
guard let layout = textView.textLayout else {
17891813
return NSMakeSize(120, minHeight)
17901814
}
1791-
let width = min(maxWidth, layout.layoutSize.width + horizontalPadding * 2)
1815+
let iconWidth: CGFloat = hasAnimation ? animationSize + animationSpacing : 0
1816+
let width = min(maxWidth, layout.layoutSize.width + iconWidth + horizontalPadding * 2)
17921817
let height = max(minHeight, layout.layoutSize.height + verticalPadding * 2)
17931818
return NSMakeSize(width, height)
17941819
}
@@ -1797,7 +1822,15 @@ final class ChatToasterView: Control {
17971822
super.layout()
17981823
glass.frame = bounds
17991824
glass.cornerRadius = bounds.height / 2
1800-
textView.center()
1825+
if hasAnimation {
1826+
let iconWidth = animationSize + animationSpacing
1827+
let contentWidth = iconWidth + textView.frame.width
1828+
let startX = floor((bounds.width - contentWidth) / 2)
1829+
animationView.setFrameOrigin(NSMakePoint(startX, floor((bounds.height - animationSize) / 2)))
1830+
textView.setFrameOrigin(NSMakePoint(startX + iconWidth, floor((bounds.height - textView.frame.height) / 2)))
1831+
} else {
1832+
textView.center()
1833+
}
18011834
}
18021835
}
18031836

@@ -7826,6 +7859,11 @@ open class ChatController<Context: AccountContextProtocol>: TelegramGenericViewC
78267859
self?.navigationController?.push(ChatController(context: context, chatLocation: .thread(result.message), mode: .preview, chatLocationContextHolder: result.contextHolder))
78277860
})
78287861
}
7862+
chatInteraction.showAnimatedToaster = { [weak self] animation, text in
7863+
guard let self else { return }
7864+
let attributed = NSAttributedString.initialize(string: text, color: self.genericView.presentationTextColor, font: .normal(.text))
7865+
self.genericView.showToaster(text: attributed, animation: animation, context: self.context, timeout: 3.0, animated: true)
7866+
}
78297867
chatInteraction.removeToaster = { [weak self] in
78307868
self?.genericView.removeToaster(animated: true)
78317869
}

‎packages/ChatUI/Sources/ChatUI/ChatInterfaceInteraction.swift‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ public final class ChatInteraction : InterfaceObserver {
320320
public var reactionPressed:()->Void = {}
321321
public var showToaster:(NSAttributedString)->Void = { _ in }
322322
public var previewTopic:(Int64)->Void = { _ in }
323+
public var showAnimatedToaster:(LocalAnimatedSticker, String)->Void = { _, _ in }
323324
public var removeToaster:()->Void = {}
324325
public var clearReactions:()->Void = {}
325326
public var attachFile:(Bool)->Void = { _ in }
@@ -1236,6 +1237,7 @@ public final class ChatInteraction : InterfaceObserver {
12361237
self.reactionPressed = { }
12371238
self.showToaster = { _ in }
12381239
self.previewTopic = { _ in }
1240+
self.showAnimatedToaster = { _, _ in }
12391241
self.removeToaster = { }
12401242
self.clearReactions = { }
12411243
self.attachFile = { _ in }

‎packages/ChatUI/Sources/ChatUI/ChatPresentationInterfaceState.swift‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,14 @@ public class ChatPresentationInterfaceState: Equatable {
463463
return self.interfaceState.messageEffect?.effect
464464
}
465465

466+
public func canSendReactions(to peer: Peer) -> Bool {
467+
var ignoreDefault = false
468+
if peer.id == self.peer?.id, let cachedData = cachedData as? CachedChannelData, let boostsToUnrestrict = cachedData.boostsToUnrestrict, boostsToUnrestrict > 0, let appliedBoosts = cachedData.appliedBoosts, appliedBoosts >= boostsToUnrestrict {
469+
ignoreDefault = true
470+
}
471+
return canSendReactionsToPeer(EnginePeer(peer), ignoreDefault: ignoreDefault)
472+
}
473+
466474
public var boostNeed: Int32 {
467475
if let cachedData = cachedData as? CachedChannelData, peer?.isAdmin == false {
468476
if let boostsToUnrestrict = cachedData.boostsToUnrestrict, slowMode?.timeout != nil {

‎packages/ChatUI/Sources/ChatUI/ChatReactionsView.swift‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,7 @@ public final class ChatReactionsLayout {
503503
public let mode: Mode
504504

505505

506-
public init(context: any AccountContextProtocol, message: Message, available: AvailableReactions?, peerAllowed: PeerAllowedReactions?, savedMessageTags: SavedMessageTags?, engine:Reactions, theme: TelegramPresentationTheme, renderType: ChatItemRenderType, currentTag: MessageReaction.Reaction?, isIncoming: Bool, isOutOfBounds: Bool, hasWallpaper: Bool, stateOverlayTextColor: NSColor, reactionPeer: @escaping () -> Peer?, openInfo:@escaping(PeerId)->Void, runEffect: @escaping(MessageReaction.Reaction, Bool)->Void, tagAction:@escaping(MessageReaction.Reaction)->Void, starReact:@escaping()->Void) {
506+
public init(context: any AccountContextProtocol, message: Message, available: AvailableReactions?, peerAllowed: PeerAllowedReactions?, savedMessageTags: SavedMessageTags?, engine:Reactions, theme: TelegramPresentationTheme, renderType: ChatItemRenderType, currentTag: MessageReaction.Reaction?, isIncoming: Bool, isOutOfBounds: Bool, hasWallpaper: Bool, stateOverlayTextColor: NSColor, canSendReactions: @escaping () -> Bool, openInfo:@escaping(PeerId)->Void, runEffect: @escaping(MessageReaction.Reaction, Bool)->Void, tagAction:@escaping(MessageReaction.Reaction)->Void, starReact:@escaping()->Void, reactionsDisabled:@escaping()->Void = {}) {
507507

508508
var mode: Mode = .full
509509
if message.id.peerId == context.peerId {
@@ -600,7 +600,8 @@ public final class ChatReactionsLayout {
600600
if message.id.peerId == context.peerId, !isFilterTag, mode == .tag {
601601
tagAction(value)
602602
} else {
603-
guard let peer = reactionPeer(), canSendReactionsToPeer(EnginePeer(peer)) else {
603+
guard canSendReactions() else {
604+
reactionsDisabled()
604605
return
605606
}
606607
if value == .stars {

‎packages/ChatUI/Sources/ChatUI/ChatRowItem.swift‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,12 +1042,23 @@ public class ChatRowItem: TableRowItem {
10421042
return chatInteraction.peer.flatMap { $0.id == message.id.peerId ? $0 : nil } ?? message.peers[message.id.peerId]
10431043
}
10441044

1045+
private var canSendReactionsHere: Bool {
1046+
guard let peer = reactionPermissionPeer else {
1047+
return false
1048+
}
1049+
return chatInteraction.presentation.canSendReactions(to: peer)
1050+
}
1051+
10451052
public var canReact: Bool {
1046-
if let message = firstMessage {
1053+
return canSendReactionsHere && isReactable
1054+
}
10471055

1048-
guard let peer = reactionPermissionPeer, canSendReactionsToPeer(EnginePeer(peer)) else {
1049-
return false
1050-
}
1056+
private var isReactionRestricted: Bool {
1057+
return !canSendReactionsHere && isReactable
1058+
}
1059+
1060+
private var isReactable: Bool {
1061+
if let message = firstMessage {
10511062

10521063
if message.activeEphemeralReplacementMessage != nil {
10531064
return false
@@ -1805,8 +1816,8 @@ public class ChatRowItem: TableRowItem {
18051816
let context = self.context
18061817
let chatInteraction = self.chatInteraction
18071818
if let reactions = reactions, !reactions.reactions.isEmpty, let available = context.reactions.available {
1808-
let layout = ChatReactionsLayout(context: chatInteraction.interactionContext, message: message, available: available, peerAllowed: chatInteraction.presentation.allowedReactions, savedMessageTags: entry.additionalData.savedMessageTags, engine: chatInteraction.interactionContext.reactions, theme: presentation, renderType: renderType, currentTag: currentTag, isIncoming: isIncoming, isOutOfBounds: isBubbleFullFilled && (self.captionLayouts.isEmpty || invertMedia) && (commentsBubbleData == nil || !invertMedia), hasWallpaper: presentation.hasWallpaper, stateOverlayTextColor: isStateOverlayLayout ? stateOverlayTextColor : (!hasBubble ? presentation.colors.grayText : presentation.chat.grayText(isIncoming, entry.renderType == .bubble)), reactionPeer: { [weak self] in
1809-
self?.reactionPermissionPeer
1819+
let layout = ChatReactionsLayout(context: chatInteraction.interactionContext, message: message, available: available, peerAllowed: chatInteraction.presentation.allowedReactions, savedMessageTags: entry.additionalData.savedMessageTags, engine: chatInteraction.interactionContext.reactions, theme: presentation, renderType: renderType, currentTag: currentTag, isIncoming: isIncoming, isOutOfBounds: isBubbleFullFilled && (self.captionLayouts.isEmpty || invertMedia) && (commentsBubbleData == nil || !invertMedia), hasWallpaper: presentation.hasWallpaper, stateOverlayTextColor: isStateOverlayLayout ? stateOverlayTextColor : (!hasBubble ? presentation.colors.grayText : presentation.chat.grayText(isIncoming, entry.renderType == .bubble)), canSendReactions: { [weak self] in
1820+
self?.canSendReactionsHere ?? false
18101821
}, openInfo: { peerId in
18111822
ChatHistoryUIHooks.shared.pushReactionPeerInfoObserved(context.bindings.rootNavigation(), context, peerId, message.id)
18121823
}, runEffect: { [weak chatInteraction] value, isLarge in
@@ -1820,6 +1831,8 @@ public class ChatRowItem: TableRowItem {
18201831
}, starReact: { [weak self] in
18211832
guard self?.canReact == true else { return }
18221833
showModal(with: Star_ReactionsController(context: context, source: .message(.init(message))), for: context.window)
1834+
}, reactionsDisabled: { [weak chatInteraction] in
1835+
chatInteraction?.showAnimatedToaster(.banned, "You cannot send reactions in this chat.") //TODOLANG Chat.SendReactionRestricted
18231836
})
18241837

18251838
_reactionsLayout = layout
@@ -3815,8 +3828,6 @@ public class ChatRowItem: TableRowItem {
38153828
public func reactAction() -> Bool {
38163829
if canReact {
38173830

3818-
3819-
38203831
let context = self.context
38213832
let value = ChatHistoryUIHooks.shared.reactionSettings(context).quickReaction
38223833
let message = self.message
@@ -3867,6 +3878,8 @@ public class ChatRowItem: TableRowItem {
38673878
context.reactions.react(messageId, values: ChatHistoryUIHooks.shared.messageNewReactions(message, reaction.toUpdate(), context.peerId == message.id.peerId))
38683879
}
38693880
}
3881+
} else if isReactionRestricted {
3882+
chatInteraction.showAnimatedToaster(.banned, "You cannot send reactions in this chat.") //TODOLANG Chat.SendReactionRestricted
38703883
}
38713884
return false
38723885
}

0 commit comments

Comments
 (0)