From e2e4e61efe286c39b191459185b4634c4dc9e578 Mon Sep 17 00:00:00 2001 From: Zomatree Date: Wed, 13 Nov 2024 23:47:10 +0000 Subject: [PATCH] feat: update markdown --- Revolt.xcodeproj/project.pbxproj | 8 + Revolt/Components/Contents.swift | 944 ++++++++++++++---- Revolt/Components/MessageBox.swift | 70 +- .../MessageRenderer/MessageContentsView.swift | 96 +- .../MessageRenderer/MessageEmbed.swift | 3 +- .../MessageRenderer/MessageView.swift | 115 ++- .../MessageRenderer/SystemMessageView.swift | 15 +- Revolt/Extensions/EnvironmentValues.swift | 15 + Revolt/Extensions/UIFont.swift | 16 + Revolt/Extensions/UIImage.swift | 32 +- .../Messagable/MessageableChannel.swift | 14 +- Revolt/Pages/Home/Home.swift | 5 + Revolt/RevoltApp.swift | 2 + 13 files changed, 1011 insertions(+), 324 deletions(-) create mode 100644 Revolt/Extensions/EnvironmentValues.swift create mode 100644 Revolt/Extensions/UIFont.swift diff --git a/Revolt.xcodeproj/project.pbxproj b/Revolt.xcodeproj/project.pbxproj index 9e294aa..d93bb32 100644 --- a/Revolt.xcodeproj/project.pbxproj +++ b/Revolt.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 172F2D012C22ED4D00948C00 /* IteratorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 172F2D002C22ED4A00948C00 /* IteratorProtocol.swift */; }; 172F2D032C22ED5C00948C00 /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 172F2D022C22ED5A00948C00 /* Collection.swift */; }; 172F2D052C22ED8500948C00 /* CheckboxStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 172F2D042C22ED8100948C00 /* CheckboxStyle.swift */; }; + 17340A472CE051DC00AD8A19 /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17340A462CE051D900AD8A19 /* UIFont.swift */; }; 1739DB962B08E53B00D23DAD /* MessageBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1739DB952B08E53B00D23DAD /* MessageBadge.swift */; }; 173A336A2C422EF800B8A58E /* DeveloperSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 173A33692C422EF800B8A58E /* DeveloperSettings.swift */; }; 173D698E2C7FF7FC00030E62 /* ChannelSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 173D698D2C7FF7FC00030E62 /* ChannelSearch.swift */; }; @@ -40,6 +41,7 @@ 1746A4B62CAF57C300095CF3 /* GroupDMChannelPermissionsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1746A4B52CAF57C300095CF3 /* GroupDMChannelPermissionsSettings.swift */; }; 1746CF5A2B83C6750051FD47 /* CodableWrapper in Frameworks */ = {isa = PBXBuildFile; productRef = 1746CF592B83C6750051FD47 /* CodableWrapper */; }; 1748B2072B1FB88B00AA2D47 /* MessageReactions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1748B2062B1FB88B00AA2D47 /* MessageReactions.swift */; }; + 174BB4612CC1C48900D6EC32 /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174BB4602CC1C48400D6EC32 /* EnvironmentValues.swift */; }; 174DA9E82B9E4D70001BC330 /* Parsing in Frameworks */ = {isa = PBXBuildFile; productRef = 174DA9E72B9E4D70001BC330 /* Parsing */; }; 1750EF6E2B0585B300DD3EB3 /* ResendEmail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1750EF6D2B0585B300DD3EB3 /* ResendEmail.swift */; }; 1751239B2CAC687C00D30C23 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 1751239A2CAC687C00D30C23 /* KeychainAccess */; }; @@ -220,6 +222,7 @@ 172F2D002C22ED4A00948C00 /* IteratorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IteratorProtocol.swift; sourceTree = ""; }; 172F2D022C22ED5A00948C00 /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; 172F2D042C22ED8100948C00 /* CheckboxStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxStyle.swift; sourceTree = ""; }; + 17340A462CE051D900AD8A19 /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; 17378BD72C76CF51004E4235 /* ci_post_clone.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = ci_post_clone.sh; sourceTree = ""; }; 1739DB952B08E53B00D23DAD /* MessageBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBadge.swift; sourceTree = ""; }; 173A33692C422EF800B8A58E /* DeveloperSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettings.swift; sourceTree = ""; }; @@ -233,6 +236,7 @@ 1746A4B32CAF2C4E00095CF3 /* BotSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BotSetting.swift; sourceTree = ""; }; 1746A4B52CAF57C300095CF3 /* GroupDMChannelPermissionsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupDMChannelPermissionsSettings.swift; sourceTree = ""; }; 1748B2062B1FB88B00AA2D47 /* MessageReactions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReactions.swift; sourceTree = ""; }; + 174BB4602CC1C48400D6EC32 /* EnvironmentValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentValues.swift; sourceTree = ""; }; 1750EF6D2B0585B300DD3EB3 /* ResendEmail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResendEmail.swift; sourceTree = ""; }; 1751239C2CAC8A1100D30C23 /* ServerEmojiSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerEmojiSettings.swift; sourceTree = ""; }; 175465CA2C42147B0076B393 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -374,7 +378,9 @@ 170C23D82C224BA40057E399 /* Extensions */ = { isa = PBXGroup; children = ( + 17340A462CE051D900AD8A19 /* UIFont.swift */, 17C212432CD19E75002D486C /* Section.swift */, + 174BB4602CC1C48400D6EC32 /* EnvironmentValues.swift */, 17DF7CAF2C4ACD9D003E8FDC /* UIImage.swift */, 172F2D022C22ED5A00948C00 /* Collection.swift */, 172F2D002C22ED4A00948C00 /* IteratorProtocol.swift */, @@ -1032,6 +1038,7 @@ 1759C39C2B291E2F006E6BBE /* SystemMessageView.swift in Sources */, 17E019DC2AF1B25100AB4663 /* SessionsSettings.swift in Sources */, 172F2D052C22ED8500948C00 /* CheckboxStyle.swift in Sources */, + 174BB4612CC1C48900D6EC32 /* EnvironmentValues.swift in Sources */, 178BB1192B02FBD1001143A4 /* Payloads.swift in Sources */, 178BB1172B02E63B001143A4 /* LoadingSpinnerView.swift in Sources */, 17CE783F2B129E28006C1D2C /* ServerScrollView.swift in Sources */, @@ -1053,6 +1060,7 @@ 17E019D12AF14EC000AB4663 /* ServerIcon.swift in Sources */, 176485752CA3947B00AF8141 /* PermissionToggle.swift in Sources */, 17F8B7092C7983730065F1DE /* CreateServer.swift in Sources */, + 17340A472CE051DC00AD8A19 /* UIFont.swift in Sources */, 17863A592C8094840051A52C /* Tile.swift in Sources */, 176485732CA3882E00AF8141 /* Binding.swift in Sources */, 172F2D012C22ED4D00948C00 /* IteratorProtocol.swift in Sources */, diff --git a/Revolt/Components/Contents.swift b/Revolt/Components/Contents.swift index fd45f97..1768140 100644 --- a/Revolt/Components/Contents.swift +++ b/Revolt/Components/Contents.swift @@ -7,15 +7,15 @@ import Foundation import SwiftUI -//import Flow +// import Flow // import Parsing import Kingfisher import Types -//import SwiftParsec +// import SwiftParsec import SubviewAttachingTextView import SnapKit -import Highlightr import UIKit +import Highlightr //enum ContentPart: Equatable { // case text(AttributedString) @@ -569,6 +569,299 @@ parser = many1 node // } //} +// +//class EmojiView: UIView { +// var imageView: UIImageView! +// var label: UILabel! +// +// init(imageSize: CGSize) { +// super.init(frame: .zero) +// self.imageView = UIImageView(frame: .zero) +// addSubview(imageView) +// } +// +// required init?(coder: NSCoder) { +// fatalError("init(coder:) has not been implemented") +// } +// +// override func layoutSubviews() { +// super.layoutSubviews() +// label.frame = self.bounds +// imageView.frame = self.bounds +// } +//} +// +//class UserMentionView: UIView { +// var imageView: UIImageView! +// var nameView: UILabel! +// var tapHandler: (() -> Void)! +// +// init(tapHandler: @escaping () -> Void) { +// super.init(frame: .zero) +// +// self.imageView = UIImageView() +// +// self.imageView.layer.masksToBounds = false +// self.imageView.layer.borderWidth = 1 +// self.imageView.layer.borderColor = UIColor.clear.cgColor +// self.imageView.clipsToBounds = true +// +// self.nameView = UILabel() +// self.nameView.numberOfLines = 1 +// +// self.tapHandler = tapHandler +// let gestureRecog = UITapGestureRecognizer(target: self, action: #selector(self.handleTap(_:))) +// +// addSubview(imageView) +// addSubview(nameView) +// +// imageView.snp.makeConstraints { make in +// make.width.equalTo(imageView.snp.height) +// //make.leading.equalTo(self.snp.leading) +// make.top.equalTo(self.snp.top).offset(2) +// make.bottom.equalTo(self.snp.bottom).offset(-2) +// make.trailing.equalTo(nameView.snp.leading).offset(-8) +// } +// +// nameView.snp.makeConstraints { make in +// //make.trailing.equalTo(self.snp.trailing) +// make.centerY.equalTo(imageView.snp.centerY) +// } +// +// self.snp.makeConstraints { make in +// make.leading.equalTo(imageView.snp.leading).offset(-2).priority(.required) +// make.trailing.equalTo(nameView.snp.trailing).offset(6).priority(.required) +// } +// } +// +// required init(coder: NSCoder) { +// fatalError("init(coder:) has not been implemented") +// } +// +// override func layoutSubviews() { +// imageView.layer.cornerRadius = imageView.frame.height / 2 +// } +// +// @objc func handleTap(_ sender: UITapGestureRecognizer? = nil) { +// self.tapHandler() +// } +//} +// +//let highlighter = Highlightr()! +// +// +//struct InnerContents: UIViewRepresentable { +// @EnvironmentObject var viewState: ViewState +// typealias UIViewType = SubviewAttachingTextView +// +// @Binding var text: String +// @Binding var calculatedHeight: CGFloat +// +// var currentServer: String? +// +// var fontSize: CGFloat +// var font: UIFont +// var foregroundColor: UIColor +// var lineLimit: Int? +// +// func makeUIView(context: Context) -> UIViewType { +// let textview = SubviewAttachingTextView() +// textview.isEditable = false +// +// if let lineLimit { +// textview.textContainer.maximumNumberOfLines = lineLimit +// textview.textContainer.lineBreakMode = .byTruncatingTail +// } +// +// textview.isSelectable = false +// textview.font = .systemFont(ofSize: fontSize) +// textview.backgroundColor = nil +// textview.isScrollEnabled = false +// textview.textColor = .white +// textview.translatesAutoresizingMaskIntoConstraints = false +// textview.textContainer.lineFragmentPadding = 0 +// textview.textContainerInset = .zero +// textview.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) +// +// return textview +// } +// +// func updateUIView(_ textview: UIViewType, context: Context) { +// if !text.isEmpty { +// var lines: [NSAttributedString] = [] +// +// for text in text.split(separator: "\n") { +// +// let attrString = try! NSMutableAttributedString(markdown: text.data(using: .utf8)!, options: .init(allowsExtendedAttributes: true, interpretedSyntax: .full)) +// +// attrString.enumerateAttribute(.font, in: NSRange(location: 0, length: attrString.length), options: [], using: { font, range, _ in +// let font = font != nil ? (font as! UIFont).withSize(fontSize) : UIFont.systemFont(ofSize: fontSize) +// +// // Custom emoji support +//// let customFont = UIFont(name: "Twitter Color Emoji", size: fontSize)! +//// let descriptor = customFont.fontDescriptor +//// let fallback = descriptor.addingAttributes([.name: font.fontName]) +//// let repaired = descriptor.addingAttributes([.cascadeList: [fallback]]) +//// let newFont = UIFont(descriptor: repaired, size: 0.0) +// +// attrString.addAttribute(.font, value: font, range: range) +// }) +// +// var foundCodeblockCount = 0 +// +// attrString.enumerateAttribute(.presentationIntentAttributeName, in: NSRange(location: 0, length: attrString.length), using: { presentation, range, _ in +// if let intent = presentation as? __NSPresentationIntent { +// +// if intent.intentKind == __NSPresentationIntentKind.codeBlock { +// let lowerInt = range.lowerBound - foundCodeblockCount +// let lower = String.Index(encodedOffset: lowerInt) +// let upper = String.Index(encodedOffset: range.upperBound - foundCodeblockCount) +// let codeText = String(attrString.string[lower../) { +// let id = match.output.1 +// +// if let channel = viewState.channels[String(id)] { +// let lowerInt = match.range.lowerBound.encodedOffset - foundChannelLength +// let lower = String.Index(encodedOffset: lowerInt) +// let upper = String.Index(encodedOffset: match.range.upperBound.encodedOffset - foundChannelLength) +// +// let globalRange = Range(uncheckedBounds: (lower, upper)) +// +// var currentAttrs = attrString.attributes(at: lowerInt, effectiveRange: nil) +// +// currentAttrs[.link] = URL(string: "revoltchat://channels?channel=\(id)")! +// currentAttrs[.backgroundColor] = UIColor.clear.withAlphaComponent(0.1) +// +// let channelName = channel.getName(viewState) +// attrString.deleteCharacters(in: NSRange(globalRange, in: attrString.string)) +// attrString.insert(NSAttributedString(string: "#\(channelName)", attributes: currentAttrs), at: lowerInt) +// +// foundChannelLength += 28 - channelName.count +// } +// } +// +// var foundUserCount = 0 +// +// for match in attrString.string.matches(of: /<@(\w{26})>/) { +// let id = match.output.1 +// +// if let user = viewState.users[String(id)] { +// let member = currentServer.flatMap { viewState.members[$0]![user.id] } +// +// let lowerInt = match.range.lowerBound.encodedOffset - (foundUserCount * 28) +// let lower = String.Index(encodedOffset: lowerInt) +// let upper = String.Index(encodedOffset: match.range.upperBound.encodedOffset - (foundUserCount * 28)) +// +// let globalRange = Range(uncheckedBounds: (lower, upper)) +// +// let currentAttrs = attrString.attributes(at: lowerInt, effectiveRange: nil) +// let currentFont = (currentAttrs[.font] ?? font) as! UIFont +// +// attrString.deleteCharacters(in: NSRange(globalRange, in: attrString.string)) +// +// let view = UserMentionView() { +// viewState.openUserSheet(user: user, member: member) +// } +// +// view.backgroundColor = viewState.theme.background2.uiColor +// view.layer.cornerRadius = currentFont.pointSize / 2 +// +// view.imageView.kf.setImage( +// with: viewState.resolveAvatarUrl(user: user, member: member, masquerade: nil), +// options: [ +// .processor(ResizingImageProcessor(referenceSize: CGSize(width: currentFont.pointSize, height: currentFont.pointSize), mode: .aspectFill)) +// ] +// ) +// view.imageView.frame = CGRect(x: 0, y: 0, width: currentFont.pointSize, height: currentFont.pointSize) +// +// view.nameView.text = member?.nickname ?? user.display_name ?? user.username +// view.nameView.font = .boldSystemFont(ofSize: currentFont.pointSize) +// +// textview.addSubview(view) +// +// attrString.insert(NSAttributedString(attachment: SubviewTextAttachment(view: view)), at: lowerInt) +// +// foundUserCount += 1 +// } +// } +// +// lines.append(attrString) +// } +// +// var attrString = NSMutableAttributedString(attributedString: lines.remove(at: 0)) +// +// for line in lines { +// attrString.append(NSAttributedString(string: "\n")) +// attrString.append(line) +// } +// +// textview.attributedText = attrString +// } else { +// textview.attributedText = NSAttributedString() +// } +// +// +// InnerContents.recalculateHeight(view: textview, result: $calculatedHeight) +// } +// +// static func recalculateHeight(view: UIView, result: Binding) { +// let newSize = view.sizeThatFits(CGSize(width: view.frame.width, height: .greatestFiniteMagnitude)) +// +// guard result.wrappedValue != newSize.height else { return } +// DispatchQueue.main.async { // call in next render cycle. +// result.wrappedValue = newSize.height +// } +// } +//} + +import SwiftUI +import UIKit +import Kingfisher +import SubviewAttachingTextView +import SnapKit +import Highlightr class EmojiView: UIView { var imageView: UIImageView! @@ -583,22 +876,23 @@ class EmojiView: UIView { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - override func layoutSubviews() { - super.layoutSubviews() - label.frame = self.bounds - imageView.frame = self.bounds - } } class UserMentionView: UIView { var imageView: UIImageView! var nameView: UILabel! - var tapHandler: (() -> Void)! - init(tapHandler: @escaping () -> Void) { + override init(frame: CGRect) { + super.init(frame: frame) + createSubViews(imageHeight: frame.height) + } + + init(imageHeight: CGFloat) { super.init(frame: .zero) - + createSubViews(imageHeight: imageHeight) + } + + private func createSubViews(imageHeight: CGFloat) { self.imageView = UIImageView() self.imageView.layer.masksToBounds = false @@ -609,239 +903,524 @@ class UserMentionView: UIView { self.nameView = UILabel() self.nameView.numberOfLines = 1 - self.tapHandler = tapHandler - let gestureRecog = UITapGestureRecognizer(target: self, action: #selector(self.handleTap(_:))) - addSubview(imageView) addSubview(nameView) imageView.snp.makeConstraints { make in - make.width.equalTo(imageView.snp.height) - //make.leading.equalTo(self.snp.leading) - make.top.equalTo(self.snp.top).offset(2) - make.bottom.equalTo(self.snp.bottom).offset(-2) - make.trailing.equalTo(nameView.snp.leading).offset(-8) + make.leading.equalTo(self.snp.leading).offset(2).labeled("image leading") + + make.height.equalTo(imageHeight).labeled("image height") + make.width.equalTo(imageView.snp.height).labeled("image width") + make.centerY.equalTo(self.snp.centerY).labeled("image center y") } nameView.snp.makeConstraints { make in - //make.trailing.equalTo(self.snp.trailing) - make.centerY.equalTo(imageView.snp.centerY) + make.leading.equalTo(imageView.snp.trailing).offset(4).labeled("name leading") + + make.centerY.equalTo(self.snp.centerY).labeled("name center y") + + make.trailing.equalTo(self.snp.trailing).offset(-6).labeled("name trailing") } - self.snp.makeConstraints { make in - make.leading.equalTo(imageView.snp.leading).offset(-2).priority(.required) - make.trailing.equalTo(nameView.snp.trailing).offset(6).priority(.required) + self.snp.makeConstraints{ make in + make.height.equalTo(nameView.snp.height).labeled("mention height") } } required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} + +class CodeBlockView: UIView { + var langView: UIView? + var contentView: UITextView! + var scrollview: UIScrollView! - override func layoutSubviews() { - imageView.layer.cornerRadius = imageView.frame.height / 2 + override init(frame: CGRect) { + super.init(frame: frame) + + createSubViews(attrString: NSAttributedString(string: ""), backgroundColor: .black, lang: nil, langFont: UIFont.systemFont(ofSize: UIFont.systemFontSize)) } - @objc func handleTap(_ sender: UITapGestureRecognizer? = nil) { - self.tapHandler() + init(attrString: NSAttributedString, backgroundColor: UIColor, lang: String?, langFont: UIFont) { + super.init(frame: .zero) + createSubViews(attrString: attrString, backgroundColor: backgroundColor, lang: lang, langFont: langFont) + } + + private func createSubViews(attrString: NSAttributedString, backgroundColor: UIColor, lang: String?, langFont: UIFont) { + self.backgroundColor = backgroundColor + + if let lang { + let text = UILabel() + let langView = UIView() + + text.text = lang.uppercased() + text.font = langFont + text.textColor = .black + + langView.backgroundColor = .systemRed + langView.layer.cornerRadius = 4 + + langView.addSubview(text) + self.langView = langView + addSubview(langView) + + text.snp.makeConstraints { make in + make.top.equalTo(langView.snp.top).offset(2) + make.bottom.equalTo(langView.snp.bottom).offset(-2) + make.leading.equalTo(langView.snp.leading).offset(6) + make.trailing.equalTo(langView.snp.trailing).offset(-6) + } + } + + let contentView = UITextView() + self.contentView = contentView + + contentView.isScrollEnabled = false + contentView.showsHorizontalScrollIndicator = true + contentView.isUserInteractionEnabled = false + contentView.isEditable = false + contentView.attributedText = attrString + contentView.backgroundColor = backgroundColor + + let scrollview = UIScrollView() + scrollview.showsHorizontalScrollIndicator = true + scrollview.showsVerticalScrollIndicator = false + scrollview.isScrollEnabled = true + + // scrollview.addSubview(contentView) + + addSubview(contentView) + + // contentView.snp.makeConstraints { make in + // make.top.equalToSuperview().offset(30) + // make.bottom.equalToSuperview().offset(-30) + // make.leading.equalToSuperview().offset(30) + // make.trailing.equalToSuperview().offset(-30) + // make.top.equalTo(scrollview.snp.top) + // make.bottom.equalTo(scrollview.snp.bottom) + // } + + if let langView { + langView.snp.makeConstraints { make in + make.top.equalTo(self.snp.top).offset(12) + make.leading.equalTo(self.snp.leading).offset(12) + make.trailing.lessThanOrEqualTo(self.snp.trailing).offset(-6) + } + } + + contentView.snp.makeConstraints { make in + if let langView { + make.top.equalTo(langView.snp.bottom).offset(4) + } else { + make.top.equalTo(self.snp.top).offset(4) + } + + make.leading.equalTo(self.snp.leading).offset(8) + make.trailing.equalTo(self.snp.trailing).offset(-8).priority(.high) + make.bottom.equalTo(self.snp.bottom).offset(-8) + } + + self.layer.cornerRadius = 8 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } } -let highlighter = Highlightr()! +let defaultParagraphStyle: NSParagraphStyle = { + var paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacing = 8.0 + paragraphStyle.minimumLineHeight = 20.0 + //paragraphStyle.lineBreakMode = .byTruncatingTail + return paragraphStyle +}() +class UserMentionTapHandler: NSObject { + let callback: () -> Void + + init(callback: @escaping () -> Void) { + self.callback = callback + } + + @objc func handle(_: UITapGestureRecognizer) { + callback() + } +} + +let highlighter = Highlightr()! struct InnerContents: UIViewRepresentable { @EnvironmentObject var viewState: ViewState - typealias UIViewType = SubviewAttachingTextView + @Environment(\.currentServer) var currentServer: Server? - @Binding var text: String - @Binding var calculatedHeight: CGFloat + typealias UIViewType = SubviewAttachingTextView - var currentServer: String? + @Binding var content: String + @State var handlers: [UserMentionTapHandler] = [] + var fontSize: CGFloat - var font: UIFont + var contentFont: UIFont var foregroundColor: UIColor - var lineLimit: Int? + var lineLimit: Int + var textAlignment: TextAlignment + + @Binding var calculatedHeight: CGFloat + + init(content: Binding, calculatedHeight: Binding, fontSize: CGFloat? = nil, font: UIFont? = nil, foregroundColor: UIColor? = nil, lineLimit: Int? = nil, textAlignment: TextAlignment) { + self._content = content + self.fontSize = fontSize ?? UIFont.systemFontSize + self.contentFont = .systemFont(ofSize: fontSize ?? UIFont.systemFontSize) + self.foregroundColor = foregroundColor ?? .white + self._calculatedHeight = calculatedHeight + self.lineLimit = lineLimit ?? 0 + self.textAlignment = textAlignment + + highlighter.setTheme(to: "atom-one-dark") + highlighter.theme.setCodeFont(UIFont.monospacedSystemFont(ofSize: self.fontSize * 0.9, weight: .regular)) + } func makeUIView(context: Context) -> UIViewType { let textview = SubviewAttachingTextView() textview.isEditable = false + + textview.textContainer.maximumNumberOfLines = lineLimit + textview.textContainer.lineBreakMode = .byTruncatingTail - if let lineLimit { - textview.textContainer.maximumNumberOfLines = lineLimit - textview.textContainer.lineBreakMode = .byTruncatingTail + switch textAlignment { + case .leading: + textview.textAlignment = .natural + case .center: + textview.textAlignment = .center + case .trailing: + // no trailing in NSTextAlignment so need to manually do left or right on layout direction + textview.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft ? .left : .right } - + textview.isSelectable = false - textview.font = .systemFont(ofSize: fontSize) + textview.font = contentFont textview.backgroundColor = nil textview.isScrollEnabled = false - textview.textColor = .white + textview.textColor = foregroundColor textview.translatesAutoresizingMaskIntoConstraints = false textview.textContainer.lineFragmentPadding = 0 textview.textContainerInset = .zero textview.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - + return textview } + func fixAttributedStringStyling(for string: NSMutableAttributedString) { + string.addAttribute(.paragraphStyle, value: defaultParagraphStyle, range: NSRange(location: 0, length: string.length)) + + string.enumerateAttribute(.font, in: NSRange(location: 0, length: string.length), options: [], using: { font, range, _ in + let font = font != nil ? (font as! UIFont).withSize(fontSize) : UIFont.systemFont(ofSize: fontSize) + + string.addAttribute(.font, value: font, range: range) + }) + + string.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: string.length), options: [], using: { color, range, _ in + string.addAttribute(.foregroundColor, value: color ?? foregroundColor, range: range) + }) + + // string.enumerateAttribute(.link, in: NSRange(location: 0, length: string.length), using: { value, range, _ in + // if value != nil { + // string.addAttribute(.foregroundColor, value: UIColor.link, range: range) + // } + // }) + } + func updateUIView(_ textview: UIViewType, context: Context) { - if !text.isEmpty { - var lines: [NSAttributedString] = [] + DispatchQueue.main.async { + handlers.removeAll() + } + + let attrString = try! NSMutableAttributedString(markdown: content.data(using: .utf8)!, options: .init(allowsExtendedAttributes: true, interpretedSyntax: .full, failurePolicy: .returnPartiallyParsedIfPossible)) + + fixAttributedStringStyling(for: attrString) + + var inlinePresentationIntents: [(InlinePresentationIntent, NSRange)] = [] + + attrString.enumerateAttribute(.inlinePresentationIntent, in: NSRange(location: 0, length: attrString.string.count), using: { value, range, _ in + if let value = value as? UInt { + inlinePresentationIntents.append((InlinePresentationIntent(rawValue: value), range)) + } + }) + + for (presentation, range) in inlinePresentationIntents { + print(presentation, attrString.string[Range(range, in: attrString.string)!]) + + if presentation == .code { + print(attrString.string[String.Index(utf16Offset: range.lowerBound, in: attrString.string)../) { - let id = match.output.1 + codeText.removeLast() - if let channel = viewState.channels[String(id)] { - let lowerInt = match.range.lowerBound.encodedOffset - foundChannelLength - let lower = String.Index(encodedOffset: lowerInt) - let upper = String.Index(encodedOffset: match.range.upperBound.encodedOffset - foundChannelLength) - - let globalRange = Range(uncheckedBounds: (lower, upper)) - - var currentAttrs = attrString.attributes(at: lowerInt, effectiveRange: nil) - - currentAttrs[.link] = URL(string: "revoltchat://channels?channel=\(id)")! - currentAttrs[.backgroundColor] = UIColor.clear.withAlphaComponent(0.1) - - let channelName = channel.getName(viewState) + if let codeBlockString = highlighter.highlight(codeText, as: presentation.languageHint) { attrString.deleteCharacters(in: NSRange(globalRange, in: attrString.string)) - attrString.insert(NSAttributedString(string: "#\(channelName)", attributes: currentAttrs), at: lowerInt) - foundChannelLength += 28 - channelName.count + let wrapperView = CodeBlockView(attrString: codeBlockString, backgroundColor: .secondarySystemBackground, lang: presentation.languageHint, langFont: UIFont(descriptor: contentFont.fontDescriptor.withSymbolicTraits(.traitBold)!, size: 10)) + textview.addSubview(wrapperView) + + attrString.insert(NSAttributedString(attachment: SubviewTextAttachment(view: wrapperView)), at: lowerInt) } - } + + default: + break + } + + if range.lowerBound != 0 { + attrString.mutableString.insert("\n", at: range.lowerBound) + } + } + + for match in attrString.string.matches(of: /:(\w{26}):/).reversed() { + let id = match.output.1 + + let lowerInt = match.range.lowerBound.utf16Offset(in: attrString.string) + let lower = match.range.lowerBound + let upper = match.range.upperBound + + let currentAttrs = attrString.attributes(at: lowerInt, effectiveRange: nil) + let currentFont = (currentAttrs[.font] ?? contentFont) as! UIFont + + let globalRange = Range(uncheckedBounds: (lower, upper)) + + attrString.deleteCharacters(in: NSRange(globalRange, in: attrString.string)) + + let attachment = NSTextAttachment() + + + KF.url(URL(string: "https://autumn.revolt.chat/emojis/\(id)")!) + .placeholder(.none) + .appendProcessor(ResizingImageProcessor(referenceSize: CGSize(width: currentFont.lineHeight, height: currentFont.lineHeight), mode: .aspectFit)) + .set(to: attachment, attributedView: textview) + + attrString.insert(NSAttributedString(attachment: attachment), at: lowerInt) + } + + for match in attrString.string.matches(of: /<#(\w{26})>/).reversed() { + let id = match.output.1 + + if let channel = viewState.channels[String(id)] { - var foundUserCount = 0 + let lowerInt = match.range.lowerBound.utf16Offset(in: attrString.string) + let lower = match.range.lowerBound + let upper = match.range.upperBound + + let globalRange = Range(uncheckedBounds: (lower, upper)) - for match in attrString.string.matches(of: /<@(\w{26})>/) { - let id = match.output.1 - - if let user = viewState.users[String(id)] { - let member = currentServer.flatMap { viewState.members[$0]![user.id] } - - let lowerInt = match.range.lowerBound.encodedOffset - (foundUserCount * 28) - let lower = String.Index(encodedOffset: lowerInt) - let upper = String.Index(encodedOffset: match.range.upperBound.encodedOffset - (foundUserCount * 28)) - - let globalRange = Range(uncheckedBounds: (lower, upper)) - - let currentAttrs = attrString.attributes(at: lowerInt, effectiveRange: nil) - let currentFont = (currentAttrs[.font] ?? font) as! UIFont - - attrString.deleteCharacters(in: NSRange(globalRange, in: attrString.string)) - - let view = UserMentionView() { - viewState.openUserSheet(user: user, member: member) - } - - view.backgroundColor = viewState.theme.background2.uiColor - view.layer.cornerRadius = currentFont.pointSize / 2 - - view.imageView.kf.setImage( - with: viewState.resolveAvatarUrl(user: user, member: member, masquerade: nil), - options: [ - .processor(ResizingImageProcessor(referenceSize: CGSize(width: currentFont.pointSize, height: currentFont.pointSize), mode: .aspectFill)) - ] - ) - view.imageView.frame = CGRect(x: 0, y: 0, width: currentFont.pointSize, height: currentFont.pointSize) - - view.nameView.text = member?.nickname ?? user.display_name ?? user.username - view.nameView.font = .boldSystemFont(ofSize: currentFont.pointSize) - - textview.addSubview(view) - - attrString.insert(NSAttributedString(attachment: SubviewTextAttachment(view: view)), at: lowerInt) - - foundUserCount += 1 - } + var currentAttrs = attrString.attributes(at: lowerInt, effectiveRange: nil) + + currentAttrs[.link] = URL(string: "revoltchat://channels?channel=\(id)")! + currentAttrs[.backgroundColor] = UIColor.clear.withAlphaComponent(0.1) + + let channelName = channel.getName(viewState) + attrString.deleteCharacters(in: NSRange(globalRange, in: attrString.string)) + attrString.insert(NSAttributedString(string: "#\(channelName)", attributes: currentAttrs), at: lowerInt) + } + } + + for match in attrString.string.matches(of: /<@(\w{26})>/).reversed() { + let id = match.output.1 + + if let user = viewState.users[String(id)] { + let member = currentServer.flatMap { viewState.members[$0.id]![user.id] } + + let lowerInt = match.range.lowerBound.utf16Offset(in: attrString.string) + let lower = match.range.lowerBound + let upper = match.range.upperBound + + let globalRange = Range(uncheckedBounds: (lower, upper)) + + let currentAttrs = attrString.attributes(at: lowerInt, effectiveRange: nil) + let currentFont = (currentAttrs[.font] ?? contentFont) as! UIFont + + attrString.deleteCharacters(in: NSRange(globalRange, in: attrString.string)) + + let view = UserMentionView(imageHeight: currentFont.pointSize) + view.backgroundColor = viewState.theme.background2.uiColor + view.layer.cornerRadius = currentFont.pointSize / 2 + + view.imageView.kf.setImage( + with: viewState.resolveAvatarUrl(user: user, member: member, masquerade: nil), + options: [ + .processor(ResizingImageProcessor(referenceSize: CGSize(width: currentFont.pointSize, height: currentFont.pointSize), mode: .aspectFill)), + .processor(RoundCornerImageProcessor(cornerRadius: .greatestFiniteMagnitude)) + ] + ) + + view.nameView.text = member?.nickname ?? user.display_name ?? user.username + view.nameView.font = .boldSystemFont(ofSize: currentFont.pointSize) + + view.isUserInteractionEnabled = true + + let handler = UserMentionTapHandler { + viewState.openUserSheet(user: user, member: member) } - lines.append(attrString) + DispatchQueue.main.async { + handlers.append(handler) + } + + let recogniser = UITapGestureRecognizer(target: handler, action: #selector(UserMentionTapHandler.handle(_:))) + view.addGestureRecognizer(recogniser) + + textview.addSubview(view) + + attrString.insert(NSAttributedString(attachment: SubviewTextAttachment(view: view)), at: lowerInt) } + } + + for match in attrString.string.matches(of: /(?:https?:\/\/)?revolt\.chat\/server\/(\w{26})\/channel\/(\w{26})\/(\w{26})/).reversed() { + let serverId = String(match.output.1) + let channelId = String(match.output.2) + let messageId = match.output.3 - var attrString = NSMutableAttributedString(attributedString: lines.remove(at: 0)) + if let server = viewState.servers[serverId], + let channel = viewState.channels[channelId], + channel.server == serverId + { + let lowerInt = match.range.lowerBound.utf16Offset(in: attrString.string) + let lower = match.range.lowerBound + let upper = match.range.upperBound + + let globalRange = Range(uncheckedBounds: (lower, upper)) + + let currentAttrs = attrString.attributes(at: lowerInt, effectiveRange: nil) + let currentFont = (currentAttrs[.font] ?? contentFont) as! UIFont + + attrString.deleteCharacters(in: NSRange(globalRange, in: attrString.string)) + + let linkString = NSMutableAttributedString(string: "􀆃\(channel.getName(viewState)) ", attributes: [.font: currentFont]) + + linkString.append(NSAttributedString(string: "􀆊", attributes: [ + .font: currentFont.withSize(currentFont.pointSize / 2), + .baselineOffset: (currentFont.capHeight - (currentFont.capHeight / 2)) / 2 + ])) + linkString.append(NSAttributedString(string: " 􀌲", attributes: [.font: currentFont])) + linkString.addAttributes([ + .link: URL(string: "revoltchat://channels?channel=\(channelId)&message=\(messageId)")!, // TODO + .backgroundColor: UIColor.clear.withAlphaComponent(0.1), + .foregroundColor: foregroundColor + ], range: NSRange(location: 0, length: linkString.length)) + + attrString.insert(linkString, at: lowerInt) + } + } + + for match in attrString.string.matches(of: //).reversed() { + let lowerInt = match.range.lowerBound.utf16Offset(in: attrString.string) + let lower = match.range.lowerBound + let upper = match.range.upperBound + + let globalRange = Range(uncheckedBounds: (lower, upper)) + + let currentAttrs = attrString.attributes(at: lowerInt, effectiveRange: nil) + let currentFont = (currentAttrs[.font] ?? contentFont) as! UIFont + + attrString.deleteCharacters(in: NSRange(globalRange, in: attrString.string)) + + let date = Date(timeIntervalSince1970: Double(match.output.1)!) - for line in lines { - attrString.append(NSAttributedString(string: "\n")) - attrString.append(line) + var content: String = "unknown" + + switch match.output.2 { + case "t": + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + content = formatter.string(from: date) + case "T": + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + content = formatter.string(from: date) + case "D": + let formatter = DateFormatter() + formatter.dateFormat = "dd MMMM YYYY" + content = formatter.string(from: date) + case "f": + let formatter = DateFormatter() + formatter.dateFormat = "dd MMMM YYYY HH:mm" + content = formatter.string(from: date) + case "F": + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, dd MMMM YYYY HH:mm" + content = formatter.string(from: date) + case "R": + let formatter = RelativeDateTimeFormatter() + formatter.dateTimeStyle = .numeric + formatter.formattingContext = .middleOfSentence + formatter.unitsStyle = .full + content = formatter.localizedString(for: date, relativeTo: Date.now) + default: + fatalError() } - textview.attributedText = attrString - } else { - textview.attributedText = NSAttributedString() + let linkString = NSAttributedString(string: content, attributes: [ + .backgroundColor: UIColor.secondarySystemBackground, + .foregroundColor: foregroundColor, + .font: currentFont + ]) + + attrString.insert(linkString, at: lowerInt) } + textview.attributedText = attrString InnerContents.recalculateHeight(view: textview, result: $calculatedHeight) } @@ -859,33 +1438,34 @@ struct InnerContents: UIViewRepresentable { struct Contents: View { @EnvironmentObject var viewState: ViewState @Environment(\.lineLimit) var lineLimit: Int? + @Environment(\.multilineTextAlignment) var textAlignment: TextAlignment @State var calculatedHeight: CGFloat = 0 @Binding var text: String - - var currentServer: String? = nil - + var fontSize: CGFloat var font: UIFont - var foregroundColor: UIColor + var foregroundColor: UIColor? - init(text: Binding, currentServer: String? = nil, fontSize: CGFloat? = nil, font: UIFont? = nil, foregroundColor: UIColor? = nil) { + init(text: Binding, fontSize: CGFloat? = nil, font: UIFont? = nil, foregroundColor: UIColor? = nil) { self._text = text - self.currentServer = currentServer self.fontSize = fontSize ?? font?.pointSize ?? UIFont.systemFontSize self.font = font ?? .systemFont(ofSize: fontSize ?? UIFont.systemFontSize) - self.foregroundColor = foregroundColor ?? .white + self.foregroundColor = foregroundColor } var body: some View { + let foreground = foregroundColor ?? viewState.theme.foreground.uiColor + if viewState.userSettingsStore.store.experiments.customMarkdown { - InnerContents(text: $text, calculatedHeight: $calculatedHeight, fontSize: fontSize, font: font, foregroundColor: foregroundColor, lineLimit: lineLimit) + InnerContents(content: $text, calculatedHeight: $calculatedHeight, fontSize: fontSize, font: font, foregroundColor: foreground, lineLimit: lineLimit, textAlignment: textAlignment) .frame(height: calculatedHeight) } else { Text((try? AttributedString(markdown: text.data(using: .utf8)!)) ?? AttributedString(text)) .font(Font.system(size: fontSize)) - .foregroundStyle(Color(uiColor: foregroundColor)) + .foregroundStyle(Color(uiColor: foreground)) .lineLimit(lineLimit) + .multilineTextAlignment(textAlignment) } } } diff --git a/Revolt/Components/MessageBox.swift b/Revolt/Components/MessageBox.swift index b1b4e83..0eb0c20 100644 --- a/Revolt/Components/MessageBox.swift +++ b/Revolt/Components/MessageBox.swift @@ -10,70 +10,63 @@ import SwiftUI import PhotosUI import Types -struct Reply { +struct Reply: Identifiable, Equatable { var message: Message var mention: Bool = false + + var id: String { message.id } } -class ReplyViewModel: ObservableObject { - var idx: Int - +struct ReplyView: View { + @EnvironmentObject var viewState: ViewState + + @Binding var reply: Reply + @Binding var replies: [Reply] - + var channel: Channel var server: Server? func remove() { - replies.remove(at: idx) - } - - internal init(idx: Int, replies: Binding<[Reply]>, channel: Channel, server: Server?) { - self.idx = idx - _replies = replies - self.channel = channel - self.server = server + withAnimation { + replies.removeAll(where: { $0.id == reply.id }) + } } -} - -struct ReplyView: View { - @EnvironmentObject var viewState: ViewState - @ObservedObject var viewModel: ReplyViewModel - + var body: some View { - let reply = $viewModel.replies[viewModel.idx] - - let user = viewState.users[reply.message.author.wrappedValue]! - let member = viewModel.server.flatMap { viewState.members[$0.id]?[user.id] } + let user = viewState.users[reply.message.author]! + let member = server.flatMap { viewState.members[$0.id]?[user.id] } HStack(alignment: .center, spacing: 8) { - Button(action: viewModel.remove) { + Button(action: remove) { Image(systemName: "xmark") .resizable() .frame(width: 10, height: 10) .foregroundStyle(viewState.theme.foreground3) + .bold() } Avatar(user: user, width: 16, height: 16) - Text(reply.message.masquerade.wrappedValue?.name ?? member?.nickname ?? user.display_name ?? user.username) + Text(reply.message.masquerade?.name ?? member?.nickname ?? user.display_name ?? user.username) .font(.caption) .fixedSize() - .foregroundStyle(member?.displayColour(theme: viewState.theme, server: viewModel.server!) ?? AnyShapeStyle(viewState.theme.foreground.color)) + .foregroundStyle(member?.displayColour(theme: viewState.theme, server: server!) ?? AnyShapeStyle(viewState.theme.foreground.color)) - if !(reply.message.wrappedValue.attachments?.isEmpty ?? true) { + if !(reply.message.attachments?.isEmpty ?? true) { Text(Image(systemName: "doc.text.fill")) .font(.caption) .foregroundStyle(viewState.theme.foreground2) } - if let content = Binding(reply.message.content) { + if let content = Binding($reply.message.content) { Contents(text: content, fontSize: 12) .lineLimit(1) .truncationMode(.tail) } - Button(action: { viewModel.replies[viewModel.idx].mention.toggle() }) { - if viewModel.replies[viewModel.idx].mention { + Button(action: { reply.mention.toggle() }) { + if reply.mention { Text("@ on") .foregroundColor(.accentColor) } else { @@ -153,9 +146,9 @@ struct MessageBox: View { if let message = editing { Task { - await viewState.http.editMessage(channel: channel.id, message: message.id, edits: MessageEdit(content: c)) - editing = nil + + await viewState.http.editMessage(channel: channel.id, message: message.id, edits: MessageEdit(content: c)) } } else { @@ -227,12 +220,13 @@ struct MessageBox: View { var body: some View { VStack(alignment: .leading, spacing: 4) { - ForEach(Array(channelReplies.enumerated()), id: \.element.message.id) { reply in - let model = ReplyViewModel(idx: reply.offset, replies: $channelReplies, channel: channel, server: server) - - ReplyView(viewModel: model) + ForEach($channelReplies) { reply in + ReplyView(reply: reply, replies: $channelReplies, channel: channel, server: server) .padding(.horizontal, 4) + //.transition(.move(edge: .bottom)) } + .animation(.default, value: channelReplies) + VStack(alignment: .leading, spacing: 8) { if selectedPhotos.count > 0 { ScrollView(.horizontal) { @@ -443,10 +437,12 @@ struct MessageBox: View { if let a { selectedPhotos = [] selectedPhotoItems = [] - channelReplies = [] autoCompleteType = nil autocompleteSearchValue = "" content = a.content ?? "" + } else { + channelReplies = [] + content = "" } }) .sheet(isPresented: $showingSelectEmoji) { diff --git a/Revolt/Components/MessageRenderer/MessageContentsView.swift b/Revolt/Components/MessageRenderer/MessageContentsView.swift index ddeb1f1..fd6a996 100644 --- a/Revolt/Components/MessageRenderer/MessageContentsView.swift +++ b/Revolt/Components/MessageRenderer/MessageContentsView.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI import Types +@MainActor class MessageContentsViewModel: ObservableObject, Equatable { var viewState: ViewState @@ -44,6 +45,9 @@ class MessageContentsViewModel: ObservableObject, Equatable { func reply() { if !channelReplies.contains(where: { $0.message.id == message.id }) && channelReplies.count < 5 { + withAnimation { + channelReplies.append(Reply(message: message)) + } channelReplies.append(Reply(message: message)) } } @@ -57,7 +61,8 @@ struct MessageContentsView: View { @State var showReportSheet: Bool = false @State var showReactSheet: Bool = false @State var showReactionsSheet: Bool = false - @State var isStatic: Bool + @State var isStatic: Bool = false + @State var onlyShowContent: Bool = false private var canManageMessages: Bool { let member = viewModel.server.flatMap { @@ -78,23 +83,26 @@ struct MessageContentsView: View { } var body: some View { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 4) { if let content = Binding(viewModel.$message.content), !content.wrappedValue.isEmpty { Contents(text: content, fontSize: 16) //.font(.body) } - if let embeds = Binding(viewModel.$message.embeds) { - ForEach(embeds, id: \.wrappedValue) { embed in - MessageEmbed(embed: embed) + if !onlyShowContent { + if let embeds = Binding(viewModel.$message.embeds) { + ForEach(embeds, id: \.wrappedValue) { embed in + MessageEmbed(embed: embed) + } } } - - if let attachments = viewModel.message.attachments { - VStack(alignment: .leading) { - ForEach(attachments) { attachment in - MessageAttachment(attachment: attachment) + if !onlyShowContent { + if let attachments = viewModel.message.attachments { + VStack(alignment: .leading) { + ForEach(attachments) { attachment in + MessageAttachment(attachment: attachment) + } } } } @@ -106,6 +114,7 @@ struct MessageContentsView: View { interactions: viewModel.$message.interactions ) } + .environment(\.currentMessage, viewModel) .sheet(isPresented: $showReportSheet) { ReportMessageSheetView(showSheet: $showReportSheet, messageView: viewModel) .presentationBackground(viewState.theme.background) @@ -127,6 +136,31 @@ struct MessageContentsView: View { } .contextMenu { if !isStatic { + if isMessageAuthor { + Button { + Task { + var replies: [Reply] = [] + + for reply in viewModel.message.replies ?? [] { + var message: Message? = viewState.messages[reply] + + if message == nil { + message = try? await viewState.http.fetchMessage(channel: viewModel.channel.id, message: reply).get() + } + + if let message { + replies.append(Reply(message: message, mention: viewModel.message.mentions?.contains(message.author) ?? false)) + } + } + + viewModel.channelReplies = replies + viewModel.editing = viewModel.message + } + } label: { + Label("Edit Message", systemImage: "pencil") + } + } + Button(action: viewModel.reply, label: { Label("Reply", systemImage: "arrowshape.turn.up.left.fill") }) @@ -151,28 +185,6 @@ struct MessageContentsView: View { Label("Copy text", systemImage: "doc.on.clipboard") } - if canDeleteMessage { - Button(role: .destructive, action: { - Task { - await viewModel.delete() - } - }, label: { - Label("Delete", systemImage: "trash") - }) - } - - if !isMessageAuthor { - Button(role: .destructive, action: { showReportSheet.toggle() }, label: { - Label("Report", systemImage: "exclamationmark.triangle") - }) - } else { - Button { - viewModel.editing = viewModel.message - } label: { - Label("Edit", systemImage: "pencil") - } - } - Button { if let server = viewModel.server { copyUrl(url: URL(string: "https://revolt.chat/app/server/\(server.id)/channel/\(viewModel.channel.id)/\(viewModel.message.id)")!) @@ -181,13 +193,29 @@ struct MessageContentsView: View { } } label: { - Label("Copy message link", systemImage: "doc.on.clipboard") + Label("Copy Message Link", systemImage: "link") } Button { copyText(text: viewModel.message.id) } label: { - Label("Copy ID", systemImage: "doc.on.clipboard") + Label("Copy Message ID", systemImage: "doc.on.clipboard") + } + + if canDeleteMessage { + Button(role: .destructive, action: { + Task { + await viewModel.delete() + } + }, label: { + Label("Delete Message", systemImage: "trash") + }) + } + + if !isMessageAuthor { + Button(role: .destructive, action: { showReportSheet.toggle() }, label: { + Label("Report Message", systemImage: "exclamationmark.triangle") + }) } } } diff --git a/Revolt/Components/MessageRenderer/MessageEmbed.swift b/Revolt/Components/MessageRenderer/MessageEmbed.swift index 4814c80..2dac5c8 100644 --- a/Revolt/Components/MessageRenderer/MessageEmbed.swift +++ b/Revolt/Components/MessageRenderer/MessageEmbed.swift @@ -109,7 +109,8 @@ struct MessageEmbed: View { } else if let video = embed.video { if let url = URL(string: video.url) { VideoPlayer(player: AVPlayer(url: url)) - .frame(width: CGFloat(integerLiteral: video.width), height: CGFloat(integerLiteral: video.height)) + .aspectRatio(CGSize(width: video.width, height: video.height), contentMode: .fit) + .frame(maxWidth: CGFloat(integerLiteral: video.width), maxHeight: CGFloat(integerLiteral: video.height)) } } else if let image = embed.image { if image.size == JanuaryImage.Size.large { diff --git a/Revolt/Components/MessageRenderer/MessageView.swift b/Revolt/Components/MessageRenderer/MessageView.swift index 8266923..726089c 100644 --- a/Revolt/Components/MessageRenderer/MessageView.swift +++ b/Revolt/Components/MessageRenderer/MessageView.swift @@ -28,7 +28,8 @@ struct MessageView: View { @EnvironmentObject var viewState: ViewState @State var showReportSheet: Bool = false - @State var isStatic: Bool + @State var isStatic: Bool = false + @State var onlyShowContent: Bool = false var isCompactMode: (Bool, Bool) { return TEMP_IS_COMPACT_MODE @@ -37,18 +38,18 @@ struct MessageView: View { private func pfpView(size: AvatarSize) -> some View { ZStack(alignment: .topLeading) { Avatar(user: viewModel.author, member: viewModel.member, masquerade: viewModel.message.masquerade, webhook: viewModel.message.webhook, width: size.sizes.0, height: size.sizes.0) - + .onTapGesture { + if !isStatic || viewModel.message.webhook != nil { + viewState.openUserSheet(withId: viewModel.author.id, server: viewModel.server?.id) + } + } + if viewModel.message.masquerade != nil { Avatar(user: viewModel.author, member: viewModel.member, webhook: viewModel.message.webhook, width: size.sizes.1, height: size.sizes.1) .padding(.leading, -size.sizes.2) .padding(.top, -size.sizes.2) } } - .onTapGesture { - if !isStatic || viewModel.message.webhook != nil { - viewState.openUserSheet(withId: viewModel.author.id, server: viewModel.server?.id) - } - } } private var nameView: some View { @@ -86,70 +87,74 @@ struct MessageView: View { } } - if isCompactMode.0 { - HStack(alignment: .top, spacing: 4) { - HStack(alignment: .center, spacing: 4) { - Text(createdAt(id: viewModel.message.id).formatted(Date.FormatStyle().hour(.twoDigits(amPM: .omitted)).minute(.twoDigits))) - .font(.caption) - .foregroundStyle(viewState.theme.foreground2) - - if isCompactMode.1 { - pfpView(size: .compact) - } - - nameView - - if viewModel.author.bot != nil { - MessageBadge(text: String(localized: "Bot"), color: viewState.theme.accent.color) - } - } - - MessageContentsView(viewModel: viewModel, isStatic: isStatic) - - if viewModel.message.edited != nil { - Text("(edited)") - .font(.caption) - .foregroundStyle(.gray) - } - } - + if viewModel.message.system != nil { + SystemMessageView(message: $viewModel.message) } else { - HStack(alignment: .top) { - pfpView(size: .regular) - .padding(.top, 2) - .padding(.trailing, 8) - - VStack(alignment: .leading, spacing: 0) { - HStack { + if isCompactMode.0 { + HStack(alignment: .top, spacing: 4) { + HStack(alignment: .center, spacing: 4) { + Text(createdAt(id: viewModel.message.id).formatted(Date.FormatStyle().hour(.twoDigits(amPM: .omitted)).minute(.twoDigits))) + .font(.caption) + .foregroundStyle(viewState.theme.foreground2) + + if isCompactMode.1 { + pfpView(size: .compact) + } + nameView if viewModel.author.bot != nil { MessageBadge(text: String(localized: "Bot"), color: viewState.theme.accent.color) } - - if viewModel.message.webhook != nil { - MessageBadge(text: String(localized: "Webhook"), color: viewState.theme.accent.color) - - } - - Text(createdAt(id: viewModel.message.id).formatted(Date.FormatStyle().hour(.twoDigits(amPM: .omitted)).minute(.twoDigits))) + } + + MessageContentsView(viewModel: viewModel, isStatic: isStatic, onlyShowContent: onlyShowContent) + + if viewModel.message.edited != nil { + Text("(edited)") .font(.caption) - .foregroundStyle(viewState.theme.foreground2) - - if viewModel.message.edited != nil { - Text("(edited)") + .foregroundStyle(.gray) + } + } + } else { + HStack(alignment: .top) { + pfpView(size: .regular) + .padding(.top, 2) + .padding(.trailing, 8) + + VStack(alignment: .leading, spacing: 0) { + HStack { + nameView + + if viewModel.author.bot != nil { + MessageBadge(text: String(localized: "Bot"), color: viewState.theme.accent.color) + } + + if viewModel.message.webhook != nil { + MessageBadge(text: String(localized: "Webhook"), color: viewState.theme.accent.color) + + } + + Text(createdAt(id: viewModel.message.id).formatted(Date.FormatStyle().hour(.twoDigits(amPM: .omitted)).minute(.twoDigits))) .font(.caption) - .foregroundStyle(.gray) + .foregroundStyle(viewState.theme.foreground2) + + if viewModel.message.edited != nil { + Text("(edited)") + .font(.caption) + .foregroundStyle(.gray) + } } + + MessageContentsView(viewModel: viewModel, isStatic: isStatic, onlyShowContent: onlyShowContent) } - - MessageContentsView(viewModel: viewModel, isStatic: isStatic) } } } } .font(Font.system(size: 14.0)) .listRowSeparator(.hidden) + .environment(\.currentMessage, viewModel) } } diff --git a/Revolt/Components/MessageRenderer/SystemMessageView.swift b/Revolt/Components/MessageRenderer/SystemMessageView.swift index 0422f7e..99ac4f0 100644 --- a/Revolt/Components/MessageRenderer/SystemMessageView.swift +++ b/Revolt/Components/MessageRenderer/SystemMessageView.swift @@ -14,14 +14,21 @@ struct SystemMessageView: View { @Binding var message: Message var body: some View { - HStack { + HStack(alignment: .center) { switch message.system! { case .user_joined(let content): let user = viewState.users[content.id]! + let member = viewState.channels[message.channel]!.server.flatMap { viewState.members[$0]?[user.id] } + Image(systemName: "arrow.forward") - Avatar(user: user, masquerade: message.masquerade) - Text(user.username) - Text("Joined") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + + Avatar(user: user, member: member, masquerade: message.masquerade, width: 24, height: 24) + + Text("\(member?.nickname ?? user.display_name ?? user.username) joined") + default: Text("unknown") } diff --git a/Revolt/Extensions/EnvironmentValues.swift b/Revolt/Extensions/EnvironmentValues.swift new file mode 100644 index 0000000..52426a1 --- /dev/null +++ b/Revolt/Extensions/EnvironmentValues.swift @@ -0,0 +1,15 @@ +// +// EnvironmentValues.swift +// Revolt +// +// Created by Angelo on 17/10/2024. +// + +import SwiftUI +import Types + +extension EnvironmentValues { + @Entry var currentMessage: MessageContentsViewModel? = nil + @Entry var currentServer: Server? = nil + @Entry var currentChannel: Channel? = nil +} diff --git a/Revolt/Extensions/UIFont.swift b/Revolt/Extensions/UIFont.swift new file mode 100644 index 0000000..8a78078 --- /dev/null +++ b/Revolt/Extensions/UIFont.swift @@ -0,0 +1,16 @@ +// +// UIFont.swift +// Revolt +// +// Created by Angelo on 10/11/2024. +// + +import UIKit + +extension UIFont { + func bottomOffsetFromBaselineForVerticalCentering(targetHeight height: CGFloat) -> CGFloat { + let textHeight = ascender - descender + let offset = (textHeight - height) / 2 + descender + return offset + } +} diff --git a/Revolt/Extensions/UIImage.swift b/Revolt/Extensions/UIImage.swift index f84ef77..fb241bb 100644 --- a/Revolt/Extensions/UIImage.swift +++ b/Revolt/Extensions/UIImage.swift @@ -30,11 +30,33 @@ extension UIImage { } } - func imageWith(newSize size: CGSize) -> UIImage { - UIGraphicsBeginImageContextWithOptions(size, false, 0) - defer { UIGraphicsEndImageContext() } - draw(in: CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height)) - return UIGraphicsGetImageFromCurrentImageContext()! + func imageWith(newSize: CGSize) -> UIImage { + let image = UIGraphicsImageRenderer(size: newSize).image { _ in + draw(in: CGRect(origin: .zero, size: newSize)) + } + + return image.withRenderingMode(renderingMode) + } + + func imageWith(targetSize: CGSize) -> UIImage { + let widthRatio = targetSize.width / size.width + let heightRatio = targetSize.height / size.height + + var newSize: CGSize + if(widthRatio > heightRatio) { + newSize = CGSize(width: size.width * heightRatio, height: size.height * heightRatio) + } else { + newSize = CGSize(width: size.width * widthRatio, height: size.height * widthRatio) + } + + let rect = CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height) + + UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) + draw(in: rect) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return newImage! } var roundedImage: UIImage { diff --git a/Revolt/Pages/Channel/Messagable/MessageableChannel.swift b/Revolt/Pages/Channel/Messagable/MessageableChannel.swift index fe83db2..c4b26e9 100644 --- a/Revolt/Pages/Channel/Messagable/MessageableChannel.swift +++ b/Revolt/Pages/Channel/Messagable/MessageableChannel.swift @@ -161,7 +161,7 @@ struct MessageableChannelView: View { let ending = users.count == 1 ? "is typing" : "are typing" - return "\(base) \(ending)" + return "\(base) \(ending)..." } func getAuthor(message: Binding) -> Binding { @@ -353,7 +353,6 @@ struct MessageableChannelView: View { } }) .safeAreaPadding(EdgeInsets(top: 0, leading: 0, bottom: 24, trailing: 0)) - .defaultScrollAnchor(.bottom) .listStyle(.plain) .listRowSeparator(.hidden) .overlay(alignment: .top) { @@ -388,16 +387,19 @@ struct MessageableChannelView: View { Spacer() } .frame(maxWidth: .infinity) - .padding(.horizontal, 8) + .padding(.horizontal, 12) .padding(.top, 2) .background(viewState.theme.messageBox) } } .environment(\.defaultMinListRowHeight, 0) - .simultaneousGesture(TapGesture().onEnded { focused = false }) - // .scrollDismissesKeyboard(.immediately) + //.gesture(TapGesture().onEnded { focused = false }, isEnabled: focused) + .scrollDismissesKeyboard(.never) } + .defaultScrollAnchor(.bottom) + .scrollDismissesKeyboard(.never) } + .gesture(TapGesture().onEnded { focused = false }, isEnabled: focused) MessageBox( channel: viewModel.channel, @@ -449,7 +451,7 @@ struct MessageableChannelView: View { } #Preview { - @StateObject var viewState = ViewState.preview() + @Previewable @StateObject var viewState = ViewState.preview() let messages = Binding($viewState.channelMessages["0"])! return MessageableChannelView(viewModel: .init(viewState: viewState, channel: viewState.channels["0"]!, server: viewState.servers[""], messages: messages), toggleSidebar: {}) diff --git a/Revolt/Pages/Home/Home.swift b/Revolt/Pages/Home/Home.swift index d49cade..ac62df1 100644 --- a/Revolt/Pages/Home/Home.swift +++ b/Revolt/Pages/Home/Home.swift @@ -114,6 +114,11 @@ struct HomeRewritten: View { .background(viewState.theme.background2.color) ZStack { + viewState.theme.messageBox + .offset(x: offset) + .frame(width: geo.size.width) + .ignoresSafeArea(.all) + MaybeChannelView(currentChannel: $currentChannel, currentSelection: $currentSelection, toggleSidebar: toggleSidebar) .disabled(offset != 0.0) .offset(x: offset) diff --git a/Revolt/RevoltApp.swift b/Revolt/RevoltApp.swift index 4363284..8377542 100644 --- a/Revolt/RevoltApp.swift +++ b/Revolt/RevoltApp.swift @@ -246,6 +246,8 @@ struct MainApp: View { ViewInvite(code: code) } } + .environment(\.currentServer, viewState.currentSelection.id.flatMap { viewState.servers[$0] }) + .environment(\.currentChannel, viewState.currentChannel.id.flatMap { viewState.channels[$0] }) .sheet(item: $viewState.currentUserSheet) { (v) in UserSheet(user: v.user, member: v.member) }