diff --git a/Revolt.xcodeproj/project.pbxproj b/Revolt.xcodeproj/project.pbxproj index 97c93b4..efbdf0f 100644 --- a/Revolt.xcodeproj/project.pbxproj +++ b/Revolt.xcodeproj/project.pbxproj @@ -94,6 +94,7 @@ 17D9C2BD2ADCA48D00426857 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 17D9C2BC2ADCA48D00426857 /* Kingfisher */; }; 17D9D2B72B0843DE00D57812 /* PermissionsCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D9D2B62B0843DE00D57812 /* PermissionsCalculator.swift */; }; 17DE7DB52BFAEC3F00C99188 /* Types.h in Headers */ = {isa = PBXBuildFile; fileRef = 17DE7DB42BFAEC3F00C99188 /* Types.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 17DF7CB02C4ACDA1003E8FDC /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DF7CAF2C4ACD9D003E8FDC /* UIImage.swift */; }; 17DFB4642AE06A5D00E1D417 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DFB4632AE06A5D00E1D417 /* Settings.swift */; }; 17E019C92AF1279100AB4663 /* AppearanceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E019C82AF1279100AB4663 /* AppearanceSettings.swift */; }; 17E019CD2AF136B100AB4663 /* PresenceIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E019CC2AF136B100AB4663 /* PresenceIndicator.swift */; }; @@ -253,6 +254,7 @@ 17DE7E0B2BFAFDDD00C99188 /* User.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; 17DE7E0C2BFAFDDD00C99188 /* Api.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Api.swift; sourceTree = ""; }; 17DE7E1B2BFAFE5600C99188 /* File.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = File.swift; sourceTree = ""; }; + 17DF7CAF2C4ACD9D003E8FDC /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; 17DFB4632AE06A5D00E1D417 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; 17E019C82AF1279100AB4663 /* AppearanceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettings.swift; sourceTree = ""; }; 17E019CC2AF136B100AB4663 /* PresenceIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresenceIndicator.swift; sourceTree = ""; }; @@ -317,6 +319,7 @@ 170C23D82C224BA40057E399 /* Extensions */ = { isa = PBXGroup; children = ( + 17DF7CAF2C4ACD9D003E8FDC /* UIImage.swift */, 172F2D022C22ED5A00948C00 /* Collection.swift */, 172F2D002C22ED4A00948C00 /* IteratorProtocol.swift */, 172F2CFE2C22ED3600948C00 /* Bundle.swift */, @@ -830,6 +833,7 @@ 178BB11B2B02FCDD001143A4 /* Responses.swift in Sources */, 17A589FA2C44781F00B9D85A /* AnyTransition.swift in Sources */, 17BF54CD2B1785E200178866 /* HomeWelcome.swift in Sources */, + 17DF7CB02C4ACDA1003E8FDC /* UIImage.swift in Sources */, 17F502542B9BFB1000A3022D /* AddFriend.swift in Sources */, 17C9A3502B0580D10043A387 /* Welcome.swift in Sources */, 17A7FE392C49EBD20083B22F /* Image.swift in Sources */, diff --git a/Revolt/Components/Contents.swift b/Revolt/Components/Contents.swift index 33463bd..7065932 100644 --- a/Revolt/Components/Contents.swift +++ b/Revolt/Components/Contents.swift @@ -8,9 +8,10 @@ import Foundation import SwiftUI import Flow -import SwiftParsec +// import Parsing import Kingfisher import Types +import SwiftParsec enum ContentPart: Equatable { case text(AttributedString) @@ -26,31 +27,144 @@ enum Node { case custom_emoji(String) } +// +//let userMention = Parse(input: Substring.self) { id in +// Node.user_mention(String(id)) +//} with: { +// "<@" +// Prefix { $0 != ">" } +// ">" +//} +// +//let channelMention = Parse(input: Substring.self) { id in +// Node.channel_mention(String(id)) +//} with: { +// "<#" +// Prefix { $0 != ">" } +// ">" +//} +//let mention = Parse(input: Substring.self) { +// OneOf { +// userMention +// channelMention +// } +//} +// +//let emoji = Parse(input: Substring.self) { id in +// Node.custom_emoji(String(id)) +//} with: { +// ":" +// Prefix { $0 != ":" } +// ":" +//} +// +//let parser = Parse(input: Substring.self) { +// Many { +// Optionally { +// Many { +// OneOf { +// mention +// emoji +// } +// } +// } +// Prefix { !["@", ":"].contains(String($0)) }.map { v in Node.text(String(v)!) } +// } +// Many { +// Parse { +// Optionally { +// OneOf { +// mention +// emoji +// } +// } +// } +// Optionally { +// OneOf { +// mention +// emoji +// } +// } +// }.map { nodes in +// nodes.flatMap { (a, b, c) in +// [a, b, c].compactMap { $0 } +// } +// } +//} + +//func parseMentions(text: String) -> [Node] { +// // if it fails just default back to regular text +// //[.text(text)] +// print(text) +// return try! parser.parse(text) +//} + +// if you need to properly debug this the haskell code for this is here: +/* +data Node = Text String | Emoji String | UserMention String | ChannelMention String +deriving Show + +emoji :: GenParser Char () Node +emoji = Emoji <$> (char ':' *> many1 (noneOf ":") <* char ':') + +mentionTemplate :: (String -> b) -> Char -> GenParser Char () b +mentionTemplate f c = f <$> (char '<' *> char c *> many1 (noneOf ">") <* char '>') + +userMention :: GenParser Char () Node +userMention = mentionTemplate UserMention '@' + +channelMention :: GenParser Char () Node +channelMention = mentionTemplate UserMention '#' + +mention :: GenParser Char () Node +mention = try userMention <|> channelMention + +customElement :: GenParser Char () Node +customElement = try emoji <|> mention + +text :: GenParser Char () Node +text = Text <$> liftA2 (:) anyChar (manyTill anyChar (lookAhead $ eof <|> void (try customElement))) + +node :: GenParser Char () Node +node = try customElement <|> text + +parser :: GenParser Char () [Node] +parser = many1 node +*/ + let character = StringParser.character func mentionTemplate(_ c: Character) -> GenericParser { - StringParser.noneOf("<@>") + character("<") + *> character(c) + *> StringParser.noneOf(["<", c, ">"]) .many1 .stringValue - .between( - character("<") *> character(c), - character(">") - ) + <* character(">") } -let userMention = mentionTemplate("@").map(Node.user_mention) -let channelMention = mentionTemplate("#").map(Node.channel_mention) +let userMention = Node.user_mention <^> mentionTemplate("@") +let channelMention = Node.channel_mention <^> mentionTemplate("#") -let mention = userMention.attempt <|> channelMention.attempt +let mention = userMention.attempt <|> channelMention -let emojiRaw = StringParser.noneOf(":").many1.stringValue.between(character(":"), character(":")) -let emoji = emojiRaw.map(Node.custom_emoji) -let customElement = mention <|> emoji +let emojiRaw = character(":") + *> StringParser.noneOf(":") + .many1 + .stringValue + <* character(":") -let text = ( - StringParser.anyCharacter.manyTill(customElement.attempt.lookAhead).attempt <|> - StringParser.anyCharacter.many1) - .stringValue.map(Node.text) +let emoji = Node.custom_emoji <^> emojiRaw + +let customElement = mention.attempt <|> emoji + +let text = StringParser.anyCharacter >>- { result in + StringParser.anyCharacter.manyTill((StringParser.eof <|> customElement.attempt.discard).lookAhead) >>- { results in + return GenericParser( + result: Node.text(String(results.prepending(result))) + ) + } +} let node = customElement.attempt <|> text @@ -59,30 +173,30 @@ let emojiOnlyParser = emojiRaw.separatedBy1(character(" ").many).optional func parseMentions(text: String) -> [Node] { // if it fails just default back to regular text - [.text(text)] - // (try? parser.run(sourceName: "", input: text)) ?? [.text(text)] + // [.text(text)] + return (try? parser.run(sourceName: "", input: text)) ?? [.text(text)] } func parseEmojisOnly(text: String) -> [String]? { (try? emojiOnlyParser.run(sourceName: "", input: text)) ?? nil } -// + //struct Contents: View { // @EnvironmentObject var viewState: ViewState // // var text: String -// +// // @State var showMemberSheet: Bool = false -// +// // func parseText(content: String, currentServer: String? = nil) -> [ContentPart] { // var parts: [ContentPart] = [] // let content = try! AttributedString(markdown: content, options: .init(allowsExtendedAttributes: true, //interpretedSyntax: .full, failurePolicy: .returnPartiallyParsedIfPossible)) -// +// // for run in content.runs { // let innerContent = content.characters[run.range].map { String($0) }.joined(separator: "") // let innerParts = parseMentions(text: innerContent) -// +// // for part in innerParts { // switch part { // case .text(let c): @@ -96,13 +210,13 @@ func parseEmojisOnly(text: String) -> [String]? { // case .user_mention(let id): // if let user = viewState.users[id] { // let member: Member? -// +// // if let server = currentServer { // member = viewState.members[server]?[id] // } else { // member = nil // } -// +// // parts.append(.user_mention(user, member)) // } else { // parts.append(.text(AttributedString("<@\(id)>", attributes: run.attributes))) @@ -112,13 +226,13 @@ func parseEmojisOnly(text: String) -> [String]? { // } // } // } -// +// // return parts // } -// +// // var body: some View { // let parts = parseText(content: text) -// +// // GeometryReader { proxy in // if let emojis = parseEmojisOnly(text: text) { // ForEach(emojis, id: \.self) { emoji in @@ -164,17 +278,17 @@ func parseEmojisOnly(text: String) -> [String]? { // //struct SubviewTextView: UIViewRepresentable { // @EnvironmentObject var viewState: ViewState -// +// // var fixedWidth: CGFloat // var parts: [ContentPart] -// +// // func memberColour(member: Member?) -> Color? { // return member.flatMap { // let server = viewState.servers[$0.id.server]! // return $0.displayColour(server: server) // } // } -// +// // func makeUIView(context: Context) -> some UIView { // let view = SubviewAttachingTextView() // view.textContainer.lineFragmentPadding = 0 @@ -184,21 +298,21 @@ func parseEmojisOnly(text: String) -> [String]? { // view.textColor = .white // view.backgroundColor = nil // view.isEditable = false -// +// // view.translatesAutoresizingMaskIntoConstraints = false // view.isScrollEnabled = false // view.adjustsFontForContentSizeCategory = true -// +// // view.setContentHuggingPriority(.required, for: .vertical) // view.setContentCompressionResistancePriority(.required, for: .vertical) // view.setContentHuggingPriority(.defaultLow, for: .horizontal) // view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) -// +// // let attrString = NSMutableAttributedString() -// +// // for part in parts { // var view: (any View)? = nil -// +// // switch part { // case .text(var str): // str.foregroundColor = .white @@ -215,7 +329,7 @@ func parseEmojisOnly(text: String) -> [String]? { // // case .custom_emoji(let emoji): // () -// +// // case .user_mention(let user, let member): // view = HStack(spacing: 2) { // Avatar(user: user, member: member, width: 16, height: 16) @@ -226,7 +340,7 @@ func parseEmojisOnly(text: String) -> [String]? { // } // .contentShape(Capsule()) // } -// +// // if let subView = view { // let controller = UIHostingController(rootView: AnyView(subView)) // let nsAttrString = NSMutableAttributedString(attachment: SubviewTextAttachment(view: controller.view!)) @@ -235,19 +349,19 @@ func parseEmojisOnly(text: String) -> [String]? { // } // } // view.attributedText = attrString -// +// // return view // } -// +// // func updateUIView(_ view: UIViewType, context: Context) { // } //} //struct Contents: View { // @EnvironmentObject var viewState: ViewState -// +// // var text: String -// +// // var body: some View { // Text(verbatim: text) // } @@ -256,92 +370,128 @@ func parseEmojisOnly(text: String) -> [String]? { //struct InnerContents: UIViewRepresentable { // var viewState: ViewState // var text: String -// +// // func makeUIView(context: Context) -> some UIView { // let textView = UITextView() -// -// +// +// // textView.attributedText = NSAttributedString(str) // textView.backgroundColor = .clear // return textView // } -// +// // func updateUIView(_ uiView: UIViewType, context: Context) { // } //} +//struct _Contents: View { +// @State var images: [String: UIImage] = [:] +// +// func loadImage(url: String) -> UIImage { +// if let image = images[url] { +// return image +// } else { +// Task { +// +// } +// } +// } +// +// var body: some View { +// let font = UIFont.preferredFont(forTextStyle: .title1) +// let nsattr = NSAttributedString(string: "Zomatree", attributes: [.foregroundColor: UIColor.red, .font: font, .link: "https://revolt.chat"]) +// let attr = AttributedString(nsattr) +// let large = UIImage(named: "large")! +// let image = Image(uiImage: large.imageWith(newSize: CGSize(width: font.pointSize, height: font.pointSize))) +// +// ( +// Text("Hello ") + +// Text(image) + +// Text(attr) + +// Text(".") +// ).font(.title) +// } +//} + + struct Contents: View { - @Environment(\.font) var font: Font? @EnvironmentObject var viewState: ViewState @Binding var text: String - - func buildContent() -> AttributedString { - let font = self.font ?? Font.body - let boldFont = font.bold() + @State var images: [String: UIImage] = [:] + var fontSize: CGFloat + + func buildContent() -> Text? { + let font = UIFont.systemFont(ofSize: fontSize) + let boldFont = UIFont.boldSystemFont(ofSize: fontSize) let parts = parseMentions(text: text) - var str = AttributedString() - + var textParts: [Text] = [] + for part in parts { switch part { case .user_mention(let string): - var mention: AttributedString = AttributedString() - + var mention: NSAttributedString + if let user = viewState.users[string] { let member = viewState.currentServer.id.flatMap { viewState.members[$0] }.flatMap { $0[string] } - + let name = member?.nickname ?? user.display_name ?? user.username - - mention.append(AttributedString("@\(name)", attributes: AttributeContainer([.foregroundColor: viewState.theme.accent.color, .font: boldFont, .link: "revoltchat://users?user=\(string)"]) -)) + + mention = NSAttributedString(string: "@\(name)", attributes: [.foregroundColor: viewState.theme.accent.color, .font: boldFont, .link: "revoltchat://users?user=\(string)"]) } else { - let container = AttributeContainer([.foregroundColor: viewState.theme.accent.color, .font: boldFont]) - - mention.append(AttributedString("@Unknown", attributes: container)) + mention = NSAttributedString(string: "@Unknown", attributes: [.foregroundColor: viewState.theme.accent.color, .font: boldFont]) } - - str.append(mention) - + + textParts.append(Text(AttributedString(mention))) + case .channel_mention(let string): - let mention: AttributedString - + let mention: NSAttributedString + if let channel = viewState.channels[string] { let name = channel.getName(viewState) - - let container = AttributeContainer([.foregroundColor: viewState.theme.accent.color, .font: boldFont, .link: "revoltchat://channels?channel=\(string)"]) - mention = AttributedString("#\(name)", attributes: container) + + mention = NSAttributedString(string: "#\(name)", attributes: [.foregroundColor: viewState.theme.accent.color, .font: boldFont, .link: "revoltchat://channels?channel=\(string)"]) } else { - let container = AttributeContainer([.foregroundColor: viewState.theme.accent.color, .font: boldFont]) - - mention = AttributedString("#Unknown", attributes: container) + mention = NSAttributedString(string: "#Unknown", attributes: [.foregroundColor: viewState.theme.accent.color, .font: boldFont]) } - - str.append(mention) - + + textParts.append(Text(AttributedString(mention))) + case .text(let string): - var substring = try! AttributedString(markdown: string, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) - substring.setAttributes(AttributeContainer([.font: font, .foregroundColor: viewState.theme.foreground.color])) - - str.append(substring) - + if string.count > 0 { + var substring = try! NSMutableAttributedString(markdown: string, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) + substring.setAttributes([.font: font, .foregroundColor: viewState.theme.foreground.color], range: NSRange(location: 0, length: substring.length)) + + textParts.append(Text(AttributedString(substring))) + } + case .custom_emoji(let string): - str.append(AttributedString("", attributes: AttributeContainer([.font: font]))) + let image = UIImage(named: "amog")!.imageWith(newSize: CGSize(width: fontSize, height: fontSize)) + + textParts.append(Text(Image(uiImage: image))) } } - - return str + + if textParts.count > 0 { + let first = textParts.removeFirst() + return textParts.reduce(first, { a, b in a + b }) + } else { + return nil + } } - + var body: some View { - Text(buildContent()) + if let text = buildContent() { + text + } } } struct ParserPreview: PreviewProvider { static let viewState = ViewState.preview().applySystemScheme(theme: .light) - + static var previews: some View { - Contents(text: .constant("hello <@0>, checkout <#0>!")) + Contents(text: .constant("hello <@0>, checkout <#0>!"), fontSize: 16) .applyPreviewModifiers(withState: viewState) } } diff --git a/Revolt/Components/MessageRenderer/MessageContentsView.swift b/Revolt/Components/MessageRenderer/MessageContentsView.swift index 29b77ff..44582aa 100644 --- a/Revolt/Components/MessageRenderer/MessageContentsView.swift +++ b/Revolt/Components/MessageRenderer/MessageContentsView.swift @@ -19,7 +19,7 @@ class MessageContentsViewModel: ObservableObject, Equatable { @Binding var channel: Channel @Binding var channelReplies: [Reply] @Binding var editing: Message? - + var channelScrollPosition: ChannelScrollController init(viewState: ViewState, message: Binding, author: Binding, member: Binding, server: Binding, channel: Binding, replies: Binding<[Reply]>, channelScrollPosition: ChannelScrollController, editing: Binding) { @@ -58,14 +58,14 @@ struct MessageContentsView: View { @State var showReactSheet: Bool = false @State var isStatic: Bool - private var isModeratorInChannel: Bool { + private var canManageMessages: Bool { let member = viewModel.server.flatMap { viewState.members[$0.id]?[viewState.currentUser!.id] } - + let permissions = resolveChannelPermissions(from: viewState.currentUser!, targettingUser: viewState.currentUser!, targettingMember: member, channel: viewModel.channel, server: viewModel.server) - - return permissions.contains(.manageChannel) + + return permissions.contains(.manageMessages) } private var isMessageAuthor: Bool { @@ -73,16 +73,16 @@ struct MessageContentsView: View { } private var canDeleteMessage: Bool { - return isMessageAuthor || isModeratorInChannel + return isMessageAuthor || canManageMessages } var body: some View { VStack(alignment: .leading) { if let content = Binding(viewModel.$message.content) { - Contents(text: content) - .font(.body) + Contents(text: content, fontSize: 17) + //.font(.body) } - + if let embeds = Binding(viewModel.$message.embeds) { ForEach(embeds, id: \.wrappedValue) { embed in MessageEmbed(embed: embed) diff --git a/Revolt/Components/MessageRenderer/MessageEmbed.swift b/Revolt/Components/MessageRenderer/MessageEmbed.swift index b8cefe2..a985f98 100644 --- a/Revolt/Components/MessageRenderer/MessageEmbed.swift +++ b/Revolt/Components/MessageRenderer/MessageEmbed.swift @@ -101,8 +101,8 @@ struct MessageEmbed: View { } if let description = embed.description { - Contents(text: .constant(description)) - .font(.footnote) + Contents(text: .constant(description), fontSize: 13) + //.font(.footnote) } if let special = embed.special, special != .none { diff --git a/Revolt/Components/Sheets/UserSheet.swift b/Revolt/Components/Sheets/UserSheet.swift index 2c7c944..3e5b21a 100644 --- a/Revolt/Components/Sheets/UserSheet.swift +++ b/Revolt/Components/Sheets/UserSheet.swift @@ -168,7 +168,7 @@ struct UserSheet: View { Text("Bio") .font(.caption) - Contents(text: .constant(bio)) + Contents(text: .constant(bio), fontSize: 17) } Spacer() diff --git a/Revolt/Extensions/UIImage.swift b/Revolt/Extensions/UIImage.swift new file mode 100644 index 0000000..4145ab3 --- /dev/null +++ b/Revolt/Extensions/UIImage.swift @@ -0,0 +1,18 @@ +// +// UIImage.swift +// Revolt +// +// Created by Angelo on 19/07/2024. +// + +import UIKit + +extension UIImage { + func imageWith(newSize: CGSize) -> UIImage { + let image = UIGraphicsImageRenderer(size: newSize).image { _ in + draw(in: CGRect(origin: .zero, size: newSize)) + } + + return image + } +} diff --git a/Revolt/ViewState.swift b/Revolt/ViewState.swift index 588c6d2..b954fe9 100644 --- a/Revolt/ViewState.swift +++ b/Revolt/ViewState.swift @@ -133,7 +133,7 @@ public class ViewState: ObservableObject { static var application: NSApplication? = nil #endif - var http: HTTPClient = HTTPClient(token: nil, baseURL: "http://local.angelomanca.com:8000") + var http: HTTPClient = HTTPClient(token: nil, baseURL: "https://revolt.chat/api") var ws: WebSocketStream? = nil var apiInfo: ApiInfo? = nil