diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ffded0e9..2e78c401f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unversioned +- Major: Added image upload functionality to i.nuuls.com. This works by dragging and dropping an image into a split, or pasting an image into the text edit field. (#1332) - Minor: Emotes in the emote popup are now sorted in the same order as the tab completion (#1549) - Minor: Removed "Online Logs" functionality as services are shut down (#1640) - Bugfix: Fix preview on hover not working when Animated emotes options was disabled (#1546) diff --git a/chatterino.pro b/chatterino.pro index 849b48e5b..883089dab 100644 --- a/chatterino.pro +++ b/chatterino.pro @@ -217,6 +217,7 @@ SOURCES += \ src/util/JsonQuery.cpp \ src/util/RapidjsonHelpers.cpp \ src/util/StreamLink.cpp \ + src/util/NuulsUploader.cpp \ src/util/WindowsHelper.cpp \ src/widgets/AccountSwitchPopup.cpp \ src/widgets/AccountSwitchWidget.cpp \ @@ -438,6 +439,7 @@ HEADERS += \ src/util/Shortcut.hpp \ src/util/StandardItemHelper.hpp \ src/util/StreamLink.hpp \ + src/util/NuulsUploader.hpp \ src/util/WindowsHelper.hpp \ src/widgets/AccountSwitchPopup.hpp \ src/widgets/AccountSwitchWidget.hpp \ diff --git a/docs/ENV.md b/docs/ENV.md index f95c74286..1615009e6 100644 --- a/docs/ENV.md +++ b/docs/ENV.md @@ -19,6 +19,17 @@ Default value: `https://braize.pajlada.com/chatterino/twitchemotes/set/%1/` Arguments: - `%1` = Emote set ID +### CHATTERINO2_IMAGE_UPLOADER_URL +Used to change the URL that Chatterino2 uses when trying to paste an image into chat. This can be used for hosting the uploaded images yourself. +Default value: `https://i.nuuls.com/upload` + +Arguments: + - None + +Notes: + - If you want to host the images yourself. You need [Nuuls' filehost software](https://github.com/nuuls/filehost) + - Other image hosting software is currently not supported. + ### CHATTERINO2_TWITCH_SERVER_HOST String value used to change what Twitch chat server host to connect to. Default value: `irc.chat.twitch.tv` diff --git a/src/common/Env.cpp b/src/common/Env.cpp index 1fa819c3d..c1e550d7c 100644 --- a/src/common/Env.cpp +++ b/src/common/Env.cpp @@ -57,6 +57,8 @@ Env::Env() , twitchEmoteSetResolverUrl(readStringEnv( "CHATTERINO2_TWITCH_EMOTE_SET_RESOLVER_URL", "https://braize.pajlada.com/chatterino/twitchemotes/set/%1/")) + , imageUploaderUrl(readStringEnv("CHATTERINO2_IMAGE_UPLOADER_URL", + "https://i.nuuls.com/upload")) , twitchServerHost( readStringEnv("CHATTERINO2_TWITCH_SERVER_HOST", "irc.chat.twitch.tv")) , twitchServerPort(readPortEnv("CHATTERINO2_TWITCH_SERVER_PORT", 443)) diff --git a/src/common/Env.hpp b/src/common/Env.hpp index 6dc8077a9..29c17b6ad 100644 --- a/src/common/Env.hpp +++ b/src/common/Env.hpp @@ -14,6 +14,7 @@ public: const QString recentMessagesApiUrl; const QString linkResolverUrl; const QString twitchEmoteSetResolverUrl; + const QString imageUploaderUrl; const QString twitchServerHost; const uint16_t twitchServerPort; const bool twitchServerSecure; diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 1d56e5787..11927120a 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -291,6 +291,7 @@ public: "/misc/attachExtensionToAnyProcess", false}; BoolSetting hideViewerCountAndDuration = { "/misc/hideViewerCountAndDuration", false}; + BoolSetting askOnImageUpload = {"/misc/askOnImageUpload", true}; /// Debug BoolSetting showUnhandledIrcMessages = {"/debug/showUnhandledIrcMessages", diff --git a/src/util/NuulsUploader.cpp b/src/util/NuulsUploader.cpp new file mode 100644 index 000000000..1678ff474 --- /dev/null +++ b/src/util/NuulsUploader.cpp @@ -0,0 +1,216 @@ +#include "NuulsUploader.hpp" + +#include "common/Env.hpp" +#include "common/NetworkRequest.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" + +#include +#include +#include +#include + +#define UPLOAD_DELAY 2000 +// Delay between uploads in milliseconds + +namespace { + +boost::optional convertToPng(QImage image) +{ + QByteArray imageData; + QBuffer buf(&imageData); + buf.open(QIODevice::WriteOnly); + bool success = image.save(&buf, "png"); + if (success) + { + return boost::optional(imageData); + } + else + { + return boost::optional(boost::none); + } +} +} // namespace + +namespace chatterino { +// These variables are only used from the main thread. +static auto uploadMutex = QMutex(); +static std::queue uploadQueue; + +void uploadImageToNuuls(RawImageData imageData, ChannelPtr channel, + ResizingTextEdit &textEdit) +{ + const static char *const boundary = "thisistheboudaryasd"; + const static QString contentType = + QString("multipart/form-data; boundary=%1").arg(boundary); + static QUrl url(Env::get().imageUploaderUrl); + + QHttpMultiPart *payload = new QHttpMultiPart(QHttpMultiPart::FormDataType); + QHttpPart part = QHttpPart(); + part.setBody(imageData.data); + part.setHeader(QNetworkRequest::ContentTypeHeader, + QString("image/%1").arg(imageData.format)); + part.setHeader(QNetworkRequest::ContentLengthHeader, + QVariant(imageData.data.length())); + part.setHeader( + QNetworkRequest::ContentDispositionHeader, + QString("form-data; name=\"attachment\"; filename=\"control_v.%1\"") + .arg(imageData.format)); + payload->setBoundary(boundary); + payload->append(part); + NetworkRequest(url, NetworkRequestType::Post) + .header("Content-Type", contentType) + + .multiPart(payload) + .onSuccess([&textEdit, channel](NetworkResult result) -> Outcome { + textEdit.insertPlainText(result.getData() + QString(" ")); + if (uploadQueue.empty()) + { + channel->addMessage(makeSystemMessage( + QString("Your image has been uploaded."))); + uploadMutex.unlock(); + } + else + { + channel->addMessage(makeSystemMessage( + QString("Your image has been uploaded. %1 left. Please " + "wait until all of them are uploaded. About %2 " + "seconds left.") + .arg(uploadQueue.size()) + .arg(uploadQueue.size() * (UPLOAD_DELAY / 1000 + 1)))); + // 2 seconds for the timer that's there not to spam the remote server + // and 1 second of actual uploading. + + QTimer::singleShot(UPLOAD_DELAY, [channel, &textEdit]() { + uploadImageToNuuls(uploadQueue.front(), channel, textEdit); + uploadQueue.pop(); + }); + } + return Success; + }) + .onError([channel](NetworkResult result) -> bool { + channel->addMessage(makeSystemMessage( + QString("An error happened while uploading your image: %1") + .arg(result.status()))); + uploadMutex.unlock(); + return true; + }) + .execute(); +} + +void upload(const QMimeData *source, ChannelPtr channel, + ResizingTextEdit &outputTextEdit) +{ + if (!uploadMutex.tryLock()) + { + channel->addMessage(makeSystemMessage( + QString("Please wait until the upload finishes."))); + return; + } + + channel->addMessage(makeSystemMessage(QString("Started upload..."))); + + if (source->hasFormat("image/png")) + { + uploadImageToNuuls({source->data("image/png"), "png"}, channel, + outputTextEdit); + } + else if (source->hasFormat("image/jpeg")) + { + uploadImageToNuuls({source->data("image/jpeg"), "jpeg"}, channel, + outputTextEdit); + } + else if (source->hasFormat("image/gif")) + { + uploadImageToNuuls({source->data("image/gif"), "gif"}, channel, + outputTextEdit); + } + else if (source->hasUrls()) + { + auto mimeDb = QMimeDatabase(); + // This path gets chosen when files are copied from a file manager, like explorer.exe, caja. + // Each entry in source->urls() is a QUrl pointing to a file that was copied. + for (const QUrl &path : source->urls()) + { + QString localPath = path.toLocalFile(); + QMimeType mime = mimeDb.mimeTypeForUrl(path); + qDebug() << mime.name(); + if (mime.name().startsWith("image") && !mime.inherits("image/gif")) + { + channel->addMessage(makeSystemMessage( + QString("Uploading image: %1").arg(localPath))); + QImage img = QImage(localPath); + if (img.isNull()) + { + channel->addMessage( + makeSystemMessage(QString("Couldn't load image :("))); + uploadMutex.unlock(); + return; + } + + boost::optional imageData = convertToPng(img); + if (imageData) + { + RawImageData data = {imageData.get(), "png"}; + uploadQueue.push(data); + } + else + { + channel->addMessage(makeSystemMessage( + QString("Cannot upload file: %1, Couldn't convert " + "image to png.") + .arg(localPath))); + uploadMutex.unlock(); + return; + } + } + else if (mime.inherits("image/gif")) + { + channel->addMessage(makeSystemMessage( + QString("Uploading GIF: %1").arg(localPath))); + QFile file(localPath); + bool isOkay = file.open(QIODevice::ReadOnly); + if (!isOkay) + { + channel->addMessage( + makeSystemMessage(QString("Failed to open file. :("))); + uploadMutex.unlock(); + return; + } + RawImageData data = {file.readAll(), "gif"}; + uploadQueue.push(data); + file.close(); + // file.readAll() => might be a bit big but it /should/ work + } + else + { + channel->addMessage(makeSystemMessage( + QString("Cannot upload file: %1, not an image") + .arg(localPath))); + uploadMutex.unlock(); + return; + } + } + if (!uploadQueue.empty()) + { + uploadImageToNuuls(uploadQueue.front(), channel, outputTextEdit); + uploadQueue.pop(); + } + } + else + { // not PNG, try loading it into QImage and save it to a PNG. + QImage image = qvariant_cast(source->imageData()); + boost::optional imageData = convertToPng(image); + if (imageData) + { + uploadImageToNuuls({imageData.get(), "png"}, channel, + outputTextEdit); + } + else + { + channel->addMessage(makeSystemMessage( + QString("Cannot upload file, failed to convert to png."))); + uploadMutex.unlock(); + } + } +} +} // namespace chatterino diff --git a/src/util/NuulsUploader.hpp b/src/util/NuulsUploader.hpp new file mode 100644 index 000000000..0c059367d --- /dev/null +++ b/src/util/NuulsUploader.hpp @@ -0,0 +1,19 @@ +#include "common/Channel.hpp" +#include "widgets/helper/ResizingTextEdit.hpp" + +#include +#include + +namespace chatterino { +struct RawImageData { + QByteArray data; + QString format; +}; + +void upload(QByteArray imageData, ChannelPtr channel, + ResizingTextEdit &textEdit, std::string format); +void upload(RawImageData imageData, ChannelPtr channel, + ResizingTextEdit &textEdit); +void upload(const QMimeData *source, ChannelPtr channel, + ResizingTextEdit &outputTextEdit); +} // namespace chatterino diff --git a/src/widgets/helper/ResizingTextEdit.cpp b/src/widgets/helper/ResizingTextEdit.cpp index edaa11738..40bc66b02 100644 --- a/src/widgets/helper/ResizingTextEdit.cpp +++ b/src/widgets/helper/ResizingTextEdit.cpp @@ -1,8 +1,11 @@ #include "widgets/helper/ResizingTextEdit.hpp" + #include "common/Common.hpp" #include "common/CompletionModel.hpp" #include "singletons/Settings.hpp" +#include + namespace chatterino { ResizingTextEdit::ResizingTextEdit() @@ -257,20 +260,20 @@ void ResizingTextEdit::insertCompletion(const QString &completion) bool ResizingTextEdit::canInsertFromMimeData(const QMimeData *source) const { - if (source->hasImage()) - { - return false; - } - else if (source->hasFormat("text/plain")) + if (source->hasImage() || source->hasFormat("text/plain")) { return true; } - return false; + return QTextEdit::canInsertFromMimeData(source); } void ResizingTextEdit::insertFromMimeData(const QMimeData *source) { - if (!source->hasImage()) + if (source->hasImage() || source->hasUrls()) + { + this->imagePasted.invoke(source); + } + else { insertPlainText(source->text()); } diff --git a/src/widgets/helper/ResizingTextEdit.hpp b/src/widgets/helper/ResizingTextEdit.hpp index ff79cddb4..cd1db3114 100644 --- a/src/widgets/helper/ResizingTextEdit.hpp +++ b/src/widgets/helper/ResizingTextEdit.hpp @@ -19,6 +19,7 @@ public: pajlada::Signals::Signal keyPressed; pajlada::Signals::NoArgSignal focused; pajlada::Signals::NoArgSignal focusLost; + pajlada::Signals::Signal imagePasted; void setCompleter(QCompleter *c); QCompleter *getCompleter() const; diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 26f35cdbe..fb8fd1524 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -545,6 +545,9 @@ void GeneralPage::initLayout(SettingsLayout &layout) layout.addCheckbox( "Hide viewercount and stream length while hovering the split", s.hideViewerCountAndDuration); + layout.addCheckbox( + "Ask for confirmation when uploading an image to i.nuuls.com", + s.askOnImageUpload); layout.addTitle("Cache"); layout.addDescription( diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp index 19ee3d151..84048191b 100644 --- a/src/widgets/splits/Split.cpp +++ b/src/widgets/splits/Split.cpp @@ -1,6 +1,7 @@ #include "widgets/splits/Split.hpp" #include "common/Common.hpp" +#include "common/Env.hpp" #include "common/NetworkRequest.hpp" #include "controllers/accounts/AccountController.hpp" #include "providers/twitch/EmoteValue.hpp" @@ -11,6 +12,7 @@ #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" #include "util/Clipboard.hpp" +#include "util/NuulsUploader.hpp" #include "util/Shortcut.hpp" #include "util/StreamLink.hpp" #include "widgets/Notebook.hpp" @@ -205,6 +207,34 @@ Split::Split(QWidget *parent) [this] { this->focused.invoke(); }); this->input_->ui_.textEdit->focusLost.connect( [this] { this->focusLost.invoke(); }); + this->input_->ui_.textEdit->imagePasted.connect([this](const QMimeData + *source) { + if (getSettings()->askOnImageUpload.getValue()) + { + QMessageBox msgBox; + msgBox.setText("Image upload"); + msgBox.setInformativeText( + "You are uploading an image to i.nuuls.com. You won't be able " + "to remove the image from the site. Are you okay with this?"); + msgBox.addButton(QMessageBox::Cancel); + msgBox.addButton(QMessageBox::Yes); + msgBox.addButton("Yes, don't ask again", QMessageBox::YesRole); + + msgBox.setDefaultButton(QMessageBox::Yes); + + auto picked = msgBox.exec(); + if (picked == QMessageBox::Cancel) + { + return; + } + else if (picked == 0) // don't ask again button + { + getSettings()->askOnImageUpload.setValue(false); + } + } + upload(source, this->getChannel(), *this->input_->ui_.textEdit); + }); + setAcceptDrops(true); } Split::~Split() @@ -700,6 +730,29 @@ void Split::reloadChannelAndSubscriberEmotes() } } +void Split::dragEnterEvent(QDragEnterEvent *event) +{ + if (event->mimeData()->hasImage() || event->mimeData()->hasUrls()) + { + event->acceptProposedAction(); + } + else + { + BaseWidget::dragEnterEvent(event); + } +} + +void Split::dropEvent(QDropEvent *event) +{ + if (event->mimeData()->hasImage() || event->mimeData()->hasUrls()) + { + this->input_->ui_.textEdit->imagePasted.invoke(event->mimeData()); + } + else + { + BaseWidget::dropEvent(event); + } +} template static Iter select_randomly(Iter start, Iter end, RandomGenerator &g) { diff --git a/src/widgets/splits/Split.hpp b/src/widgets/splits/Split.hpp index a22ba94a0..c4ef428a3 100644 --- a/src/widgets/splits/Split.hpp +++ b/src/widgets/splits/Split.hpp @@ -86,6 +86,9 @@ protected: void leaveEvent(QEvent *event) override; void focusInEvent(QFocusEvent *event) override; + void dragEnterEvent(QDragEnterEvent *event) override; + void dropEvent(QDropEvent *event) override; + private: void channelNameUpdated(const QString &newChannelName); void handleModifiers(Qt::KeyboardModifiers modifiers);