Skip to content

Commit

Permalink
feat: Emoji rendering and autocomplete
Browse files Browse the repository at this point in the history
  • Loading branch information
Zomatree committed Jul 20, 2024
1 parent a723e77 commit ae8d71c
Show file tree
Hide file tree
Showing 5 changed files with 238 additions and 100 deletions.
66 changes: 53 additions & 13 deletions Revolt/Components/Contents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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
}
Expand Down
125 changes: 63 additions & 62 deletions Revolt/Components/EmojiPicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,68 @@ struct PickerEmojiCategory {
var parent: PickerEmojiParent
}

@MainActor
func loadEmojis(withState viewState: ViewState) -> OrderedDictionary<PickerEmojiParent, [PickerEmoji]> {
let baseEmojis = try! JSONDecoder().decode([EmojiGroup].self, from: emojiPickerContent.data(using: .utf8)!)

var emojis: OrderedDictionary<PickerEmojiParent, [PickerEmoji]> = [:]

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
Expand All @@ -86,69 +148,8 @@ struct EmojiPicker: View {

@State var scrollPosition: String?

func loadEmojis() -> OrderedDictionary<PickerEmojiParent, [PickerEmoji]> {
let baseEmojis = try! JSONDecoder().decode([EmojiGroup].self, from: emojiPickerContent.data(using: .utf8)!)

var emojis: OrderedDictionary<PickerEmojiParent, [PickerEmoji]> = [:]

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) {
Expand Down
79 changes: 74 additions & 5 deletions Revolt/Components/MessageBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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]

Expand All @@ -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()) })
})
}
}

Expand Down Expand Up @@ -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)
}
Expand All @@ -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
}
Expand All @@ -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)
Expand Down Expand Up @@ -325,6 +392,8 @@ struct MessageBox: View {
autoCompleteType = .user
case "#":
autoCompleteType = .channel
case ":":
autoCompleteType = .emoji
default:
autoCompleteType = nil
}
Expand Down
Loading

0 comments on commit ae8d71c

Please sign in to comment.