Story caption and reply text length limit

This commit is contained in:
Ilya Laktyushin 2023-07-04 14:24:15 +02:00
parent 14c595b53f
commit 16c66c7bad
10 changed files with 291 additions and 81 deletions

View File

@ -17,6 +17,7 @@ public struct UserLimitsConfiguration: Equatable {
public let maxReactionsPerMessage: Int32
public let maxSharedFolderInviteLinks: Int32
public let maxSharedFolderJoin: Int32
public let maxStoryCaptionLength: Int32
public static var defaultValue: UserLimitsConfiguration {
return UserLimitsConfiguration(
@ -34,7 +35,8 @@ public struct UserLimitsConfiguration: Equatable {
maxAnimatedEmojisInText: 10,
maxReactionsPerMessage: 1,
maxSharedFolderInviteLinks: 3,
maxSharedFolderJoin: 2
maxSharedFolderJoin: 2,
maxStoryCaptionLength: 1024
)
}
@ -53,7 +55,8 @@ public struct UserLimitsConfiguration: Equatable {
maxAnimatedEmojisInText: Int32,
maxReactionsPerMessage: Int32,
maxSharedFolderInviteLinks: Int32,
maxSharedFolderJoin: Int32
maxSharedFolderJoin: Int32,
maxStoryCaptionLength: Int32
) {
self.maxPinnedChatCount = maxPinnedChatCount
self.maxArchivedPinnedChatCount = maxArchivedPinnedChatCount
@ -70,6 +73,7 @@ public struct UserLimitsConfiguration: Equatable {
self.maxReactionsPerMessage = maxReactionsPerMessage
self.maxSharedFolderInviteLinks = maxSharedFolderInviteLinks
self.maxSharedFolderJoin = maxSharedFolderJoin
self.maxStoryCaptionLength = maxStoryCaptionLength
}
}
@ -109,5 +113,6 @@ extension UserLimitsConfiguration {
self.maxReactionsPerMessage = getValue("reactions_user_max", orElse: 1)
self.maxSharedFolderInviteLinks = getValue("chatlist_invites_limit", orElse: isPremium ? 100 : 3)
self.maxSharedFolderJoin = getValue("chatlists_joined_limit", orElse: isPremium ? 100 : 2)
self.maxStoryCaptionLength = getGeneralValue("story_caption_length_limit", orElse: defaultValue.maxStoryCaptionLength)
}
}

View File

@ -51,6 +51,7 @@ public enum EngineConfiguration {
public let maxReactionsPerMessage: Int32
public let maxSharedFolderInviteLinks: Int32
public let maxSharedFolderJoin: Int32
public let maxStoryCaptionLength: Int32
public static var defaultValue: UserLimits {
return UserLimits(UserLimitsConfiguration.defaultValue)
@ -71,7 +72,8 @@ public enum EngineConfiguration {
maxAnimatedEmojisInText: Int32,
maxReactionsPerMessage: Int32,
maxSharedFolderInviteLinks: Int32,
maxSharedFolderJoin: Int32
maxSharedFolderJoin: Int32,
maxStoryCaptionLength: Int32
) {
self.maxPinnedChatCount = maxPinnedChatCount
self.maxArchivedPinnedChatCount = maxArchivedPinnedChatCount
@ -88,6 +90,7 @@ public enum EngineConfiguration {
self.maxReactionsPerMessage = maxReactionsPerMessage
self.maxSharedFolderInviteLinks = maxSharedFolderInviteLinks
self.maxSharedFolderJoin = maxSharedFolderJoin
self.maxStoryCaptionLength = maxStoryCaptionLength
}
}
}
@ -139,7 +142,8 @@ public extension EngineConfiguration.UserLimits {
maxAnimatedEmojisInText: userLimitsConfiguration.maxAnimatedEmojisInText,
maxReactionsPerMessage: userLimitsConfiguration.maxReactionsPerMessage,
maxSharedFolderInviteLinks: userLimitsConfiguration.maxSharedFolderInviteLinks,
maxSharedFolderJoin: userLimitsConfiguration.maxSharedFolderJoin
maxSharedFolderJoin: userLimitsConfiguration.maxSharedFolderJoin,
maxStoryCaptionLength: userLimitsConfiguration.maxStoryCaptionLength
)
}
}

View File

