diff --git a/Revolt/Components/Contents.swift b/Revolt/Components/Contents.swift index 7065932..3f278b0 100644 --- a/Revolt/Components/Contents.swift +++ b/Revolt/Components/Contents.swift @@ -417,8 +417,41 @@ func parseEmojisOnly(text: String) -> [String]? { struct Contents: View { @EnvironmentObject var viewState: ViewState @Binding var text: String - @State var images: [String: UIImage] = [:] + @State var images: [URL: UIImage] = [:] var fontSize: CGFloat + + func addImageToState(url: URL, image: UIImage, round: Bool) { + let image = round ? image.roundedImage : image + + images[url] = image.imageWith(newSize: CGSize(width: fontSize, height: fontSize), contentMode: .contentAspectFit) + } + + func getImage(url: URL, round: Bool = false) -> UIImage { + if let image = images[url] { + return image + } else { + Task { + ImageCache.default.retrieveImage(forKey: url.absoluteString, options: []) { cacheResult in + if case .success(let cacheImage) = cacheResult, + let image = cacheImage.image + { + addImageToState(url: url, image: image, round: round) + } else { + ImageDownloader.default.downloadImage(with: url, options: []) { result in + if case .success(let image) = result, + let image = UIImage(data: image.originalData) + { + ImageCache.default.store(image, forKey: url.absoluteString, options: .init([])) + addImageToState(url: url, image: image, round: round) + } + } + } + } + } + + return UIImage() + } + } func buildContent() -> Text? { let font = UIFont.systemFont(ofSize: fontSize) @@ -430,20 +463,21 @@ struct Contents: View { for part in parts { switch part { case .user_mention(let string): - 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 = NSAttributedString(string: "@\(name)", attributes: [.foregroundColor: viewState.theme.accent.color, .font: boldFont, .link: "revoltchat://users?user=\(string)"]) + let mention = NSAttributedString(string: name, attributes: [.foregroundColor: viewState.theme.accent.color, .font: boldFont, .link: "revoltchat://users?user=\(string)"]) + let pfpUrl = (member?.avatar ?? user.avatar).map { viewState.formatUrl(with: $0) } ?? "\(viewState.http.baseURL)/users/\(user.id)/default_avatar" + + let image = getImage(url: URL(string: pfpUrl)!, round: true) + let text = Text(Image(uiImage: image)) + Text(AttributedString(mention)) + + textParts.append(text) } else { - mention = NSAttributedString(string: "@Unknown", attributes: [.foregroundColor: viewState.theme.accent.color, .font: boldFont]) + textParts.append(Text(AttributedString(NSAttributedString(string: "@Unknown", attributes: [.foregroundColor: viewState.theme.accent.color, .font: boldFont])))) } - - textParts.append(Text(AttributedString(mention))) - case .channel_mention(let string): let mention: NSAttributedString @@ -465,16 +499,22 @@ struct Contents: View { textParts.append(Text(AttributedString(substring))) } - case .custom_emoji(let string): - let image = UIImage(named: "amog")!.imageWith(newSize: CGSize(width: fontSize, height: fontSize)) - - textParts.append(Text(Image(uiImage: image))) + case .custom_emoji(let id): + if let emoji = viewState.emojis[id] { + let url = viewState.formatUrl(fromEmoji: emoji.id) + + let image = getImage(url: URL(string: url)!) + + textParts.append(Text(Image(uiImage: image))) + } else { + textParts.append(Text(verbatim: ":\(id):")) + } } } if textParts.count > 0 { let first = textParts.removeFirst() - return textParts.reduce(first, { a, b in a + b }) + return textParts.reduce(first, (+)) } else { return nil } diff --git a/Revolt/Components/EmojiPicker.swift b/Revolt/Components/EmojiPicker.swift index f127523..ee42c77 100644 --- a/Revolt/Components/EmojiPicker.swift +++ b/Revolt/Components/EmojiPicker.swift @@ -78,6 +78,68 @@ struct PickerEmojiCategory { var parent: PickerEmojiParent } +@MainActor +func loadEmojis(withState viewState: ViewState) -> OrderedDictionary { + let baseEmojis = try! JSONDecoder().decode([EmojiGroup].self, from: emojiPickerContent.data(using: .utf8)!) + + var emojis: OrderedDictionary = [:] + + for emoji in viewState.emojis.values { + if case .server(let id) = emoji.parent { + let server = viewState.servers[id.id]! + let parent = PickerEmojiParent.server(server) + let emoji = PickerEmoji( + base: [], + emojiId: emoji.id, + alternates: [], + emoticons: [], + shortcodes: [], + animated: emoji.animated ?? false, + directional: false + ) + + if emojis[parent] == nil { + emojis[parent] = [] + } + + emojis[parent]!.append(emoji) + } + } + + for category in baseEmojis { + let parent = PickerEmojiParent.unicode(category.group) + emojis[parent] = category.emoji + } + + return emojis +} + +#if os(iOS) +func convertEmojiToImage(text: String) -> UIImage { + let size = CGSize(width: 32, height: 32) + UIGraphicsBeginImageContextWithOptions(size, false, 0) + UIColor.clear.set() + let rect = CGRect(origin: CGPoint(), size: size) + UIRectFill(rect) + (text as NSString).draw(in: rect, withAttributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 30)]) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image! +} +#elseif os(macOS) +func convertEmojiToImage(text: String) -> NSImage { + let canvas = NSImage(size: NSSize(width: 32, height: 32)) + + let image = NSImage(size: NSSize(width: 32, height: 32), flipped: false) { rect in + canvas.draw(in: rect) + (text as NSString).draw(in: rect, withAttributes: [NSAttributedString.Key.font: NSFont.systemFont(ofSize: 30)]) + return true + } + + return image +} +#endif + struct EmojiPicker: View { @EnvironmentObject var viewState: ViewState var background: AnyView @@ -86,69 +148,8 @@ struct EmojiPicker: View { @State var scrollPosition: String? - func loadEmojis() -> OrderedDictionary { - let baseEmojis = try! JSONDecoder().decode([EmojiGroup].self, from: emojiPickerContent.data(using: .utf8)!) - - var emojis: OrderedDictionary = [:] - - for emoji in viewState.emojis.values { - if case .server(let id) = emoji.parent { - let server = viewState.servers[id.id]! - let parent = PickerEmojiParent.server(server) - let emoji = PickerEmoji( - base: [], - emojiId: emoji.id, - alternates: [], - emoticons: [], - shortcodes: [], - animated: emoji.animated ?? false, - directional: false - ) - - if emojis[parent] == nil { - emojis[parent] = [] - } - - emojis[parent]!.append(emoji) - } - } - - for category in baseEmojis { - let parent = PickerEmojiParent.unicode(category.group) - emojis[parent] = category.emoji - } - - return emojis - } - - #if os(iOS) - func convertEmojiToImage(text: String) -> UIImage { - let size = CGSize(width: 32, height: 32) - UIGraphicsBeginImageContextWithOptions(size, false, 0) - UIColor.clear.set() - let rect = CGRect(origin: CGPoint(), size: size) - UIRectFill(rect) - (text as NSString).draw(in: rect, withAttributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 30)]) - let image = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - return image! - } - #elseif os(macOS) - func convertEmojiToImage(text: String) -> NSImage { - let canvas = NSImage(size: NSSize(width: 32, height: 32)) - - let image = NSImage(size: NSSize(width: 32, height: 32), flipped: false) { rect in - canvas.draw(in: rect) - (text as NSString).draw(in: rect, withAttributes: [NSAttributedString.Key.font: NSFont.systemFont(ofSize: 30)]) - return true - } - - return image - } - #endif - var body: some View { - let emojis = loadEmojis() + let emojis = loadEmojis(withState: viewState) ZStack(alignment: .top) { ScrollView(.horizontal) { diff --git a/Revolt/Components/MessageBox.swift b/Revolt/Components/MessageBox.swift index 4456a5e..6ebedd5 100644 --- a/Revolt/Components/MessageBox.swift +++ b/Revolt/Components/MessageBox.swift @@ -70,11 +70,13 @@ struct MessageBox: View { enum AutocompleteType { case user case channel + case emoji } enum AutocompleteValues { case channels([Channel]) case users([(User, Member?)]) + case emojis([PickerEmoji]) } struct Photo: Identifiable, Hashable { @@ -165,7 +167,11 @@ struct MessageBox: View { users = viewState.members[server!.id]!.values.map({ m in (viewState.users[m.id.user]!, m) }) } - return AutocompleteValues.users(users) + return AutocompleteValues.users(users.filter { pair in + pair.0.display_name?.lowercased().starts(with: autocompleteSearchValue.lowercased()) + ?? pair.1?.nickname?.lowercased().starts(with: autocompleteSearchValue.lowercased()) + ?? pair.0.username.lowercased().starts(with: autocompleteSearchValue.lowercased()) + }) case .channel: let channels: [Channel] @@ -176,7 +182,24 @@ struct MessageBox: View { channels = server!.channels.compactMap({ viewState.channels[$0] }) } - return AutocompleteValues.channels(channels) + return AutocompleteValues.channels(channels.filter { channel in + channel.getName(viewState).lowercased().starts(with: autocompleteSearchValue.lowercased()) + }) + case .emoji: + return AutocompleteValues.emojis(loadEmojis(withState: viewState) + .values + .flatMap { $0 } + .filter { emoji in + let names: [String] + + if let emojiId = emoji.emojiId, let emoji = viewState.emojis[emojiId] { + names = [emoji.name] + } else { + names = emoji.alternates.prepending(emoji.base).map { String(String.UnicodeScalarView($0.compactMap(Unicode.Scalar.init))) } + } + + return names.contains(where: { $0.lowercased().starts(with: autocompleteSearchValue.lowercased()) }) + }) } } @@ -246,12 +269,12 @@ struct MessageBox: View { ForEach(users, id: \.0.id) { (user, member) in Button { withAnimation { - content = String(content.dropLast()) + content = String(content.dropLast(autocompleteSearchValue.count + 1)) content.append("<@\(user.id)>") autoCompleteType = nil } } label: { - HStack(spacing: 4) { + HStack(spacing: 8) { Avatar(user: user, member: member, width: 24, height: 24) Text(verbatim: member?.nickname ?? user.display_name ?? user.username) } @@ -264,7 +287,7 @@ struct MessageBox: View { ForEach(channels) { channel in Button { withAnimation { - content = String(content.dropLast()) + content = String(content.dropLast(autocompleteSearchValue.count + 1)) content.append("<#\(channel.id)>") autoCompleteType = nil } @@ -275,6 +298,50 @@ struct MessageBox: View { .background(viewState.theme.background2.color) .clipShape(RoundedRectangle(cornerRadius: 8)) } + case .emojis(let emojis): + ForEach(emojis) { emoji in + Button { + let emojiString: String + + if let emojiId = emoji.emojiId { + emojiString = ":\(emojiId):" + } else { + emojiString = String(String.UnicodeScalarView(emoji.base.compactMap(Unicode.Scalar.init))) + } + + withAnimation { + content = String(content.dropLast(autocompleteSearchValue.count + 1)) + content.append(emojiString) + autoCompleteType = nil + } + } label: { + HStack(spacing: 8) { + if let id = emoji.emojiId { + let emoji = viewState.emojis[id]! + + LazyImage(source: .emoji(id), height: 24, width: 24, clipTo: Rectangle()) + Text(verbatim: emoji.name) + } else { + let emojiString = String(String.UnicodeScalarView(emoji.base.compactMap(Unicode.Scalar.init))) + let image = convertEmojiToImage(text: emojiString) + +#if os(iOS) + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) +#elseif os(macOS) + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) +#endif + + Text(verbatim: emojiString) + } + } + } + } } } .frame(height: 42) @@ -325,6 +392,8 @@ struct MessageBox: View { autoCompleteType = .user case "#": autoCompleteType = .channel + case ":": + autoCompleteType = .emoji default: autoCompleteType = nil } diff --git a/Revolt/Extensions/UIImage.swift b/Revolt/Extensions/UIImage.swift index 4145ab3..f84ef77 100644 --- a/Revolt/Extensions/UIImage.swift +++ b/Revolt/Extensions/UIImage.swift @@ -8,11 +8,48 @@ import UIKit extension UIImage { - func imageWith(newSize: CGSize) -> UIImage { - let image = UIGraphicsImageRenderer(size: newSize).image { _ in - draw(in: CGRect(origin: .zero, size: newSize)) + enum ContentMode { + case contentFill + case contentAspectFill + case contentAspectFit + } + + func imageWith(newSize size: CGSize, contentMode: ContentMode) -> UIImage? { + let aspectWidth = size.width / self.size.width + let aspectHeight = size.height / self.size.height + + switch contentMode { + case .contentFill: + return imageWith(newSize: size) + case .contentAspectFit: + let aspectRatio = min(aspectWidth, aspectHeight) + return imageWith(newSize: CGSize(width: self.size.width * aspectRatio, height: self.size.height * aspectRatio)) + case .contentAspectFill: + let aspectRatio = max(aspectWidth, aspectHeight) + return imageWith(newSize: CGSize(width: self.size.width * aspectRatio, height: self.size.height * aspectRatio)) + } + } + + 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()! + } + + var roundedImage: UIImage { + let rect = CGRect(origin:CGPoint(x: 0, y: 0), size: self.size) + UIGraphicsBeginImageContextWithOptions(self.size, false, 1) + defer { + // End context after returning to avoid memory leak + UIGraphicsEndImageContext() } - return image + UIBezierPath( + roundedRect: rect, + cornerRadius: self.size.height + ).addClip() + self.draw(in: rect) + return UIGraphicsGetImageFromCurrentImageContext()! } } diff --git a/Revolt/Pages/MessageableChannel.swift b/Revolt/Pages/MessageableChannel.swift index c7dcb77..d363ef9 100644 --- a/Revolt/Pages/MessageableChannel.swift +++ b/Revolt/Pages/MessageableChannel.swift @@ -253,22 +253,7 @@ struct MessageableChannelView: View { .padding(.leading, 40) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12)) - - // .if(lastMessage.id == viewModel.messages.last, content: { - // $0.onAppear { - // let message = group.last!.message - // if var unread = viewState.unreads[viewModel.channel.id] { - // unread.last_id = lastMessage.id - // viewState.unreads[viewModel.channel.id] = unread - // } else { - // viewState.unreads[viewModel.channel.id] = Unread(id: Unread.Id(channel: viewModel.channel.id, user: viewState.currentUser!.id), last_id: lastMessage.id) - // } - // - // Task { - // await viewState.http.ackMessage(channel: viewModel.channel.id, message: message.id) - // } - // } - // }) + // // if lastMessage.id == viewState.unreads[viewModel.channel.id]?.last_id, lastMessage.id != viewModel.messages.last { // HStack(spacing: 0) { @@ -303,6 +288,12 @@ struct MessageableChannelView: View { ) .onPreferenceChange(VisibleKey.self) { isVisible in atBottom = isVisible + + if isVisible, let lastMessage = messages.last?.last { + Task { + await viewState.http.ackMessage(channel: viewModel.channel.id, message: lastMessage.message.id) + } + } } .onChange(of: messages) { (_, _) in withAnimation {