diff --git a/NextcloudTalk.xcodeproj/project.pbxproj b/NextcloudTalk.xcodeproj/project.pbxproj index 747b15217..73c4bd87a 100644 --- a/NextcloudTalk.xcodeproj/project.pbxproj +++ b/NextcloudTalk.xcodeproj/project.pbxproj @@ -3387,7 +3387,7 @@ repositoryURL = "https://github.com/nextcloud-deps/CDMarkdownKit.git"; requirement = { kind = revision; - revision = 4b328bc7bbf4a822b56a6b925fb087275cca2ed4; + revision = 632cd89fb53fcb6bf04d53219e984bdf279ea19a; }; }; 1F45A1142A01D6EC005FE87D /* XCRemoteSwiftPackageReference "SDWebImage" */ = { diff --git a/NextcloudTalk/Chat/Chat views/MessageBodyTextView.swift b/NextcloudTalk/Chat/Chat views/MessageBodyTextView.swift index 6351ae77b..563941b48 100644 --- a/NextcloudTalk/Chat/Chat views/MessageBodyTextView.swift +++ b/NextcloudTalk/Chat/Chat views/MessageBodyTextView.swift @@ -68,6 +68,12 @@ class MessageBodyTextView: UITextView, UITextViewDelegate { self.isEditable = false self.isScrollEnabled = false self.delegate = self + + let codeBlockGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleCodeBlockTap(_:))) + + // Don't swallow the touch, links and the message context menu need to see it as well + codeBlockGestureRecognizer.cancelsTouchesInView = false + self.addGestureRecognizer(codeBlockGestureRecognizer) } override func awakeFromNib() { @@ -105,13 +111,57 @@ class MessageBodyTextView: UITextView, UITextViewDelegate { return true } + guard let attributedText = self.attributedText, let startIndex = self.characterIndex(at: point), startIndex < attributedText.length + else { return false } + + if attributedText.attribute(.link, at: startIndex, effectiveRange: nil) != nil { + return true + } + + // Code blocks need to receive touches as well, to be able to open them in a scrollable view + return self.codeBlockRange(at: point) != nil + } + + // MARK: - Code blocks + + private func characterIndex(at point: CGPoint) -> Int? { guard let position = self.closestPosition(to: point), let range = self.tokenizer.rangeEnclosingPosition(position, with: .character, inDirection: .layout(.left)) - else { return false } + else { return nil } + + return self.offset(from: self.beginningOfDocument, to: range.start) + } + + private func codeBlockRange(at point: CGPoint) -> NSRange? { + guard let attributedText = self.attributedText, let index = self.characterIndex(at: point), index < attributedText.length + else { return nil } + + var effectiveRange = NSRange() + + // Inline code is styled like a code block, only the attribute set by the parser tells them apart + guard attributedText.attribute(.syntaxBlock, at: index, effectiveRange: &effectiveRange) != nil else { return nil } + + return effectiveRange + } + + @objc private func handleCodeBlockTap(_ gestureRecognizer: UITapGestureRecognizer) { + let point = gestureRecognizer.location(in: self) + + guard let attributedText = self.attributedText, let index = self.characterIndex(at: point), index < attributedText.length, + // A detected link inside a code block is handled by the text view itself + attributedText.attribute(.link, at: index, effectiveRange: nil) == nil, + let range = self.codeBlockRange(at: point) + else { return } + + // The block keeps the newline before the closing fence, but leading spaces are part of the code + let code = attributedText.attributedSubstring(from: range).string.trimmingCharacters(in: .newlines) + + guard !code.isEmpty else { return } - let startIndex = self.offset(from: self.beginningOfDocument, to: range.start) + let codeViewController = GithubPermalinkViewController(codeBlock: code) + let navigationController = UINavigationController(rootViewController: codeViewController) - return self.attributedText.attribute(.link, at: startIndex, effectiveRange: nil) != nil + NCUserInterfaceController.sharedInstance().mainViewController.present(navigationController, animated: true) } // MARK: - UITextView delegate diff --git a/NextcloudTalk/Chat/Chat views/References/GithubPermalinkViewController.swift b/NextcloudTalk/Chat/Chat views/References/GithubPermalinkViewController.swift index fd1eecc43..4f94f6419 100644 --- a/NextcloudTalk/Chat/Chat views/References/GithubPermalinkViewController.swift +++ b/NextcloudTalk/Chat/Chat views/References/GithubPermalinkViewController.swift @@ -25,6 +25,7 @@ import SwiftyAttributes private var repo = "" private var filePath = "" private var lineNumberWidth: CGFloat = 0 + private var codeBlock: String? init(url: String, sourceWithLineNumbers: NSAttributedString, @@ -45,6 +46,17 @@ import SwiftyAttributes self.lineNumberWidth = lineNumberWidth } + // A code block of a chat message, shown without line numbers and without any repository details + init(codeBlock: String) { + super.init(nibName: "GithubPermalinkViewController", bundle: nil) + + self.codeBlock = codeBlock + self.sourceWithoutLineNumbers = NSAttributedString(string: codeBlock, attributes: [ + .font: UIFont.monospacedPreferredFont(forTextStyle: .body), + .foregroundColor: UIColor.label + ]) + } + required init?(coder: NSCoder) { super.init(coder: coder) } @@ -58,15 +70,19 @@ import SwiftyAttributes NCAppBranding.styleViewController(self) - self.navigationItem.title = NSLocalizedString("Source code", comment: "") + self.navigationItem.title = NSLocalizedString("Source code", comment: "Title of a view showing the source code of a file or a code block") self.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(self.cancelButtonPressed)) if #unavailable(iOS 26.0) { self.navigationItem.leftBarButtonItem?.tintColor = NCAppBranding.themeTextColor() } - let githubButton = UIBarButtonItem(image: UIImage(named: "github")?.withRenderingMode(.alwaysTemplate), style: .plain, target: self, action: #selector(githubButtonPressed)) - self.navigationItem.rightBarButtonItem = githubButton + if self.codeBlock != nil { + self.navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "doc.on.doc"), style: .plain, target: self, action: #selector(copyButtonPressed)) + } else { + let githubButton = UIBarButtonItem(image: UIImage(named: "github")?.withRenderingMode(.alwaysTemplate), style: .plain, target: self, action: #selector(githubButtonPressed)) + self.navigationItem.rightBarButtonItem = githubButton + } if #unavailable(iOS 26.0) { self.navigationItem.rightBarButtonItem?.tintColor = NCAppBranding.themeTextColor() @@ -99,6 +115,21 @@ import SwiftyAttributes // Take safe-area padding of 10 into account here self.scrollViewLeftConstraint.constant = self.lineNumberWidth + 10 + if self.codeBlock != nil { + // A code block has no repository details to show, remove the labels and start with the source at the top. + // Removing the labels also removes the constraints the scroll views had to them, so those are set up again. + self.ownerLabel.removeFromSuperview() + self.repoLabel.removeFromSuperview() + self.fileLabel.removeFromSuperview() + + NSLayoutConstraint.activate([ + self.sourceWithNumbersScrollView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 10), + self.sourceWithoutNumbersScrollView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 10) + ]) + + return + } + var formattedOwner = NSLocalizedString("Owner", comment: "Owner of a repository").attributedString + ": ".attributedString formattedOwner = formattedOwner.withFont(fontSemibold).withTextColor(.secondaryLabel) formattedOwner += self.owner.withFont(font) @@ -115,6 +146,11 @@ import SwiftyAttributes self.fileLabel.attributedText = formattedPath } + func copyButtonPressed() { + UIPasteboard.general.string = self.codeBlock + NotificationPresenter.shared().present(text: NSLocalizedString("Code copied", comment: "Shown after the code of a code block was copied to the clipboard"), dismissAfterDelay: 5.0, includedStyle: .dark) + } + func githubButtonPressed() { if let url = self.url { NCUtils.openLinkInBrowser(link: url) diff --git a/NextcloudTalk/en.lproj/Localizable.strings b/NextcloudTalk/en.lproj/Localizable.strings index 232b941cc..111094d2a 100644 --- a/NextcloudTalk/en.lproj/Localizable.strings +++ b/NextcloudTalk/en.lproj/Localizable.strings @@ -676,6 +676,9 @@ /* Code block */ "Code" = "Code"; +/* Shown after the code of a code block was copied to the clipboard */ +"Code copied" = "Code copied"; + /* No comment provided by engineer. */ "Configuration" = "Configuration"; @@ -2328,7 +2331,7 @@ /* Title for conversations sorting options */ "Sort conversations" = "Sort conversations"; -/* No comment provided by engineer. */ +/* Title of a view showing the source code of a file or a code block */ "Source code" = "Source code"; /* speaker = Loudspeaker, device */