mirror of
https://github.com/Chatterino/chatterino2.git
synced 2024-11-21 22:24:07 +01:00
Create uploader to i.nuuls.com (#1332)
This commit adds support for uploading images to i.nuuls.com from clipboard or by dragging an image into a split. Documentation for how to self-host the image uploader is available in ENV.md By default, you will be asked before an image upload takes place. There's an option in the dialog to not be asked again, if that option is chosen you can revert that choice in the settings dialog.
This commit is contained in:
commit
001dce5da1
13 changed files with 323 additions and 7 deletions
|
@ -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)
|
||||
|
|
|
@ -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 \
|
||||
|
|
11
docs/ENV.md
11
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`
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -291,6 +291,7 @@ public:
|
|||
"/misc/attachExtensionToAnyProcess", false};
|
||||
BoolSetting hideViewerCountAndDuration = {
|
||||
"/misc/hideViewerCountAndDuration", false};
|
||||
BoolSetting askOnImageUpload = {"/misc/askOnImageUpload", true};
|
||||
|
||||
/// Debug
|
||||
BoolSetting showUnhandledIrcMessages = {"/debug/showUnhandledIrcMessages",
|
||||
|
|
216
src/util/NuulsUploader.cpp
Normal file
216
src/util/NuulsUploader.cpp
Normal file
|
@ -0,0 +1,216 @@
|
|||
#include "NuulsUploader.hpp"
|
||||
|
||||
#include "common/Env.hpp"
|
||||
#include "common/NetworkRequest.hpp"
|
||||
#include "providers/twitch/TwitchMessageBuilder.hpp"
|
||||
|
||||
#include <QBuffer>
|
||||
#include <QHttpMultiPart>
|
||||
#include <QMimeDatabase>
|
||||
#include <QMutex>
|
||||
|
||||
#define UPLOAD_DELAY 2000
|
||||
// Delay between uploads in milliseconds
|
||||
|
||||
namespace {
|
||||
|
||||
boost::optional<QByteArray> convertToPng(QImage image)
|
||||
{
|
||||
QByteArray imageData;
|
||||
QBuffer buf(&imageData);
|
||||
buf.open(QIODevice::WriteOnly);
|
||||
bool success = image.save(&buf, "png");
|
||||
if (success)
|
||||
{
|
||||
return boost::optional<QByteArray>(imageData);
|
||||
}
|
||||
else
|
||||
{
|
||||
return boost::optional<QByteArray>(boost::none);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace chatterino {
|
||||
// These variables are only used from the main thread.
|
||||
static auto uploadMutex = QMutex();
|
||||
static std::queue<RawImageData> 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<QByteArray> 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<QImage>(source->imageData());
|
||||
boost::optional<QByteArray> 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
|
19
src/util/NuulsUploader.hpp
Normal file
19
src/util/NuulsUploader.hpp
Normal file
|
@ -0,0 +1,19 @@
|
|||
#include "common/Channel.hpp"
|
||||
#include "widgets/helper/ResizingTextEdit.hpp"
|
||||
|
||||
#include <QMimeData>
|
||||
#include <QString>
|
||||
|
||||
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
|
|
@ -1,8 +1,11 @@
|
|||
#include "widgets/helper/ResizingTextEdit.hpp"
|
||||
|
||||
#include "common/Common.hpp"
|
||||
#include "common/CompletionModel.hpp"
|
||||
#include "singletons/Settings.hpp"
|
||||
|
||||
#include <QMimeData>
|
||||
|
||||
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());
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ public:
|
|||
pajlada::Signals::Signal<QKeyEvent *> keyPressed;
|
||||
pajlada::Signals::NoArgSignal focused;
|
||||
pajlada::Signals::NoArgSignal focusLost;
|
||||
pajlada::Signals::Signal<const QMimeData *> imagePasted;
|
||||
|
||||
void setCompleter(QCompleter *c);
|
||||
QCompleter *getCompleter() const;
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 <typename Iter, typename RandomGenerator>
|
||||
static Iter select_randomly(Iter start, Iter end, RandomGenerator &g)
|
||||
{
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue