diff --git a/Swiftcord.xcodeproj/project.pbxproj b/Swiftcord.xcodeproj/project.pbxproj index 0651e213..e8d89316 100644 --- a/Swiftcord.xcodeproj/project.pbxproj +++ b/Swiftcord.xcodeproj/project.pbxproj @@ -62,6 +62,7 @@ DA54D5762844B9C500B11857 /* CurrentUser+.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA54D5752844B9C500B11857 /* CurrentUser+.swift */; }; DA54D5782844DA1400B11857 /* UserSettingsProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA54D5772844DA1400B11857 /* UserSettingsProfileView.swift */; }; DA54D57A2844E41A00B11857 /* ProfileBadges.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA54D5792844E41A00B11857 /* ProfileBadges.swift */; }; + DA54D57C2845C36E00B11857 /* MessagesView+.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA54D57B2845C36E00B11857 /* MessagesView+.swift */; }; DA57F44428056718001DC46E /* ChannelList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA57F44328056718001DC46E /* ChannelList.swift */; }; DA57F44628065209001DC46E /* ChannelButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA57F44528065209001DC46E /* ChannelButton.swift */; }; DA585C9927E1F6AC00FA4EE0 /* View+.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA585C9827E1F6AC00FA4EE0 /* View+.swift */; }; @@ -142,6 +143,7 @@ DA54D5752844B9C500B11857 /* CurrentUser+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CurrentUser+.swift"; sourceTree = ""; }; DA54D5772844DA1400B11857 /* UserSettingsProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsProfileView.swift; sourceTree = ""; }; DA54D5792844E41A00B11857 /* ProfileBadges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileBadges.swift; sourceTree = ""; }; + DA54D57B2845C36E00B11857 /* MessagesView+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessagesView+.swift"; sourceTree = ""; }; DA57F44328056718001DC46E /* ChannelList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelList.swift; sourceTree = ""; }; DA57F44528065209001DC46E /* ChannelButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelButton.swift; sourceTree = ""; }; DA585C9827E1F6AC00FA4EE0 /* View+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+.swift"; sourceTree = ""; }; @@ -360,6 +362,7 @@ E7AF1C35282FC2E8001F78DF /* NSTextView+.swift */, DA520ACA27D4A23A009FD740 /* String+.swift */, DA585C9827E1F6AC00FA4EE0 /* View+.swift */, + DA54D57B2845C36E00B11857 /* MessagesView+.swift */, ); path = Extensions; sourceTree = ""; @@ -629,6 +632,7 @@ DA32EF5027C8D7E000A9ED72 /* Message+.swift in Sources */, DAAFB5C5282AB37500807B54 /* MediaControllerView.swift in Sources */, DAAFB5CC282B879200807B54 /* Double+.swift in Sources */, + DA54D57C2845C36E00B11857 /* MessagesView+.swift in Sources */, DA520AC927D3A55D009FD740 /* SettingsView.swift in Sources */, DA32EF2827C633FE00A9ED72 /* UserAvatarView.swift in Sources */, DA585C9927E1F6AC00FA4EE0 /* View+.swift in Sources */, diff --git a/Swiftcord.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Swiftcord.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6063990f..a624a6b1 100644 --- a/Swiftcord.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Swiftcord.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "location" : "https://github.com/SwiftcordApp/DiscordKit", "state" : { "branch" : "main", - "revision" : "ab5add39584f2cd1304ad08675422d294b11067e" + "revision" : "c32383146fc45e6917a4e3b646efba1e0622240d" } }, { diff --git a/Swiftcord/SwiftcordApp.swift b/Swiftcord/SwiftcordApp.swift index fa95047f..bd575550 100644 --- a/Swiftcord/SwiftcordApp.swift +++ b/Swiftcord/SwiftcordApp.swift @@ -9,7 +9,7 @@ import DiscordKit import SwiftUI @main -struct SwiftcordApp: App { +struct SwiftcordApp: App, Equatable { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate let persistenceController = PersistenceController.shared @StateObject var updaterViewModel = UpdaterViewModel() @@ -42,4 +42,8 @@ struct SwiftcordApp: App { .environmentObject(state) } } + + static func == (lhs: SwiftcordApp, rhs: SwiftcordApp) -> Bool { + lhs.gateway == rhs.gateway && lhs.state == rhs.state + } } diff --git a/Swiftcord/Utils/Extensions/MessagesView+.swift b/Swiftcord/Utils/Extensions/MessagesView+.swift new file mode 100644 index 00000000..566c6904 --- /dev/null +++ b/Swiftcord/Utils/Extensions/MessagesView+.swift @@ -0,0 +1,102 @@ +// +// MessagesView+.swift +// Swiftcord +// +// Created by Vincent Kwok on 31/5/22. +// + +import Foundation +import DiscordKit + +extension MessagesView { + internal func fetchMoreMessages() { + guard let channel = ctx.channel else { return } + if let oldTask = fetchMessagesTask { + oldTask.cancel() + fetchMessagesTask = nil + } + + if loadError { showingInfoBar = false } + loadError = false + + fetchMessagesTask = Task { + let lastMsg = messages.isEmpty ? nil : messages[messages.count - 1].id + + guard let newMessages = await DiscordAPI.getChannelMsgs( + id: channel.id, + before: lastMsg + ) else { + try Task.checkCancellation() // Check if the task is cancelled before continuing + + fetchMessagesTask = nil + loadError = true + showingInfoBar = true + infoBarData = InfoBarData( + message: "Messages failed to load", + buttonLabel: "Try again", + color: .red, + buttonIcon: "arrow.clockwise", + clickHandler: { fetchMoreMessages() } + ) + state.loadingState = .messageLoad + return + } + state.loadingState = .messageLoad + try Task.checkCancellation() + + reachedTop = newMessages.count < 50 + messages.append(contentsOf: newMessages) + fetchMessagesTask = nil + } + } + + internal func sendMessage(with message: String, attachments: [URL]) { + lastSentTyping = Date(timeIntervalSince1970: 0) + newMessage = "" + showingInfoBar = false + Task { + guard (await DiscordAPI.createChannelMsg( + message: NewMessage( + content: message, + attachments: attachments.isEmpty ? nil : attachments.enumerated() + .map { (idx, attachment) in + NewAttachment( + id: String(idx), + filename: try! attachment.resourceValues(forKeys: [URLResourceKey.nameKey]).name! + ) + } + ), + attachments: attachments, + id: ctx.channel!.id + )) != nil else { + showingInfoBar = true + infoBarData = InfoBarData( + message: "Could not send message", + buttonLabel: "Try again", + color: .red, + buttonIcon: "arrow.clockwise", + clickHandler: { sendMessage(with: message, attachments: attachments) } + ) + return + } + } + } + + internal func preAttachChecks(for attachment: URL) -> Bool { + guard let size = try? attachment.resourceValues(forKeys: [URLResourceKey.fileSizeKey]).fileSize, size < 8*1024*1024 else { + newAttachmentErr = NewAttachmentError( + title: "Your files are too powerful", + message: "The max file size is 8MB." + ) + return false + } + guard attachments.count <= 10 else { + newAttachmentErr = NewAttachmentError( + title: "Too many uploads!", + message: "You can only upload 10 files at a time!" + ) + return false + } + return true + } +} diff --git a/Swiftcord/Views/ContentView.swift b/Swiftcord/Views/ContentView.swift index e2a0a5bd..7db2ce86 100644 --- a/Swiftcord/Views/ContentView.swift +++ b/Swiftcord/Views/ContentView.swift @@ -176,7 +176,7 @@ struct ContentView: View { } _ = gateway.socket.onSessionInvalid.addHandler { state.loadingState = .initial } } - } + } /*private func addItem() { withAnimation { diff --git a/Swiftcord/Views/EnvObjects/UIStateEnv.swift b/Swiftcord/Views/EnvObjects/UIStateEnv.swift index 5c86ba9b..0d57a9a3 100644 --- a/Swiftcord/Views/EnvObjects/UIStateEnv.swift +++ b/Swiftcord/Views/EnvObjects/UIStateEnv.swift @@ -13,9 +13,16 @@ enum LoadingState { case messageLoad } -class UIState: ObservableObject { +class UIState: ObservableObject, Equatable { @Published var loadingState: LoadingState = .initial @Published var attemptLogin = false @Published var selfMute = false - @Published var selfDeaf = false + @Published var selfDeaf = false + + static func == (lhs: UIState, rhs: UIState) -> Bool { + return lhs.loadingState == rhs.loadingState && + lhs.attemptLogin == rhs.attemptLogin && + lhs.selfMute == rhs.selfMute && + lhs.selfDeaf == rhs.selfDeaf + } } diff --git a/Swiftcord/Views/Message/AttachmentView.swift b/Swiftcord/Views/Message/AttachmentView.swift index 70b7fb8d..04de62df 100644 --- a/Swiftcord/Views/Message/AttachmentView.swift +++ b/Swiftcord/Views/Message/AttachmentView.swift @@ -129,7 +129,10 @@ struct AttachmentView: View { "application/json": "doc.text", // Archives "application/gzip": "doc.zipper", - "application/zip": "doc.zipper" + "application/zip": "doc.zipper", + // Videos + "video/mp4": "film", + "video/quicktime": "film" ] /// Resizes image dimensions the way the official client does diff --git a/Swiftcord/Views/Message/MessageInputView.swift b/Swiftcord/Views/Message/MessageInputView.swift index 8f250c52..509b8efa 100644 --- a/Swiftcord/Views/Message/MessageInputView.swift +++ b/Swiftcord/Views/Message/MessageInputView.swift @@ -53,11 +53,12 @@ struct MessageAttachmentView: View { struct MessageInputView: View { let placeholder: String @Binding var message: String - @State private var attachments: [URL] = [] + @Binding var attachments: [URL] @State private var inhibitingSend = false @State private var showingAttachmentErr = false @State private var attachmentErr = "" let onSend: (String, [URL]) -> Void + let preAttach: (URL) -> Bool private func send() { guard message.hasContent() || !attachments.isEmpty else { return } @@ -72,6 +73,7 @@ struct MessageInputView: View { HStack { ForEach(attachments.indices, id: \.self) { idx in MessageAttachmentView(attachment: attachments[idx]) { + guard idx < attachments.count else { return } withAnimation { _ = attachments.remove(at: idx) } } } @@ -86,26 +88,14 @@ struct MessageInputView: View { panel.allowsMultipleSelection = false panel.canChooseDirectories = false panel.treatsFilePackagesAsDirectories = true - panel.beginSheetModal(for: NSApp.mainWindow!, completionHandler: { num in + panel.beginSheetModal(for: NSApp.mainWindow!) { num in if num == NSApplication.ModalResponse.OK { - guard let size = try? panel.url?.resourceValues(forKeys: [URLResourceKey.fileSizeKey]).fileSize, size < 8*1024*1024 else { - attachmentErr = "That file's too huge! Choose something that's <= 8MiB." - showingAttachmentErr = true - return - } - - guard !attachments.contains(panel.url!) else { - attachmentErr = "You've already selected that file" - showingAttachmentErr = true - return + if let fileURL = panel.url, preAttach(fileURL) { + withAnimation { attachments.append(fileURL) } } - withAnimation { attachments.append(panel.url!) } } - }) + } } label: { Image(systemName: "plus.circle.fill").font(.system(size: 20)).opacity(0.75) } - .alert(attachmentErr, isPresented: $showingAttachmentErr) { - Button("Got It!", role: .cancel) { } - } .buttonStyle(.plain) .padding(.leading, 18) diff --git a/Swiftcord/Views/Message/MessagesView.swift b/Swiftcord/Views/Message/MessagesView.swift index a3b19577..0d2447b7 100644 --- a/Swiftcord/Views/Message/MessagesView.swift +++ b/Swiftcord/Views/Message/MessagesView.swift @@ -17,6 +17,12 @@ extension View { } } +struct NewAttachmentError: Identifiable { + var id: String { title + message } + let title: String + let message: String +} + struct MessagesViewHeader: View { let chl: Channel? @@ -72,17 +78,19 @@ struct MessagesViewHeader: View { struct MessagesView: View, Equatable { static func == (lhs: MessagesView, rhs: MessagesView) -> Bool { - lhs.messages == rhs.messages + lhs.messages == rhs.messages && lhs.attachments == rhs.attachments } - @State private var reachedTop = false - @State private var messages: [Message] = [] - @State private var enteredText = " " - @State private var showingInfoBar = false - @State private var loadError = false - @State private var infoBarData: InfoBarData? - @State private var fetchMessagesTask: Task<(), Error>? - @State private var lastSentTyping = Date(timeIntervalSince1970: 0) + @State internal var reachedTop = false + @State internal var messages: [Message] = [] + @State internal var newMessage = " " + @State internal var attachments: [URL] = [] + @State internal var showingInfoBar = false + @State internal var loadError = false + @State internal var infoBarData: InfoBarData? + @State internal var fetchMessagesTask: Task<(), Error>? + @State internal var lastSentTyping = Date(timeIntervalSince1970: 0) + @State internal var newAttachmentErr: NewAttachmentError? @State private var messageInputHeight = 0.0 @State private var dropOver = false @@ -93,80 +101,6 @@ struct MessagesView: View, Equatable { // Gateway @State private var evtID: EventDispatch.HandlerIdentifier? - private func fetchMoreMessages() { - guard let channel = ctx.channel else { return } - if let oldTask = fetchMessagesTask { - oldTask.cancel() - fetchMessagesTask = nil - } - - if loadError { showingInfoBar = false } - loadError = false - - fetchMessagesTask = Task { - let lastMsg = messages.isEmpty ? nil : messages[messages.count - 1].id - - guard let newMessages = await DiscordAPI.getChannelMsgs( - id: channel.id, - before: lastMsg - ) else { - try Task.checkCancellation() // Check if the task is cancelled before continuing - - fetchMessagesTask = nil - loadError = true - showingInfoBar = true - infoBarData = InfoBarData( - message: "Messages failed to load", - buttonLabel: "Try again", - color: .red, - buttonIcon: "arrow.clockwise", - clickHandler: { fetchMoreMessages() } - ) - state.loadingState = .messageLoad - return - } - state.loadingState = .messageLoad - try Task.checkCancellation() - - reachedTop = newMessages.count < 50 - messages.append(contentsOf: newMessages) - fetchMessagesTask = nil - } - } - - private func sendMessage(content: String, attachments: [URL]) { - lastSentTyping = Date(timeIntervalSince1970: 0) - enteredText = "" - showingInfoBar = false - Task { - guard (await DiscordAPI.createChannelMsg( - message: NewMessage( - content: content, - attachments: attachments.isEmpty ? nil : attachments.enumerated() - .map { (idx, attachment) in - NewAttachment( - id: String(idx), - filename: try! attachment.resourceValues(forKeys: [URLResourceKey.nameKey]).name! - ) - } - ), - attachments: attachments, - id: ctx.channel!.id - )) != nil else { - enteredText = content.trimmingCharacters(in: .newlines) // Message failed to send - showingInfoBar = true - infoBarData = InfoBarData( - message: "Could not send message", - buttonLabel: "Try again", - color: .red, - buttonIcon: "arrow.clockwise", - clickHandler: { sendMessage(content: enteredText, attachments: attachments) } - ) - return - } - } - } - var body: some View { ZStack(alignment: .bottom) { ScrollView(.vertical) { @@ -244,11 +178,12 @@ struct MessagesView: View, Equatable { MessageInputView( placeholder: "Message \(ctx.channel?.type == .text ? "#" : "")\(ctx.channel?.label(gateway.cache.users) ?? "")", - message: $enteredText, onSend: sendMessage + message: $newMessage, attachments: $attachments, + onSend: sendMessage, preAttach: preAttachChecks ) - .onAppear { enteredText = "" } - .onChange(of: enteredText) { [enteredText] content in - if content.count > enteredText.count, + .onAppear { newMessage = "" } + .onChange(of: newMessage) { [newMessage] content in + if content.count > newMessage.count, Date().timeIntervalSince(lastSentTyping) > 8 { // Send typing start msg once every 8s while typing lastSentTyping = Date() @@ -291,6 +226,33 @@ struct MessagesView: View, Equatable { } } .frame(minWidth: 525) + .blur(radius: dropOver ? 24 : 0) + .overlay { + if dropOver { + ZStack { + VStack(spacing: 24) { + Image(systemName: "paperclip") + .font(.system(size: 64)) + .foregroundColor(.accentColor) + Text("Drop file to add attachment").font(.largeTitle) + } + Rectangle() + .stroke(style: StrokeStyle(lineWidth: 10, lineCap: .round, dash: [25, 20])) + .opacity(0.75) + }.padding(24) + } + } + .animation(.easeOut(duration: 0.25), value: dropOver) + .onDrop(of: [.fileURL], isTargeted: $dropOver) { providers -> Bool in + for provider in providers { + _ = provider.loadObject(ofClass: URL.self) { itemURL, err in + if let itemURL = itemURL, preAttachChecks(for: itemURL) { + attachments.append(itemURL) + } + } + } + return true + } .onChange(of: ctx.channel, perform: { channel in guard channel != nil else { return } messages = [] @@ -351,5 +313,12 @@ struct MessagesView: View, Equatable { } }) } + .alert(item: $newAttachmentErr) { err in + Alert( + title: Text(err.title), + message: Text(err.message), + dismissButton: .cancel(Text("Got It!")) + ) + } } }