@ -392,11 +392,20 @@ final class MediaEditorScreenComponent: Component {
}
@objc private func deactivateInput() {
self.currentInputMode = .text
if hasFirstResponder(self) {
self.endEditing(true)
guard let view = self.inputPanel.view as? MessageInputPanelComponent.View else {
return
}
if view.canDeactivateInput() {
self.currentInputMode = .text
if hasFirstResponder(self) {
if let view = self.inputPanel.view as? MessageInputPanelComponent.View {
view.deactivateInput()
}
} else {
self.state?.updated(transition: .spring(duration: 0.4).withUserData(TextFieldComponent.AnimationHint(kind: .textFocusChanged)))
}
} else {
self.state?.updated(transition: .spring(duration: 0.4).withUserData(TextFieldComponent.AnimationHint(kind: .textFocusChanged)))
view.animateError()
}
}
@ -967,13 +976,14 @@ final class MediaEditorScreenComponent: Component {
inputPanelAvailableWidth += 200.0
}
}
if environment.inputHeight > 0.0 || self.currentInputMode == .emoji {
let keyboardWasHidden = self.inputPanelExternalState.isKeyboardHidden
if environment.inputHeight > 0.0 || self.currentInputMode == .emoji || keyboardWasHidden {
inputPanelAvailableHeight = 200.0
}
var inputHeight = environment.inputHeight
var keyboardHeight = environment.deviceMetrics.standardInputHeight(inLandscape: false)
let keyboardWasHidden = self.inputPanelExternalState.isKeyboardHidden
if case .emoji = self.currentInputMode, let inputData = self.inputMediaNodeData {
let inputMediaNode: ChatEntityKeyboardInputNode
@ -1078,6 +1088,7 @@ final class MediaEditorScreenComponent: Component {
strings: environment.strings,
style: .editor,
placeholder: "Add a caption...",
maxLength: Int(component.context.userLimits.maxStoryCaptionLength),
queryTypes: [.mention],
alwaysDarkWhenHasText: false,
nextInputMode: { _ in return nextInputMode },
@ -1424,10 +1435,8 @@ final class MediaEditorScreenComponent: Component {
muteButtonView.layer.shadowOpacity = 0.35
self.addSubview(muteButtonView)
if self.animatingButtons {
muteButtonView.layer.animateAlpha(from: 0.0, to: muteButtonView.alpha, duration: 0.1)
muteButtonView.layer.animateScale(from: 0.4, to: 1.0, duration: 0.1)
}
muteButtonView.layer.animateAlpha(from: 0.0, to: muteButtonView.alpha, duration: self.animatingButtons ? 0.1 : 0.2)
muteButtonView.layer.animateScale(from: 0.4, to: 1.0, duration: self.animatingButtons ? 0.1 : 0.2)
}
transition.setPosition(view: muteButtonView, position: muteButtonFrame.center)
transition.setBounds(view: muteButtonView, bounds: CGRect(origin: .zero, size: muteButtonFrame.size))

View File

@ -250,6 +250,7 @@ final class StoryPreviewComponent: Component {
strings: presentationData.strings,
style: .story,
placeholder: "Reply Privately...",
maxLength: nil,
queryTypes: [],
alwaysDarkWhenHasText: false,
nextInputMode: { _ in return .stickers },

View File

@ -64,6 +64,7 @@ public final class MessageInputPanelComponent: Component {
public let strings: PresentationStrings
public let style: Style
public let placeholder: String
public let maxLength: Int?
public let queryTypes: ContextQueryTypes
public let alwaysDarkWhenHasText: Bool
public let nextInputMode: (Bool) -> InputMode?
@ -104,6 +105,7 @@ public final class MessageInputPanelComponent: Component {
strings: PresentationStrings,
style: Style,
placeholder: String,
maxLength: Int?,
queryTypes: ContextQueryTypes,
alwaysDarkWhenHasText: Bool,
nextInputMode: @escaping (Bool) -> InputMode?,
@ -144,6 +146,7 @@ public final class MessageInputPanelComponent: Component {
self.style = style
self.nextInputMode = nextInputMode
self.placeholder = placeholder
self.maxLength = maxLength
self.queryTypes = queryTypes
self.alwaysDarkWhenHasText = alwaysDarkWhenHasText
self.areVoiceMessagesAvailable = areVoiceMessagesAvailable
@ -196,6 +199,9 @@ public final class MessageInputPanelComponent: Component {
if lhs.placeholder != rhs.placeholder {
return false
}
if lhs.maxLength != rhs.maxLength {
return false
}
if lhs.queryTypes != rhs.queryTypes {
return false
}
@ -266,6 +272,8 @@ public final class MessageInputPanelComponent: Component {
private let placeholder = ComponentView<Empty>()
private let vibrancyPlaceholder = ComponentView<Empty>()
private let counter = ComponentView<Empty>()
private var disabledPlaceholder: ComponentView<Empty>?
private let textField = ComponentView<Empty>()
private let textFieldExternalState = TextFieldComponent.ExternalState()
@ -297,6 +305,8 @@ public final class MessageInputPanelComponent: Component {
private var viewForOverlayContent: ViewForOverlayContent?
private var currentEmojiSuggestionView: ComponentHostView<Empty>?
private let hapticFeedback = HapticFeedback()
private var component: MessageInputPanelComponent?
private weak var state: EmptyComponentState?
@ -376,6 +386,30 @@ public final class MessageInputPanelComponent: Component {
}
}
public func canDeactivateInput() -> Bool {
guard let component = self.component else {
return true
}
if let maxLength = component.maxLength, self.textFieldExternalState.textLength > maxLength {
return false
} else {
return true
}
}
public func deactivateInput() {
if self.canDeactivateInput() {
if let textFieldView = self.textField.view as? TextFieldComponent.View {
textFieldView.deactivateInput()
}
}
}
public func animateError() {
self.textField.view?.layer.addShakeAnimation()
self.hapticFeedback.error()
}
public func updateContextQueries() {
guard let component = self.component, let textFieldView = self.textField.view as? TextFieldComponent.View else {
return
@ -534,7 +568,6 @@ public final class MessageInputPanelComponent: Component {
)
let isEditing = self.textFieldExternalState.isEditing || component.forceIsEditing
let placeholderSize = self.placeholder.update(
transition: .immediate,
component: AnyComponent(Text(
@ -568,7 +601,7 @@ public final class MessageInputPanelComponent: Component {
} else {
fieldBackgroundFrame = fieldFrame
}
transition.setFrame(view: self.vibrancyEffectView, frame: CGRect(origin: CGPoint(), size: fieldBackgroundFrame.size))
transition.setFrame(view: self.fieldBackgroundView, frame: fieldBackgroundFrame)
@ -659,6 +692,36 @@ public final class MessageInputPanelComponent: Component {
}
}
if let maxLength = component.maxLength, maxLength - self.textFieldExternalState.textLength < 5 {
let remainingLength = max(-999, maxLength - self.textFieldExternalState.textLength)
let counterSize = self.counter.update(
transition: .immediate,
component: AnyComponent(Text(
text: "\(remainingLength)",
font: Font.with(size: 14.0, traits: .monospacedNumbers),
color: self.textFieldExternalState.textLength > maxLength ? UIColor(rgb: 0xff3b30) : UIColor(rgb: 0xffffff, alpha: 0.25)
)),
environment: {},
containerSize: availableTextFieldSize
)
let counterFrame = CGRect(origin: CGPoint(x: availableSize.width - insets.right + floorToScreenPixels((insets.right - counterSize.width) * 0.5), y: size.height - insets.bottom - baseFieldHeight - counterSize.height - 5.0), size: counterSize)
if let counterView = self.counter.view {
if counterView.superview == nil {
self.addSubview(counterView)
counterView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
counterView.center = counterFrame.center
} else {
transition.setPosition(view: counterView, position: counterFrame.center)
}
counterView.bounds = CGRect(origin: .zero, size: counterFrame.size)
}
} else if let counterView = self.counter.view, counterView.superview != nil {
counterView.layer.animateAlpha(from: 1.00, to: 0.0, duration: 0.2, completion: { _ in
counterView.removeFromSuperview()
})
}
if component.attachmentAction != nil {
let attachmentButtonMode: MessageInputActionButtonComponent.Mode
attachmentButtonMode = .attach
@ -830,12 +893,20 @@ public final class MessageInputPanelComponent: Component {
component.sendMessageAction()
} else if case let .text(string) = self.getSendMessageInput(), string.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
} else {
component.sendMessageAction()
if let maxLength = component.maxLength, self.textFieldExternalState.textLength > maxLength {
self.animateError()
} else {
component.sendMessageAction()
}
}
}
case .apply:
if case .up = action {
component.sendMessageAction()
if let maxLength = component.maxLength, self.textFieldExternalState.textLength > maxLength {
self.animateError()
} else {
component.sendMessageAction()
}
}
case .voiceInput, .videoInput:
component.setMediaRecordingActive?(action == .down, mode == .videoInput, sendAction)

View File

@ -73,6 +73,7 @@ swift_library(
"//submodules/ImageBlur",
"//submodules/StickerPackPreviewUI",
"//submodules/Components/AnimatedStickerComponent",
"//submodules/OpenInExternalAppUI",
],
visibility = [
"//visibility:public",

View File

@ -44,19 +44,22 @@ final class StoryContentCaptionComponent: Component {
let text: String
let entities: [MessageTextEntity]
let action: (Action) -> Void
let longTapAction: (Action) -> Void
init(
externalState: ExternalState,
context: AccountContext,
text: String,
entities: [MessageTextEntity],
action: @escaping (Action) -> Void
action: @escaping (Action) -> Void,
longTapAction: @escaping (Action) -> Void
) {
self.externalState = externalState
self.context = context
self.text = text
self.entities = entities
self.action = action
self.longTapAction = longTapAction
}
static func ==(lhs: StoryContentCaptionComponent, rhs: StoryContentCaptionComponent) -> Bool {
@ -242,44 +245,47 @@ final class StoryContentCaptionComponent: Component {
switch recognizer.state {
case .ended:
if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation, let component = self.component, let textNode = self.textNode {
switch gesture {
case .tap:
let titleFrame = textNode.textNode.view.bounds
if titleFrame.contains(location) {
if let (index, attributes) = textNode.textNode.attributesAtPoint(CGPoint(x: location.x - titleFrame.minX, y: location.y - titleFrame.minY)) {
if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler)], !(self.dustNode?.isRevealed ?? true) {
let convertedPoint = recognizer.view?.convert(location, to: self.dustNode?.view) ?? location
self.dustNode?.revealAtLocation(convertedPoint)
return
} else if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String {
var concealed = true
if let (attributeText, fullText) = textNode.textNode.attributeSubstring(name: TelegramTextAttributes.URL, index: index) {
concealed = !doesUrlMatchText(url: url, text: attributeText, fullText: fullText)
}
component.action(.url(url: url, concealed: concealed))
return
} else if let peerMention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention {
component.action(.peerMention(peerId: peerMention.peerId, mention: peerMention.mention))
return
} else if let peerName = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String {
component.action(.textMention(peerName))
return
} else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag {
component.action(.hashtag(hashtag.peerName, hashtag.hashtag))
return
} else if let bankCard = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BankCard)] as? String {
component.action(.bankCard(bankCard))
return
} else if let emoji = attributes[NSAttributedString.Key(rawValue: ChatTextInputAttributes.customEmoji.rawValue)] as? ChatTextInputTextCustomEmojiAttribute, let file = emoji.file {
component.action(.customEmoji(file))
return
let titleFrame = textNode.textNode.view.bounds
if titleFrame.contains(location) {
if let (index, attributes) = textNode.textNode.attributesAtPoint(CGPoint(x: location.x - titleFrame.minX, y: location.y - titleFrame.minY)) {
let action: Action?
if case .tap = gesture, let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler)], !(self.dustNode?.isRevealed ?? true) {
let convertedPoint = recognizer.view?.convert(location, to: self.dustNode?.view) ?? location
self.dustNode?.revealAtLocation(convertedPoint)
return
} else if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String {
var concealed = true
if let (attributeText, fullText) = textNode.textNode.attributeSubstring(name: TelegramTextAttributes.URL, index: index) {
concealed = !doesUrlMatchText(url: url, text: attributeText, fullText: fullText)
}
action = .url(url: url, concealed: concealed)
} else if let peerMention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention {
action = .peerMention(peerId: peerMention.peerId, mention: peerMention.mention)
} else if let peerName = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String {
action = .textMention(peerName)
} else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag {
action = .hashtag(hashtag.peerName, hashtag.hashtag)
} else if let bankCard = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BankCard)] as? String {
action = .bankCard(bankCard)
} else if let emoji = attributes[NSAttributedString.Key(rawValue: ChatTextInputAttributes.customEmoji.rawValue)] as? ChatTextInputTextCustomEmojiAttribute, let file = emoji.file {
action = .customEmoji(file)
} else {
action = nil
}
guard let action else {
return
}
switch gesture {
case .tap:
component.action(action)
case .longTap:
component.longTapAction(action)
default:
return
}
self.expand(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)))
return
}
self.expand(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)))
default:
break
}
}
default:

View File

@ -645,14 +645,25 @@ public final class StoryItemSetContainerComponent: Component {
return false
}
private func deactivateInput() {
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state, let component = self.component, let itemLayout = self.itemLayout {
if hasFirstResponder(self) {
self.sendMessageContext.currentInputMode = .text
self.endEditing(true)
} else if case .media = self.sendMessageContext.currentInputMode {
self.sendMessageContext.currentInputMode = .text
self.state?.updated(transition: .spring(duration: 0.4))
if hasFirstResponder(self) || self.sendMessageContext.currentInputMode == .media {
if let view = self.inputPanel.view as? MessageInputPanelComponent.View {
if view.canDeactivateInput() {
self.sendMessageContext.currentInputMode = .text
if hasFirstResponder(self) {
view.deactivateInput()
} else {
self.state?.updated(transition: .spring(duration: 0.4).withUserData(TextFieldComponent.AnimationHint(kind: .textFocusChanged)))
}
} else {
view.animateError()
}
}
} else if self.displayViewList {
let point = recognizer.location(in: self)
@ -1686,6 +1697,7 @@ public final class StoryItemSetContainerComponent: Component {
strings: component.strings,
style: .story,
placeholder: "Reply Privately...",
maxLength: 4096,
queryTypes: [.mention, .emoji],
alwaysDarkWhenHasText: component.metrics.widthClass == .regular,
nextInputMode: { [weak self] hasText in
@ -2538,11 +2550,29 @@ public final class StoryItemSetContainerComponent: Component {
self.sendMessageContext.openPeerMention(view: self, peerId: peerId)
case let .hashtag(username, value):
self.sendMessageContext.openHashtag(view: self, hashtag: value, peerName: username)
case let .bankCard(value):
let _ = value
case .bankCard:
break
case .customEmoji:
break
}
},
longTapAction: { [weak self] action in
guard let self, let component = self.component else {
return
}
self.sendMessageContext.presentTextEntityActions(view: self, action: action, openUrl: { [weak self] url, concealed in
openUserGeneratedUrl(context: component.context, peerId: component.slice.peer.id, url: url, concealed: concealed, skipUrlAuth: false, skipConcealedAlert: false, present: { [weak self] c in
guard let self, let component = self.component, let controller = component.controller() else {
return
}
controller.present(c, in: .window(.root))
}, openResolved: { [weak self] resolved in
guard let self else {
return
}
self.sendMessageContext.openResolved(view: self, result: resolved, forceExternal: false, concealed: concealed)
})
})
}
)),
environment: {},

View File

@ -36,6 +36,8 @@ import OverlayStatusController
import PresentationDataUtils
import TextFieldComponent
import StickerPackPreviewUI
import OpenInExternalAppUI
import SafariServices
final class StoryItemSetContainerSendMessage {
enum InputMode {
@ -526,7 +528,7 @@ final class StoryItemSetContainerSendMessage {
if self.videoRecorderValue == nil {
if let currentInputPanelFrame = view.inputPanel.view?.frame {
self.videoRecorder.set(.single(legacyInstantVideoController(theme: defaultDarkPresentationTheme, forStory: true, panelFrame: view.convert(currentInputPanelFrame, to: nil), context: component.context, peerId: peer.id, slowmodeState: nil, hasSchedule: true, send: { [weak self, weak view] videoController, message in
guard let self, let view, let component = view.component else {
guard let self, let view else {
return
}
guard let message = message else {
@ -541,15 +543,6 @@ final class StoryItemSetContainerSendMessage {
self.videoRecorder.set(.single(nil))
self.sendMessages(view: view, peer: peer, messages: [updatedMessage])
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
view.component?.controller()?.present(UndoOverlayController(
presentationData: presentationData,
content: .succeed(text: "Message Sent"),
elevatedLayout: false,
animateInAsReplacement: false,
action: { _ in return false }
), in: .current)
}, displaySlowmodeTooltip: { [weak self] view, rect in
//self?.interfaceInteraction?.displaySlowmodeTooltip(view, rect)
let _ = self
@ -597,15 +590,6 @@ final class StoryItemSetContainerSendMessage {
self.sendMessages(view: view, peer: peer, messages: [.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: randomId), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: Int64(data.compressedData.count), attributes: [.Audio(isVoice: true, duration: Int(data.duration), title: nil, performer: nil, waveform: waveformBuffer)])), replyToMessageId: nil, replyToStoryId: focusedStoryId, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])])
HapticFeedback().tap()
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
view.component?.controller()?.present(UndoOverlayController(
presentationData: presentationData,
content: .succeed(text: "Message Sent"),
elevatedLayout: false,
animateInAsReplacement: false,
action: { _ in return false }
), in: .current)
}
})
} else if let videoRecorderValue = self.videoRecorderValue {
@ -2520,4 +2504,97 @@ final class StoryItemSetContainerSendMessage {
}
}))
}
func presentTextEntityActions(view: StoryItemSetContainerComponent.View, action: StoryContentCaptionComponent.Action, openUrl: @escaping (String, Bool) -> Void) {
guard let component = view.component else {
return
}
let actionSheet = ActionSheetController(theme: ActionSheetControllerTheme(presentationTheme: component.theme, fontSize: .regular), allowInputInset: false)
var canOpenIn = false
let title: String
let value: String
var openAction: String? = component.strings.Conversation_LinkDialogOpen
var copyAction = component.strings.Conversation_ContextMenuCopy
switch action {
case let .url(url, _):
title = url
value = url
canOpenIn = availableOpenInOptions(context: component.context, item: .url(url: url)).count > 1
if canOpenIn {
openAction = component.strings.Conversation_FileOpenIn
}
copyAction = component.strings.Conversation_ContextMenuCopyLink
case let .hashtag(_, hashtag):
title = hashtag
value = hashtag
case let .bankCard(bankCard):
title = bankCard
value = bankCard
openAction = nil
case let .peerMention(_, mention):
title = mention
value = mention
case let .textMention(mention):
title = mention
value = mention
case .customEmoji:
return
}
var items: [ActionSheetItem] = []
items.append(ActionSheetTextItem(title: title))
if let openAction {
items.append(ActionSheetButtonItem(title: openAction, color: .accent, action: { [weak self, weak view, weak actionSheet] in
actionSheet?.dismissAnimated()
if let self, let view {
switch action {
case let .url(url, concealed):
if canOpenIn {
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let actionSheet = OpenInActionSheetController(context: component.context, item: .url(url: url), openUrl: { url in
if let navigationController = component.controller()?.navigationController as? NavigationController {
component.context.sharedContext.openExternalUrl(context: component.context, urlContext: .generic, url: url, forceExternal: true, presentationData: presentationData, navigationController: navigationController, dismissInput: {})
}
})
component.controller()?.present(actionSheet, in: .window(.root))
} else {
openUrl(url, concealed)
}
case let .hashtag(peerName, value):
self.openHashtag(view: view, hashtag: value, peerName: peerName)
case let .peerMention(peerId, _):
self.openPeerMention(view: view, peerId: peerId)
case let .textMention(mention):
self.openPeerMention(view: view, name: mention)
case .customEmoji, .bankCard:
return
}
}
}))
}
items.append(ActionSheetButtonItem(title: copyAction, color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
UIPasteboard.general.string = value
}))
if case let .url(url, _) = action, let link = URL(string: url) {
items.append(ActionSheetButtonItem(title: component.strings.Conversation_AddToReadingList, color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
let _ = try? SSReadingList.default()?.addItem(with: link, title: nil, previewText: nil)
}))
}
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: component.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
component.controller()?.present(actionSheet, in: .window(.root))
}
}

View File

@ -24,6 +24,7 @@ public final class TextFieldComponent: Component {
public final class ExternalState {
public fileprivate(set) var isEditing: Bool = false
public fileprivate(set) var hasText: Bool = false
public fileprivate(set) var textLength: Int = 0
public var initialText: NSAttributedString?
public var hasTrackingView = false
@ -492,6 +493,10 @@ public final class TextFieldComponent: Component {
self.textView.becomeFirstResponder()
}
public func deactivateInput() {
self.textView.resignFirstResponder()
}
private var spoilersRevealed = false
private var spoilerIsDisappearing = false
private func updateSpoilersRevealed(animated: Bool = true) {
@ -775,6 +780,7 @@ public final class TextFieldComponent: Component {
component.externalState.hasText = self.textStorage.length != 0
component.externalState.isEditing = isEditing
component.externalState.textLength = self.textStorage.string.count
if component.hideKeyboard {
if self.textView.inputView == nil {