mirror of
https://github.com/stoatchat/for-ios.git
synced 2026-07-18 16:04:32 -04:00
588 lines
25 KiB
Swift
588 lines
25 KiB
Swift
//
|
|
// MessageBox.swift
|
|
// Revolt
|
|
//
|
|
// Created by Zomatree on 21/04/2023.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
import PhotosUI
|
|
import Types
|
|
|
|
struct Reply: Identifiable, Equatable {
|
|
var message: Message
|
|
var mention: Bool = false
|
|
|
|
var id: String { message.id }
|
|
}
|
|
|
|
struct ReplyView: View {
|
|
@EnvironmentObject var viewState: ViewState
|
|
|
|
@Binding var reply: Reply
|
|
|
|
@Binding var replies: [Reply]
|
|
|
|
var channel: Channel
|
|
var server: Server?
|
|
|
|
func remove() {
|
|
withAnimation {
|
|
replies.removeAll(where: { $0.id == reply.id })
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
let user = viewState.users[reply.message.author]!
|
|
let member = server.flatMap { viewState.members[$0.id]?[user.id] }
|
|
|
|
HStack(alignment: .center, spacing: 8) {
|
|
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?.name ?? member?.nickname ?? user.display_name ?? user.username)
|
|
.font(.caption)
|
|
.fixedSize()
|
|
.foregroundStyle(member?.displayColour(theme: viewState.theme, server: server!) ?? AnyShapeStyle(viewState.theme.foreground.color))
|
|
|
|
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) {
|
|
Contents(text: content, fontSize: 12)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
}
|
|
|
|
Button(action: { reply.mention.toggle() }) {
|
|
if reply.mention {
|
|
Text("@ on")
|
|
.foregroundColor(.accentColor)
|
|
} else {
|
|
Text("@ off")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
let data: Data
|
|
#if os(macOS)
|
|
let image: NSImage?
|
|
#else
|
|
let image: UIImage?
|
|
#endif
|
|
let id: UUID
|
|
let filename: String
|
|
}
|
|
|
|
@EnvironmentObject var viewState: ViewState
|
|
|
|
@Binding var channelReplies: [Reply]
|
|
var focusState: FocusState<Bool>.Binding
|
|
@Binding var showingSelectEmoji: Bool
|
|
@Binding var editing: Message?
|
|
|
|
@State var showingSelectFile = false
|
|
@State var showingSelectPhoto = false
|
|
|
|
@State var reshowKeyboard = false
|
|
|
|
@State var content = ""
|
|
|
|
@State var selectedPhotos: [Photo] = []
|
|
@State var selectedPhotoItems: [PhotosPickerItem] = []
|
|
@State var selectedEmoji: String = ""
|
|
|
|
@State var autoCompleteType: AutocompleteType? = nil
|
|
@State var autocompleteSearchValue: String = ""
|
|
|
|
let channel: Channel
|
|
let server: Server?
|
|
|
|
init(channel: Channel, server: Server?, channelReplies: Binding<[Reply]>, focusState f: FocusState<Bool>.Binding, showingSelectEmoji: Binding<Bool>, editing: Binding<Message?>) {
|
|
self.channel = channel
|
|
self.server = server
|
|
_channelReplies = channelReplies
|
|
focusState = f
|
|
_showingSelectEmoji = showingSelectEmoji
|
|
_editing = editing
|
|
|
|
if let msg = editing.wrappedValue {
|
|
content = msg.content ?? ""
|
|
}
|
|
}
|
|
|
|
func sendMessage() {
|
|
let c = content
|
|
content = ""
|
|
let replies = channelReplies
|
|
channelReplies = []
|
|
|
|
if let message = editing {
|
|
Task {
|
|
editing = nil
|
|
|
|
await viewState.http.editMessage(channel: channel.id, message: message.id, edits: MessageEdit(content: c))
|
|
}
|
|
|
|
} else {
|
|
let f = selectedPhotos.map({ ($0.data, $0.filename) })
|
|
selectedPhotos = []
|
|
|
|
Task {
|
|
await viewState.queueMessage(channel: channel.id, replies: replies, content: c, attachments: f)
|
|
}
|
|
}
|
|
}
|
|
|
|
func getAutocompleteValues(fromType type: AutocompleteType) -> AutocompleteValues {
|
|
switch type {
|
|
case .user:
|
|
let users: [(User, Member?)]
|
|
|
|
switch channel {
|
|
case .saved_messages(_):
|
|
users = [(viewState.currentUser!, nil)]
|
|
|
|
case .dm_channel(let dMChannel):
|
|
users = dMChannel.recipients.map({ (viewState.users[$0]!, nil) })
|
|
|
|
case .group_dm_channel(let groupDMChannel):
|
|
users = groupDMChannel.recipients.map({ (viewState.users[$0]!, nil) })
|
|
|
|
case .text_channel(_), .voice_channel(_):
|
|
users = viewState.members[server!.id]!.values.map({ m in (viewState.users[m.id.user]!, m) })
|
|
}
|
|
|
|
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]
|
|
|
|
switch channel {
|
|
case .saved_messages(_), .dm_channel(_), .group_dm_channel(_):
|
|
channels = [channel]
|
|
case .text_channel(_), .voice_channel(_):
|
|
channels = server!.channels.compactMap({ viewState.channels[$0] })
|
|
}
|
|
|
|
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 {
|
|
var values = emoji.alternates
|
|
values.append(emoji.base)
|
|
names = values.map { String(String.UnicodeScalarView($0.compactMap(Unicode.Scalar.init))) }
|
|
}
|
|
|
|
return names.contains(where: { $0.lowercased().starts(with: autocompleteSearchValue.lowercased()) })
|
|
})
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
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) {
|
|
HStack {
|
|
ForEach($selectedPhotos, id: \.self) { file in
|
|
let file = file.wrappedValue
|
|
|
|
ZStack(alignment: .topTrailing) {
|
|
if let image = file.image {
|
|
#if os(iOS)
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(maxWidth: 100, maxHeight: 100)
|
|
.clipShape(RoundedRectangle(cornerRadius: 5.0, style: .circular))
|
|
#else
|
|
Image(nsImage: image)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(maxWidth: 100, maxHeight: 100)
|
|
.clipShape(RoundedRectangle(cornerRadius: 5.0, style: .circular))
|
|
#endif
|
|
} else {
|
|
ZStack {
|
|
Rectangle()
|
|
.frame(width: 100, height: 100)
|
|
.foregroundStyle(viewState.theme.background.color)
|
|
.clipShape(RoundedRectangle(cornerRadius: 5.0, style: .circular))
|
|
|
|
Text(verbatim: file.filename)
|
|
.font(.caption)
|
|
.foregroundStyle(viewState.theme.foreground.color)
|
|
}
|
|
}
|
|
Button(action: { selectedPhotos.removeAll(where: { $0.id == file.id }) }) {
|
|
Image(systemName: "xmark.app.fill")
|
|
.resizable()
|
|
.foregroundStyle(.gray)
|
|
.symbolRenderingMode(.hierarchical)
|
|
.opacity(0.9)
|
|
.frame(width: 16, height: 16)
|
|
.frame(width: 24, height: 24)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let type = autoCompleteType {
|
|
let values = getAutocompleteValues(fromType: type)
|
|
|
|
ScrollView(.horizontal) {
|
|
LazyHStack {
|
|
switch values {
|
|
case .users(let users):
|
|
ForEach(users, id: \.0.id) { (user, member) in
|
|
Button {
|
|
withAnimation {
|
|
content = String(content.dropLast(autocompleteSearchValue.count + 1))
|
|
content.append("<@\(user.id)>")
|
|
autoCompleteType = nil
|
|
}
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Avatar(user: user, member: member, width: 24, height: 24)
|
|
Text(verbatim: member?.nickname ?? user.display_name ?? user.username)
|
|
}
|
|
.padding(6)
|
|
}
|
|
.background(viewState.theme.background2.color)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
case .channels(let channels):
|
|
ForEach(channels) { channel in
|
|
Button {
|
|
withAnimation {
|
|
content = String(content.dropLast(autocompleteSearchValue.count + 1))
|
|
content.append("<#\(channel.id)>")
|
|
autoCompleteType = nil
|
|
}
|
|
} label: {
|
|
ChannelIcon(channel: channel)
|
|
.padding(6)
|
|
}
|
|
.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)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
if editing != nil {
|
|
Button {
|
|
editing = nil
|
|
content = ""
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "pencil")
|
|
.foregroundStyle(viewState.theme.accent)
|
|
|
|
Text("Editing Message")
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "xmark")
|
|
.foregroundStyle(viewState.theme.foreground2)
|
|
}
|
|
.bold()
|
|
}
|
|
}
|
|
|
|
HStack(alignment: .top) {
|
|
if editing == nil {
|
|
UploadButton(showingSelectFile: $showingSelectFile, showingSelectPhoto: $showingSelectPhoto, selectedPhotoItems: $selectedPhotoItems, selectedPhotos: $selectedPhotos)
|
|
.frame(alignment: .top)
|
|
}
|
|
|
|
TextField("", text: $content.animation(), axis: .vertical)
|
|
.focused(focusState)
|
|
.placeholder(when: content.isEmpty) {
|
|
Text("Message #\(channel.getName(viewState))")
|
|
.foregroundStyle(viewState.theme.foreground2.color)
|
|
}
|
|
.onChange(of: content) { _, value in
|
|
withAnimation {
|
|
if let last = value.split(separator: " ").last {
|
|
let pre = last.first
|
|
autocompleteSearchValue = String(last[last.index(last.startIndex, offsetBy: 1)...])
|
|
|
|
switch pre {
|
|
case "@":
|
|
autoCompleteType = .user
|
|
case "#":
|
|
autoCompleteType = .channel
|
|
case ":":
|
|
autoCompleteType = .emoji
|
|
default:
|
|
autoCompleteType = nil
|
|
}
|
|
} else {
|
|
autoCompleteType = nil
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: focusState.wrappedValue, { _, v in
|
|
if v, showingSelectEmoji {
|
|
withAnimation {
|
|
showingSelectEmoji = false
|
|
}
|
|
}
|
|
})
|
|
.onChange(of: showingSelectEmoji, { b, a in
|
|
if b, !a {
|
|
withAnimation {
|
|
focusState.wrappedValue = true
|
|
}
|
|
}
|
|
})
|
|
.onChange(of: editing, { b, a in
|
|
if let a {
|
|
selectedPhotos = []
|
|
selectedPhotoItems = []
|
|
autoCompleteType = nil
|
|
autocompleteSearchValue = ""
|
|
content = a.content ?? ""
|
|
} else {
|
|
channelReplies = []
|
|
content = ""
|
|
}
|
|
})
|
|
.sheet(isPresented: $showingSelectEmoji) {
|
|
EmojiPicker(background: AnyView(viewState.theme.background)) { emoji in
|
|
if let id = emoji.emojiId {
|
|
content.append(":\(id):")
|
|
} else {
|
|
content.append(String(String.UnicodeScalarView(emoji.base.compactMap(Unicode.Scalar.init))))
|
|
}
|
|
|
|
showingSelectEmoji = false
|
|
}
|
|
.padding([.top, .horizontal])
|
|
.background(viewState.theme.background.ignoresSafeArea(.all))
|
|
.presentationDetents([.large])
|
|
}
|
|
|
|
Group {
|
|
Button {
|
|
withAnimation {
|
|
focusState.wrappedValue = false
|
|
showingSelectEmoji.toggle()
|
|
}
|
|
} label: {
|
|
Image(systemName: "face.smiling")
|
|
.resizable()
|
|
.frame(width: 24, height: 24)
|
|
.foregroundStyle(viewState.theme.foreground3.color)
|
|
}
|
|
|
|
|
|
if !content.isEmpty || !selectedPhotos.isEmpty {
|
|
Button(action: sendMessage) {
|
|
Image(systemName: "arrow.up.circle.fill")
|
|
.resizable()
|
|
.frame(width: 24, height: 24)
|
|
.foregroundStyle(viewState.theme.foreground3.color)
|
|
}
|
|
}
|
|
}
|
|
.frame(alignment: .top)
|
|
}
|
|
}
|
|
.padding(.top, 8)
|
|
}
|
|
.padding(.top, 4)
|
|
.padding(.horizontal, 12)
|
|
.padding(.bottom, 12)
|
|
.background(viewState.theme.messageBox.color)
|
|
}
|
|
}
|
|
|
|
struct UploadButton: View {
|
|
@EnvironmentObject var viewState: ViewState
|
|
|
|
@Binding var showingSelectFile: Bool
|
|
@Binding var showingSelectPhoto: Bool
|
|
@Binding var selectedPhotoItems: [PhotosPickerItem]
|
|
@Binding var selectedPhotos: [MessageBox.Photo]
|
|
|
|
func onFileCompletion(res: Result<URL, Error>) {
|
|
if case .success(let url) = res, url.startAccessingSecurityScopedResource() {
|
|
let data = try? Data(contentsOf: url)
|
|
url.stopAccessingSecurityScopedResource()
|
|
|
|
guard let data = data else { return }
|
|
|
|
#if os(macOS)
|
|
let image = NSImage(data: data)
|
|
#else
|
|
let image = UIImage(data: data)
|
|
#endif
|
|
|
|
selectedPhotos.append(.init(data: data, image: image, id: UUID(), filename: url.lastPathComponent))
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
Image(systemName: "plus")
|
|
.resizable()
|
|
.foregroundStyle(viewState.theme.foreground3.color)
|
|
.frame(width: 16, height: 16)
|
|
.frame(width: 20, height: 20)
|
|
|
|
.photosPicker(isPresented: $showingSelectPhoto, selection: $selectedPhotoItems)
|
|
.photosPickerStyle(.presentation)
|
|
|
|
.fileImporter(isPresented: $showingSelectFile, allowedContentTypes: [.item], onCompletion: onFileCompletion)
|
|
|
|
.onTapGesture {
|
|
showingSelectPhoto = true
|
|
}
|
|
.contextMenu {
|
|
Button(action: {
|
|
showingSelectFile = true
|
|
}) {
|
|
Text("Select File")
|
|
}
|
|
Button(action: {
|
|
showingSelectPhoto = true
|
|
}) {
|
|
Text("Select Photo")
|
|
}
|
|
}
|
|
.onChange(of: selectedPhotoItems) { before, after in
|
|
if after.isEmpty { return }
|
|
Task {
|
|
for item in after {
|
|
if let data = try? await item.loadTransferable(type: Data.self) {
|
|
#if os(macOS)
|
|
let img = NSImage(data: data)
|
|
#else
|
|
let img = UIImage(data: data)
|
|
#endif
|
|
|
|
if let img = img {
|
|
let fileType = item.supportedContentTypes[0].preferredFilenameExtension!
|
|
let fileName = (item.itemIdentifier ?? "Image") + ".\(fileType)"
|
|
selectedPhotos.append(.init(data: data, image: img, id: UUID(), filename: fileName))
|
|
}
|
|
}
|
|
}
|
|
selectedPhotoItems.removeAll()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MessageBox_Previews: PreviewProvider {
|
|
static var viewState: ViewState = ViewState.preview().applySystemScheme(theme: .dark)
|
|
@State static var replies: [Reply] = []
|
|
@State static var showingSelectEmoji = false
|
|
@FocusState static var focused: Bool
|
|
|
|
static var previews: some View {
|
|
let channel = viewState.channels["0"]!
|
|
let server = viewState.servers["0"]!
|
|
|
|
MessageBox(channel: channel, server: server, channelReplies: $replies, focusState: $focused, showingSelectEmoji: $showingSelectEmoji, editing: .constant(nil))
|
|
.applyPreviewModifiers(withState: viewState)
|
|
}
|
|
}
|