fix: Working mention and emoji rendering

This commit is contained in:
Zomatree
2024-07-20 01:57:29 +01:00
parent a891529b87
commit a723e77545
7 changed files with 273 additions and 101 deletions
+4
View File
@@ -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 = "<group>"; };
17DE7E0C2BFAFDDD00C99188 /* Api.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Api.swift; sourceTree = "<group>"; };
17DE7E1B2BFAFE5600C99188 /* File.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = File.swift; sourceTree = "<group>"; };
17DF7CAF2C4ACD9D003E8FDC /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = "<group>"; };
17DFB4632AE06A5D00E1D417 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
17E019C82AF1279100AB4663 /* AppearanceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettings.swift; sourceTree = "<group>"; };
17E019CC2AF136B100AB4663 /* PresenceIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresenceIndicator.swift; sourceTree = "<group>"; };
@@ -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 */,
+238 -88
View File
@@ -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<String, (), String> {
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>", input: text)) ?? [.text(text)]
// [.text(text)]
return (try? parser.run(sourceName: "<input>", input: text)) ?? [.text(text)]
}
func parseEmojisOnly(text: String) -> [String]? {
(try? emojiOnlyParser.run(sourceName: "<input>", 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("<emoji>", 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)
}
}
@@ -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<Message>, author: Binding<User>, member: Binding<Member?>, server: Binding<Server?>, channel: Binding<Channel>, replies: Binding<[Reply]>, channelScrollPosition: ChannelScrollController, editing: Binding<Message?>) {
@@ -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)
@@ -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 {
+1 -1
View File
@@ -168,7 +168,7 @@ struct UserSheet: View {
Text("Bio")
.font(.caption)
Contents(text: .constant(bio))
Contents(text: .constant(bio), fontSize: 17)
}
Spacer()
+18
View File
@@ -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
}
}
+1 -1
View File
@@ -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