Skip to content

Commit b33c855

Browse files
authored
Fix links and text selection in poll messages and poll results modal (#94)
* fix(ChatUI, PollResultsUI): enable links and text selection in polls and results view * fix(PollResultsUI): respect copy protection in poll text and results * fix(ChatUI): address poll interaction and copy review * fix(ChatUI): avoid redundant poll redraws and restore description menus * fix(ChatUI): route poll copy and translation to the correct text
1 parent 78e30d6 commit b33c855

8 files changed

Lines changed: 1054 additions & 35 deletions

File tree

‎packages/ChatUI/Sources/ChatUI/ChatMessageMenuItems.swift‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -908,7 +908,7 @@ public func chatMenuItems(for message: Message, entry: ChatHistoryEntry?, textLa
908908
}
909909
}
910910

911-
if let poll = message.media.first as? TelegramMediaPoll, mode.customChatContents == nil {
911+
if let poll = message.media.first as? TelegramMediaPoll, mode.customChatContents == nil, data.textLayout?.0?.selectedRange.hasSelectText != true {
912912
var text = poll.text
913913
var entities: [MessageTextEntity] = []
914914
entities.append(contentsOf: poll.textEntities)
@@ -950,7 +950,7 @@ public func chatMenuItems(for message: Message, entry: ChatHistoryEntry?, textLa
950950
// Rich messages get their own Translate item above (flattened preview + translateRichMessage) —
951951
// skip the plain-text item so the menu doesn't offer two.
952952
let isRichMessage = message.attributes.contains(where: { $0 is RichTextMessageAttribute })
953-
if !isRichMessage, language == nil || !toLang.contains(language!), !muteTranslate, !isService, translateConfig.canTranslate {
953+
if !isRichMessage, !(message.media.first is TelegramMediaPoll), language == nil || !toLang.contains(language!), !muteTranslate, !isService, translateConfig.canTranslate {
954954
thirdBlock.append(ContextMenuItem(strings().chatContextTranslate, handler: {
955955
showModal(with: ChatHistoryUIHooks.shared.makeTranslateModalObserved(context, language, appAppearance.languageCode, text, entities, source: .message(messageId), onToneChanged: { tone in
956956
data.chatInteraction.setTranslateTone(tone)

‎packages/ChatUI/Sources/ChatUI/ChatPollItem.swift‎

Lines changed: 165 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,14 @@ public class ChatPollItem: ChatRowItem {
399399
}
400400

401401
public let isTranslateLoading: Bool
402+
403+
fileprivate var canCopyText: Bool {
404+
guard let message = message, !message.flags.contains(.CopyProtected) else {
405+
return false
406+
}
407+
let peer = chatInteraction.peer.flatMap { $0.id == message.id.peerId ? $0 : nil } ?? message.peers[message.id.peerId]
408+
return peer.map { !$0.isCopyProtected(cachedData: chatInteraction.presentation.cachedData) } ?? false
409+
}
402410

403411
public override init(_ initialSize: NSSize, _ chatInteraction: ChatInteraction, _ context: any AccountContextProtocol, _ object: ChatHistoryEntry, theme: TelegramPresentationTheme) {
404412

@@ -527,7 +535,11 @@ public class ChatPollItem: ChatRowItem {
527535
optionText.append(string: option.text, color: self.presentation.chat.textColor(isIncoming, renderType == .bubble), font: nameFont)
528536
InlineStickerItem.apply(to: optionText, associatedMedia: message?.associatedMedia ?? [:], entities: option.entities, isPremium: context.isPremium)
529537

530-
let nameLayout = TextViewLayout(optionText, alwaysStaticItems: true)
538+
let nameLayout = TextViewLayout(optionText, selectText: self.presentation.chat.selectText(isIncoming, renderType == .bubble), alwaysStaticItems: true, mayItems: canCopyText)
539+
nameLayout.interactions.copyAttributedString = LinkHooks.shared.globalLinkExecutor().copyAttributedString
540+
nameLayout.interactions.copy = { [weak self] in
541+
return self?.canCopyText != true
542+
}
531543

532544
let wrapper = PollOption(option: option, nameText: nameLayout, percent: percent, realPercent: realPercent, voteCount: votedCount, isSelected: isSelected, isIncoming: isIncoming, isBubbled: renderType == .bubble, voted: voted, isLoading: object.additionalData.pollStateData.identifiers.contains(option.opaqueIdentifier) && object.additionalData.pollStateData.isLoading, presentation: self.presentation, isCorrect: isCorrect, isQuiz: poll.isQuiz, isMultipleSelected: object.additionalData.pollStateData.identifiers.contains(option.opaqueIdentifier), isMultipleChoice: poll.isMultiple, optionMedia: option.media, anyOptionHasMedia: anyOptionHasMedia, hideResults: hideResults && (voted || object.additionalData.pollStateData.isLoading), vote: { [weak self] control in
533545
self?.voteOption(option, for: control)
@@ -563,8 +575,42 @@ public class ChatPollItem: ChatRowItem {
563575
if let message = message, !message.text.isEmpty {
564576
let descAttr = ChatMessageItem.applyMessageEntities(with: message.attributes, for: message.text, message: message, context: context, fontSize: theme.fontSize, openInfo: { [weak chatInteraction] peerId, toChat, postId, action in
565577
chatInteraction?.openInfo(peerId, toChat, postId, action)
566-
}, textColor: self.presentation.chat.textColor(isIncoming, renderType == .bubble), linkColor: self.presentation.chat.linkColor(isIncoming, renderType == .bubble), isDark: theme.colors.isDark, bubbled: renderType == .bubble).mutableCopy() as! NSMutableAttributedString
567-
self.descriptionText = TextViewLayout(descAttr, alwaysStaticItems: true)
578+
}, botCommand: { [weak chatInteraction] command in
579+
chatInteraction?.sendPlainText(command)
580+
}, hashtag: { [weak chatInteraction] tag in
581+
chatInteraction?.hashtag(tag)
582+
}, applyProxy: { [weak chatInteraction] proxy in
583+
chatInteraction?.applyProxy(proxy)
584+
}, textColor: self.presentation.chat.textColor(isIncoming, renderType == .bubble), linkColor: self.presentation.chat.linkColor(isIncoming, renderType == .bubble), openBank: { [weak chatInteraction] bank in
585+
chatInteraction?.openBank(bank)
586+
}, isDark: theme.colors.isDark, bubbled: renderType == .bubble, openPhoneNumber: { [weak chatInteraction] phone in
587+
chatInteraction?.openPhoneNumberContextMenu(phone)
588+
}, openTonAddress: { [weak chatInteraction] address in
589+
chatInteraction?.openTonAddressContextMenu(address)
590+
}, ignoreLinks: !object.additionalData.canHighlightLinks && isIncoming).mutableCopy() as! NSMutableAttributedString
591+
let layout = TextViewLayout(descAttr, selectText: self.presentation.chat.selectText(isIncoming, renderType == .bubble), strokeLinks: renderType == .bubble, alwaysStaticItems: true, disableTooltips: false, mayItems: canCopyText)
592+
layout.interactions = LinkHooks.shared.globalLinkExecutor()
593+
layout.interactions.copy = { [weak self] in
594+
return self?.canCopyText != true
595+
}
596+
layout.interactions.topWindow = { [weak self] in
597+
return self?.menuAdditionView ?? .single(nil)
598+
}
599+
layout.interactions.menuItems = { [weak self] type in
600+
if let strongSelf = self, let message = strongSelf.message {
601+
return chatMenuItems(for: message, entry: strongSelf.entry, textLayout: (strongSelf.descriptionText, type), chatInteraction: strongSelf.chatInteraction)
602+
}
603+
return .complete()
604+
}
605+
let processURL = layout.interactions.processURL
606+
let makeLinkType = layout.interactions.makeLinkType
607+
layout.interactions.processURL = { [weak self] link in
608+
if case .code = makeLinkType((link, "")), self?.canCopyText != true {
609+
return
610+
}
611+
processURL(link)
612+
}
613+
self.descriptionText = layout
568614
}
569615

570616
let titleAttr = NSMutableAttributedString()
@@ -573,7 +619,11 @@ public class ChatPollItem: ChatRowItem {
573619
InlineStickerItem.apply(to: titleAttr, associatedMedia: message?.associatedMedia ?? [:], entities: poll.textEntities, isPremium: context.isPremium)
574620

575621

576-
self.titleText = TextViewLayout(titleAttr, alwaysStaticItems: true)
622+
self.titleText = TextViewLayout(titleAttr, selectText: self.presentation.chat.selectText(isIncoming, renderType == .bubble), alwaysStaticItems: true, mayItems: canCopyText)
623+
self.titleText.interactions.copyAttributedString = LinkHooks.shared.globalLinkExecutor().copyAttributedString
624+
self.titleText.interactions.copy = { [weak self] in
625+
return self?.canCopyText != true
626+
}
577627

578628

579629
let typeText: String = self.isBotQuiz ? strings().chatQuizTextType : poll.title
@@ -715,7 +765,7 @@ public class ChatPollItem: ChatRowItem {
715765
} else {
716766
source = .general
717767
}
718-
return chatMenuItems(for: message, entry: entry, textLayout: nil, chatInteraction: chatInteraction, source: source)
768+
return chatMenuItems(for: message, entry: entry, textLayout: foundIndex == nil ? ((view as? ChatPollItemView)?.menuTextLayout(at: location), nil) : nil, chatInteraction: chatInteraction, source: source)
719769
}
720770
return super.menuItems(in: location)
721771
}
@@ -968,7 +1018,8 @@ public final class ChatPollItemView : ChatRowView {
9681018

9691019
contentNode.change(size: NSMakeSize(contentFrameModifier(item).width, item.contentSize.height), animated: animated)
9701020
contentNode.update(with: item, animated: animated)
971-
1021+
updateTextSelection(updatePermissions: true)
1022+
9721023
contentNode.updateLayout(item: item, size: contentNode.frame.size, transition: animated ? .animated(duration: 0.2, curve: .easeOut) : .immediate)
9731024
}
9741025

@@ -1003,18 +1054,71 @@ public final class ChatPollItemView : ChatRowView {
10031054
fatalError("init(coder:) has not been implemented")
10041055
}
10051056

1057+
public override func notify(with value: Any, oldValue: Any, animated: Bool) {
1058+
super.notify(with: value, oldValue: oldValue, animated: animated)
1059+
updateTextSelection()
1060+
}
1061+
1062+
private func updateTextSelection(updatePermissions: Bool = false) {
1063+
guard let item = item as? ChatPollItem else { return }
1064+
let canCopy = item.canCopyText
1065+
if !canCopy, selectManager.find(item.stableId) != nil {
1066+
selectManager.removeAll()
1067+
}
1068+
var views: [TextView] = [contentNode.titleView.textView] + contentNode.options.map { $0.nameView.textView }
1069+
if let descriptionView = contentNode.descriptionView {
1070+
views.append(descriptionView)
1071+
}
1072+
for view in views {
1073+
if updatePermissions || view.textLayout?.mayItems != canCopy {
1074+
view.isSelectable = canCopy
1075+
view.textLayout?.mayItems = canCopy
1076+
}
1077+
if !canCopy, let layout = view.textLayout, layout.selectedRange.range.location != NSNotFound || !layout.additionalSelections.isEmpty {
1078+
layout.clearSelect()
1079+
layout.additionalSelections = []
1080+
view.needsDisplay = true
1081+
}
1082+
}
1083+
}
1084+
1085+
func menuTextLayout(at location: NSPoint) -> TextViewLayout? {
1086+
let views: [TextView] = [contentNode.titleView.textView] + [contentNode.descriptionView].compactMap { $0 }
1087+
for view in views {
1088+
let point = view.convert(location, from: contentView)
1089+
if view.bounds.contains(point) {
1090+
return view.textLayout
1091+
}
1092+
}
1093+
return nil
1094+
}
1095+
10061096
public override func canStartTextSelecting(_ event: NSEvent) -> Bool {
1007-
1008-
let point = contentView.convert(event.locationInWindow, from: nil)
1009-
return NSPointInRect(point, NSMakeRect(0, contentNode.titleView.frame.minY, contentNode.frame.width, contentNode.titleView.frame.height))
1097+
for view in selectableTextViews {
1098+
let point = view.convert(event.locationInWindow, from: nil)
1099+
if NSPointInRect(point, view.bounds) {
1100+
return true
1101+
}
1102+
}
1103+
return false
10101104
}
1011-
1105+
10121106
override var textSelectionSources: ChatTextSelectionSources {
10131107
return .views(selectableTextViews)
10141108
}
10151109

10161110
public override var selectableTextViews: [TextView] {
1017-
return [contentNode.titleView.textView]
1111+
guard let item = item as? ChatPollItem, item.canCopyText else {
1112+
return []
1113+
}
1114+
var views: [TextView] = [contentNode.titleView.textView]
1115+
if let descriptionView = contentNode.descriptionView {
1116+
views.append(descriptionView)
1117+
}
1118+
for option in contentNode.options {
1119+
views.append(option.nameView.textView)
1120+
}
1121+
return views
10181122
}
10191123

10201124
public override func canMultiselectTextIn(_ location: NSPoint) -> Bool {
@@ -1061,7 +1165,7 @@ public final class ChatPollItemView : ChatRowView {
10611165

10621166
private final class PollOptionView : Control {
10631167
private var percentView: ImageView?
1064-
private let nameView: InteractiveTextView = InteractiveTextView(frame: .zero)
1168+
fileprivate let nameView: InteractiveTextView = InteractiveTextView(frame: .zero)
10651169
private var selectingView:ImageView?
10661170
private let progressBackgroundView: View = View(frame: NSZeroRect)
10671171
private let progressView: LinearProgressControl = LinearProgressControl(progressHeight: 5)
@@ -1075,11 +1179,52 @@ private final class PollOptionView : Control {
10751179

10761180
private var selectedImageView: ImageView?
10771181

1182+
private var startMouseDownPoint: NSPoint?
1183+
private var didDrag: Bool = false
1184+
1185+
override func mouseDown(with event: NSEvent) {
1186+
startMouseDownPoint = event.locationInWindow
1187+
didDrag = false
1188+
if !event.modifierFlags.contains(.control) {
1189+
selectManager.removeAll()
1190+
}
1191+
super.mouseDown(with: event)
1192+
}
1193+
1194+
override func mouseDragged(with event: NSEvent) {
1195+
if let start = startMouseDownPoint {
1196+
if chatTextSelectionDidDrag(from: start, to: event.locationInWindow) {
1197+
didDrag = true
1198+
}
1199+
}
1200+
super.mouseDragged(with: event)
1201+
}
1202+
1203+
override func mouseUp(with event: NSEvent) {
1204+
if let start = startMouseDownPoint {
1205+
if chatTextSelectionDidDrag(from: start, to: event.locationInWindow) {
1206+
didDrag = true
1207+
}
1208+
}
1209+
defer {
1210+
startMouseDownPoint = nil
1211+
didDrag = false
1212+
}
1213+
super.mouseUp(with: event)
1214+
}
1215+
1216+
override func accessibilityPerformPress() -> Bool {
1217+
startMouseDownPoint = nil
1218+
didDrag = false
1219+
return super.accessibilityPerformPress()
1220+
}
1221+
10781222
private var option: PollOption?
10791223
private weak var pollItem: ChatPollItem?
10801224
required init(frame frameRect: NSRect) {
10811225
super.init(frame: frameRect)
10821226
nameView.userInteractionEnabled = false
1227+
nameView.textView.isSelectable = true
10831228
progressView.hasMinumimVisibility = true
10841229
progressBackgroundView.layer?.cornerRadius = 2.5
10851230
addSubview(nameView)
@@ -1095,9 +1240,13 @@ private final class PollOptionView : Control {
10951240

10961241
progressView.isEventLess = true
10971242
progressBackgroundView.isEventLess = true
1098-
1243+
10991244
set(handler: { [weak self] control in
1100-
self?.option?.vote(control)
1245+
guard let self = self else { return }
1246+
if self.startMouseDownPoint != nil && (self.didDrag || !selectManager.isEmpty) {
1247+
return
1248+
}
1249+
self.option?.vote(control)
11011250
}, for: .Click)
11021251
}
11031252

@@ -1659,7 +1808,7 @@ private final class PollOptionView : Control {
16591808

16601809
private final class PollView : Control {
16611810
fileprivate var mediaContentView: ChatMediaContentView?
1662-
private var descriptionView: TextView?
1811+
fileprivate var descriptionView: TextView?
16631812
fileprivate let titleView: InteractiveTextView = InteractiveTextView(frame: .zero)
16641813
private let typeView: TextView = TextView()
16651814
private var actionButton: TextButton?
@@ -1736,6 +1885,7 @@ private final class PollView : Control {
17361885
self.descriptionView = descView
17371886
addSubview(descView)
17381887
}
1888+
descView.isSelectable = item.canCopyText
17391889
descView.update(descriptionText)
17401890
} else {
17411891
self.descriptionView?.removeFromSuperview()

‎packages/ChatUI/Sources/ChatUI/ChatSelectText.swift‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,12 @@ public protocol MultipleSelectable {
486486
var header: String? { get }
487487
}
488488

489+
func chatTextSelectionDidDrag(from start: NSPoint, to location: NSPoint) -> Bool {
490+
let dx = location.x - start.x
491+
let dy = location.y - start.y
492+
return dx * dx + dy * dy > 4 * 4
493+
}
494+
489495
public class ChatSelectText : NSObject {
490496

491497
private var beginInnerLocation:NSPoint = NSMakePoint(-1, -1)
@@ -500,7 +506,6 @@ public class ChatSelectText : NSObject {
500506
private var locationInWindow: NSPoint? = nil
501507
private var reversible: Bool = false
502508
private var isDragConfirmed: Bool = false
503-
private let dragThreshold: CGFloat = 4
504509

505510
private func confirmDragIfNeeded(_ location: NSPoint) -> Bool {
506511
if isDragConfirmed {
@@ -509,9 +514,7 @@ public class ChatSelectText : NSObject {
509514
guard let start = locationInWindow else {
510515
return false
511516
}
512-
let dx = location.x - start.x
513-
let dy = location.y - start.y
514-
if dx * dx + dy * dy > dragThreshold * dragThreshold {
517+
if chatTextSelectionDidDrag(from: start, to: location) {
515518
isDragConfirmed = true
516519
}
517520
return isDragConfirmed

0 commit comments

Comments
 (0)