diff --git a/CMakeLists.txt b/CMakeLists.txt
index 027bc44ea2876ba2601708e313c5e8b1b0c07a63..550a3aa4dc35e020434fab590b199d68b2ed3afa 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -311,7 +311,6 @@ set(SRC_FILES
 	# Dialogs
 	src/dialogs/CreateRoom.cpp
 	src/dialogs/FallbackAuth.cpp
-	src/dialogs/PreviewUploadOverlay.cpp
 	src/dialogs/ReCaptcha.cpp
 
 	# Emoji
@@ -509,7 +508,6 @@ qt5_wrap_cpp(MOC_HEADERS
 	# Dialogs
 	src/dialogs/CreateRoom.h
 	src/dialogs/FallbackAuth.h
-	src/dialogs/PreviewUploadOverlay.h
 	src/dialogs/ReCaptcha.h
 
 	# Emoji
diff --git a/resources/qml/TimelineView.qml b/resources/qml/TimelineView.qml
index 1933baeb20edfc5684308399873fdbd216daa619..0b3a6427edcb267c71b6c31b7100bc1b68e0d47d 100644
--- a/resources/qml/TimelineView.qml
+++ b/resources/qml/TimelineView.qml
@@ -124,6 +124,12 @@ Item {
             color: Nheko.theme.separator
         }
 
+        Button {
+            text: "Send files " + (room ? room.input.uploads.length : 0)
+            visible: room && room.input.uploads.length > 0
+            onClicked: room.input.acceptUploads()
+        }
+
         NotificationWarning {
         }
 
diff --git a/src/dialogs/PreviewUploadOverlay.cpp b/src/dialogs/PreviewUploadOverlay.cpp
deleted file mode 100644
index a00358d9b3b88f722aa3c8c46c0689965815cd22..0000000000000000000000000000000000000000
--- a/src/dialogs/PreviewUploadOverlay.cpp
+++ /dev/null
@@ -1,223 +0,0 @@
-// SPDX-FileCopyrightText: 2017 Konstantinos Sideris <siderisk@auth.gr>
-// SPDX-FileCopyrightText: 2021 Nheko Contributors
-// SPDX-FileCopyrightText: 2022 Nheko Contributors
-//
-// SPDX-License-Identifier: GPL-3.0-or-later
-
-#include <QBuffer>
-#include <QFile>
-#include <QFileInfo>
-#include <QHBoxLayout>
-#include <QMimeDatabase>
-#include <QVBoxLayout>
-
-#include "dialogs/PreviewUploadOverlay.h"
-
-#include "Config.h"
-#include "Logging.h"
-#include "MainWindow.h"
-#include "Utils.h"
-
-using namespace dialogs;
-
-constexpr const char *DEFAULT = "Upload %1?";
-constexpr const char *ERR_MSG = "Failed to load image type '%1'. Continue upload?";
-
-PreviewUploadOverlay::PreviewUploadOverlay(QWidget *parent)
-  : QWidget{parent}
-  , titleLabel_{this}
-  , fileName_{this}
-  , upload_{tr("Upload"), this}
-  , cancel_{tr("Cancel"), this}
-{
-    auto hlayout = new QHBoxLayout;
-    hlayout->setContentsMargins(0, 0, 0, 0);
-    hlayout->addStretch(1);
-    hlayout->addWidget(&cancel_);
-    hlayout->addWidget(&upload_);
-
-    auto vlayout = new QVBoxLayout{this};
-    vlayout->addWidget(&titleLabel_);
-    vlayout->addWidget(&infoLabel_);
-    vlayout->addWidget(&fileName_);
-    vlayout->addLayout(hlayout);
-    vlayout->setSpacing(conf::modals::WIDGET_SPACING);
-    vlayout->setContentsMargins(conf::modals::WIDGET_MARGIN,
-                                conf::modals::WIDGET_MARGIN,
-                                conf::modals::WIDGET_MARGIN,
-                                conf::modals::WIDGET_MARGIN);
-
-    upload_.setDefault(true);
-    connect(&upload_, &QPushButton::clicked, this, [this]() {
-        emit confirmUpload(data_, mediaType_, fileName_.text());
-        close();
-    });
-
-    connect(&fileName_, &QLineEdit::returnPressed, this, [this]() {
-        emit confirmUpload(data_, mediaType_, fileName_.text());
-        close();
-    });
-
-    connect(&cancel_, &QPushButton::clicked, this, [this]() {
-        emit aborted();
-        close();
-    });
-}
-
-void
-PreviewUploadOverlay::init()
-{
-    QSize winsize;
-    QPoint center;
-
-    auto window = MainWindow::instance();
-    if (window) {
-        winsize = window->frameGeometry().size();
-        center  = window->frameGeometry().center();
-    } else {
-        nhlog::ui()->warn("unable to retrieve MainWindow's size");
-    }
-
-    fileName_.setText(QFileInfo{filePath_}.fileName());
-
-    setAutoFillBackground(true);
-    setWindowFlags(Qt::Tool | Qt::WindowStaysOnTopHint);
-    setWindowModality(Qt::WindowModal);
-
-    QFont font;
-    font.setPointSizeF(font.pointSizeF() * conf::modals::LABEL_MEDIUM_SIZE_RATIO);
-
-    titleLabel_.setFont(font);
-    titleLabel_.setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
-    titleLabel_.setAlignment(Qt::AlignCenter);
-    infoLabel_.setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum);
-    fileName_.setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
-    fileName_.setAlignment(Qt::AlignCenter);
-    upload_.setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
-    cancel_.setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
-
-    if (isImage_) {
-        infoLabel_.setAlignment(Qt::AlignCenter);
-
-        const auto maxWidth  = winsize.width() * 0.8;
-        const auto maxHeight = winsize.height() * 0.8;
-
-        // Scale image preview to fit into the application window.
-        infoLabel_.setPixmap(utils::scaleDown(maxWidth, maxHeight, image_));
-        move(center.x() - (width() * 0.5), center.y() - (height() * 0.5));
-    } else {
-        infoLabel_.setAlignment(Qt::AlignLeft);
-    }
-    infoLabel_.setScaledContents(false);
-
-    show();
-}
-
-void
-PreviewUploadOverlay::setLabels(const QString &type, const QString &mime, uint64_t upload_size)
-{
-    if (mediaType_.split('/')[0] == QLatin1String("image")) {
-        if (!image_.loadFromData(data_)) {
-            titleLabel_.setText(QString{tr(ERR_MSG)}.arg(type));
-        } else {
-            titleLabel_.setText(QString{tr(DEFAULT)}.arg(mediaType_));
-        }
-        isImage_ = true;
-    } else {
-        auto const info = QString{tr("Media type: %1\n"
-                                     "Media size: %2\n")}
-                            .arg(mime, utils::humanReadableFileSize(upload_size));
-
-        titleLabel_.setText(QString{tr(DEFAULT)}.arg(QStringLiteral("file")));
-        infoLabel_.setText(info);
-    }
-}
-
-void
-PreviewUploadOverlay::setPreview(const QImage &src, const QString &mime)
-{
-    nhlog::ui()->info(
-      "Pasting image with size: {}x{}, format: {}", src.height(), src.width(), mime.toStdString());
-
-    auto const &split = mime.split('/');
-    auto const &type  = split[1];
-
-    QBuffer buffer(&data_);
-    buffer.open(QIODevice::WriteOnly);
-    if (src.save(&buffer, type.toStdString().c_str()))
-        titleLabel_.setText(QString{tr(DEFAULT)}.arg(QStringLiteral("image")));
-    else
-        titleLabel_.setText(QString{tr(ERR_MSG)}.arg(type));
-
-    mediaType_ = mime;
-    filePath_  = "clipboard." + type;
-    image_.convertFromImage(src);
-    isImage_ = true;
-
-    titleLabel_.setText(QString{tr(DEFAULT)}.arg(QStringLiteral("image")));
-    init();
-}
-
-void
-PreviewUploadOverlay::setPreview(const QByteArray data, const QString &mime)
-{
-    nhlog::ui()->info("Pasting {} bytes of data, mimetype {}", data.size(), mime.toStdString());
-
-    auto const &split = mime.split('/');
-    auto const &type  = split[1];
-
-    data_      = data;
-    mediaType_ = mime;
-    filePath_  = "clipboard." + type;
-    isImage_   = false;
-
-    if (mime == QLatin1String("image/svg+xml")) {
-        isImage_ = true;
-        image_.loadFromData(data_, mediaType_.toStdString().c_str());
-    }
-
-    setLabels(type, mime, data_.size());
-    init();
-}
-
-void
-PreviewUploadOverlay::setPreview(const QString &path)
-{
-    QFile file{path};
-
-    if (!file.open(QIODevice::ReadOnly)) {
-        nhlog::ui()->warn(
-          "Failed to open file ({}): {}", path.toStdString(), file.errorString().toStdString());
-        close();
-        return;
-    }
-
-    QMimeDatabase db;
-    auto mime = db.mimeTypeForFileNameAndData(path, &file);
-
-    if ((data_ = file.readAll()).isEmpty()) {
-        nhlog::ui()->warn("Failed to read media: {}", file.errorString().toStdString());
-        close();
-        return;
-    }
-
-    auto const &split = mime.name().split('/');
-
-    mediaType_ = mime.name();
-    filePath_  = file.fileName();
-    isImage_   = false;
-
-    setLabels(split[1], mime.name(), data_.size());
-    init();
-}
-
-void
-PreviewUploadOverlay::keyPressEvent(QKeyEvent *event)
-{
-    if (event->matches(QKeySequence::Cancel)) {
-        emit aborted();
-        close();
-    } else {
-        QWidget::keyPressEvent(event);
-    }
-}
diff --git a/src/dialogs/PreviewUploadOverlay.h b/src/dialogs/PreviewUploadOverlay.h
deleted file mode 100644
index aa72d4d232e0e85f2f152075ca7224d377387a97..0000000000000000000000000000000000000000
--- a/src/dialogs/PreviewUploadOverlay.h
+++ /dev/null
@@ -1,53 +0,0 @@
-// SPDX-FileCopyrightText: 2017 Konstantinos Sideris <siderisk@auth.gr>
-// SPDX-FileCopyrightText: 2021 Nheko Contributors
-// SPDX-FileCopyrightText: 2022 Nheko Contributors
-//
-// SPDX-License-Identifier: GPL-3.0-or-later
-
-#pragma once
-
-#include <QImage>
-#include <QLabel>
-#include <QLineEdit>
-#include <QPixmap>
-#include <QPushButton>
-#include <QWidget>
-
-class QMimeData;
-
-namespace dialogs {
-
-class PreviewUploadOverlay : public QWidget
-{
-    Q_OBJECT
-public:
-    PreviewUploadOverlay(QWidget *parent = nullptr);
-
-    void setPreview(const QImage &src, const QString &mime);
-    void setPreview(const QByteArray data, const QString &mime);
-    void setPreview(const QString &path);
-    void keyPressEvent(QKeyEvent *event);
-
-signals:
-    void confirmUpload(const QByteArray data, const QString &media, const QString &filename);
-    void aborted();
-
-private:
-    void init();
-    void setLabels(const QString &type, const QString &mime, uint64_t upload_size);
-
-    bool isImage_;
-    QPixmap image_;
-
-    QByteArray data_;
-    QString filePath_;
-    QString mediaType_;
-
-    QLabel titleLabel_;
-    QLabel infoLabel_;
-    QLineEdit fileName_;
-
-    QPushButton upload_;
-    QPushButton cancel_;
-};
-} // dialogs
diff --git a/src/timeline/InputBar.cpp b/src/timeline/InputBar.cpp
index 349ce7af8db3b588c5033dee9618be13aed1dd92..1b7d6efba3e663dd2ad869d837eae488036f914d 100644
--- a/src/timeline/InputBar.cpp
+++ b/src/timeline/InputBar.cpp
@@ -5,6 +5,7 @@
 
 #include "InputBar.h"
 
+#include <QBuffer>
 #include <QClipboard>
 #include <QDropEvent>
 #include <QFileDialog>
@@ -31,7 +32,6 @@
 #include "TimelineViewManager.h"
 #include "UserSettingsPage.h"
 #include "Utils.h"
-#include "dialogs/PreviewUploadOverlay.h"
 
 #include "blurhash.hpp"
 
@@ -67,29 +67,23 @@ InputBar::insertMimeData(const QMimeData *md)
 
     if (md->hasImage()) {
         if (formats.contains(QStringLiteral("image/svg+xml"), Qt::CaseInsensitive)) {
-            showPreview(*md, QLatin1String(""), QStringList(QStringLiteral("image/svg+xml")));
+            startUploadFromMimeData(*md, QStringLiteral("image/svg+xml"));
+        } else if (formats.contains(QStringLiteral("image/png"), Qt::CaseInsensitive)) {
+            startUploadFromMimeData(*md, QStringLiteral("image/png"));
         } else {
-            showPreview(*md, QLatin1String(""), image);
+            startUploadFromMimeData(*md, image.first());
         }
     } else if (!audio.empty()) {
-        showPreview(*md, QLatin1String(""), audio);
+        startUploadFromMimeData(*md, audio.first());
     } else if (!video.empty()) {
-        showPreview(*md, QLatin1String(""), video);
+        startUploadFromMimeData(*md, video.first());
     } else if (md->hasUrls()) {
         // Generic file path for any platform.
-        QString path;
         for (auto &&u : md->urls()) {
             if (u.isLocalFile()) {
-                path = u.toLocalFile();
-                break;
+                startUploadFromPath(u.toLocalFile());
             }
         }
-
-        if (!path.isEmpty() && QFileInfo::exists(path)) {
-            showPreview(*md, path, formats);
-        } else {
-            nhlog::ui()->warn("Clipboard does not contain any valid file paths.");
-        }
     } else if (md->hasFormat(QStringLiteral("x-special/gnome-copied-files"))) {
         // Special case for X11 users. See "Notes for X11 Users" in md.
         // Source: http://doc.qt.io/qt-5/qclipboard.html
@@ -108,21 +102,12 @@ InputBar::insertMimeData(const QMimeData *md)
             return;
         }
 
-        QString path;
         for (int i = 1; i < data.size(); ++i) {
             QUrl url{data[i]};
             if (url.isLocalFile()) {
-                path = url.toLocalFile();
-                break;
+                startUploadFromPath(url.toLocalFile());
             }
         }
-
-        if (!path.isEmpty()) {
-            showPreview(*md, path, formats);
-        } else {
-            nhlog::ui()->warn("Clipboard does not contain any valid file paths: {}",
-                              data.join(", ").toStdString());
-        }
     } else if (md->hasText()) {
         emit insertText(md->text());
     } else {
@@ -275,25 +260,7 @@ InputBar::openFileSelection()
     if (fileName.isEmpty())
         return;
 
-    QMimeDatabase db;
-    QMimeType mime = db.mimeTypeForFile(fileName, QMimeDatabase::MatchContent);
-
-    QFile file{fileName};
-
-    if (!file.open(QIODevice::ReadOnly)) {
-        emit ChatPage::instance()->showNotification(
-          QStringLiteral("Error while reading media: %1").arg(file.errorString()));
-        return;
-    }
-
-    setUploading(true);
-
-    auto bin = file.readAll();
-
-    QMimeData data;
-    data.setData(mime.name(), bin);
-
-    showPreview(data, fileName, QStringList{mime.name()});
+    startUploadFromPath(fileName);
 }
 
 void
@@ -661,123 +628,204 @@ InputBar::command(const QString &command, QString args)
     }
 }
 
-void
-InputBar::showPreview(const QMimeData &source, const QString &path, const QStringList &formats)
+MediaUpload::MediaUpload(std::unique_ptr<QIODevice> source_,
+                         QString mimetype,
+                         QString originalFilename,
+                         bool encrypt,
+                         QObject *parent)
+  : QObject(parent)
+  , source(std::move(source_))
+  , mimetype_(std::move(mimetype))
+  , originalFilename_(QFileInfo(originalFilename).fileName())
+  , encrypt_(encrypt)
 {
-    auto *previewDialog_ = new dialogs::PreviewUploadOverlay(nullptr);
-    previewDialog_->setAttribute(Qt::WA_DeleteOnClose);
+    mimeClass_ = mimetype_.left(mimetype_.indexOf(u'/'));
 
-    // Force SVG to _not_ be handled as an image, but as raw data
-    if (source.hasImage() &&
-        (formats.empty() || formats.front() != QLatin1String("image/svg+xml"))) {
-        if (!formats.empty() && formats.front().startsWith(QLatin1String("image/"))) {
-            // known format, keep as-is
-            previewDialog_->setPreview(qvariant_cast<QImage>(source.imageData()), formats.front());
-        } else {
-            // unknown image format, default to image/png
-            previewDialog_->setPreview(qvariant_cast<QImage>(source.imageData()),
-                                       QStringLiteral("image/png"));
-        }
-    } else if (!path.isEmpty())
-        previewDialog_->setPreview(path);
-    else if (!formats.isEmpty()) {
-        const auto &mime = formats.first();
-        previewDialog_->setPreview(source.data(mime), mime);
-    } else {
-        setUploading(false);
-        previewDialog_->deleteLater();
+    if (!source->isOpen())
+        source->open(QIODevice::ReadOnly);
+
+    data = source->readAll();
+
+    if (!data.size()) {
+        nhlog::ui()->warn("Attempted to upload zero-byte file?! Mimetype {}, filename {}",
+                          mimetype_.toStdString(),
+                          originalFilename_.toStdString());
+        emit uploadFailed(this);
         return;
     }
 
-    connect(previewDialog_, &dialogs::PreviewUploadOverlay::aborted, this, [this]() {
-        setUploading(false);
-    });
+    nhlog::ui()->debug("Mime: {}", mimetype_.toStdString());
+    if (mimeClass_ == u"image") {
+        QImage img = utils::readImage(data);
+
+        dimensions_ = img.size();
+        if (img.height() > 200 && img.width() > 360)
+            img = img.scaled(360, 200, Qt::KeepAspectRatioByExpanding);
+        std::vector<unsigned char> data_;
+        for (int y = 0; y < img.height(); y++) {
+            for (int x = 0; x < img.width(); x++) {
+                auto p = img.pixel(x, y);
+                data_.push_back(static_cast<unsigned char>(qRed(p)));
+                data_.push_back(static_cast<unsigned char>(qGreen(p)));
+                data_.push_back(static_cast<unsigned char>(qBlue(p)));
+            }
+        }
+        blurhash_ =
+          QString::fromStdString(blurhash::encode(data_.data(), img.width(), img.height(), 4, 3));
+    }
+}
 
-    connect(
-      previewDialog_,
-      &dialogs::PreviewUploadOverlay::confirmUpload,
-      this,
-      [this](const QByteArray &data, const QString &mime, const QString &fn) {
-          if (!data.size()) {
-              nhlog::ui()->warn("Attempted to upload zero-byte file?! Mimetype {}, filename {}",
-                                mime.toStdString(),
-                                fn.toStdString());
+void
+MediaUpload::startUpload()
+{
+    auto payload = std::string(data.data(), data.size());
+    if (encrypt_) {
+        mtx::crypto::BinaryBuf buf;
+        std::tie(buf, encryptedFile) = mtx::crypto::encrypt_file(std::move(payload));
+        payload                      = mtx::crypto::to_string(buf);
+    }
+    size_ = payload.size();
+
+    http::client()->upload(
+      payload,
+      encryptedFile ? "application/octet-stream" : mimetype_.toStdString(),
+      originalFilename_.toStdString(),
+      [this](const mtx::responses::ContentURI &res, mtx::http::RequestErr err) mutable {
+          if (err) {
+              emit ChatPage::instance()->showNotification(
+                tr("Failed to upload media. Please try again."));
+              nhlog::net()->warn("failed to upload media: {} {} ({})",
+                                 err->matrix_error.error,
+                                 to_string(err->matrix_error.errcode),
+                                 static_cast<int>(err->status_code));
+              emit uploadFailed(this);
               return;
           }
-          setUploading(true);
 
-          setText(QLatin1String(""));
+          auto url = QString::fromStdString(res.content_uri);
+          if (encryptedFile)
+              encryptedFile->url = res.content_uri;
 
-          auto payload = std::string(data.data(), data.size());
-          std::optional<mtx::crypto::EncryptedFile> encryptedFile;
-          if (cache::isRoomEncrypted(room->roomId().toStdString())) {
-              mtx::crypto::BinaryBuf buf;
-              std::tie(buf, encryptedFile) = mtx::crypto::encrypt_file(payload);
-              payload                      = mtx::crypto::to_string(buf);
-          }
+          emit uploadComplete(this, std::move(url));
+      });
+}
 
-          QSize dimensions;
-          QString blurhash;
-          auto mimeClass = mime.left(mime.indexOf(u'/'));
-          nhlog::ui()->debug("Mime: {}", mime.toStdString());
-          if (mimeClass == u"image") {
-              QImage img = utils::readImage(data);
-
-              dimensions = img.size();
-              if (img.height() > 200 && img.width() > 360)
-                  img = img.scaled(360, 200, Qt::KeepAspectRatioByExpanding);
-              std::vector<unsigned char> data_;
-              for (int y = 0; y < img.height(); y++) {
-                  for (int x = 0; x < img.width(); x++) {
-                      auto p = img.pixel(x, y);
-                      data_.push_back(static_cast<unsigned char>(qRed(p)));
-                      data_.push_back(static_cast<unsigned char>(qGreen(p)));
-                      data_.push_back(static_cast<unsigned char>(qBlue(p)));
-                  }
-              }
-              blurhash = QString::fromStdString(
-                blurhash::encode(data_.data(), img.width(), img.height(), 4, 3));
-          }
+void
+InputBar::finalizeUpload(MediaUpload *upload, QString url)
+{
+    auto mime          = upload->mimetype();
+    auto filename      = upload->filename();
+    auto mimeClass     = upload->mimeClass();
+    auto size          = upload->size();
+    auto encryptedFile = upload->encryptedFile_();
+    if (mimeClass == u"image")
+        image(filename, encryptedFile, url, mime, size, upload->dimensions(), upload->blurhash());
+    else if (mimeClass == u"audio")
+        audio(filename, encryptedFile, url, mime, size);
+    else if (mimeClass == u"video")
+        video(filename, encryptedFile, url, mime, size);
+    else
+        file(filename, encryptedFile, url, mime, size);
 
-          http::client()->upload(
-            payload,
-            encryptedFile ? "application/octet-stream" : mime.toStdString(),
-            QFileInfo(fn).fileName().toStdString(),
-            [this,
-             filename      = fn,
-             encryptedFile = std::move(encryptedFile),
-             mimeClass,
-             mime,
-             size = payload.size(),
-             dimensions,
-             blurhash](const mtx::responses::ContentURI &res, mtx::http::RequestErr err) mutable {
-                if (err) {
-                    emit ChatPage::instance()->showNotification(
-                      tr("Failed to upload media. Please try again."));
-                    nhlog::net()->warn("failed to upload media: {} {} ({})",
-                                       err->matrix_error.error,
-                                       to_string(err->matrix_error.errcode),
-                                       static_cast<int>(err->status_code));
-                    setUploading(false);
-                    return;
-                }
-
-                auto url = QString::fromStdString(res.content_uri);
-                if (encryptedFile)
-                    encryptedFile->url = res.content_uri;
-
-                if (mimeClass == u"image")
-                    image(filename, encryptedFile, url, mime, size, dimensions, blurhash);
-                else if (mimeClass == u"audio")
-                    audio(filename, encryptedFile, url, mime, size);
-                else if (mimeClass == u"video")
-                    video(filename, encryptedFile, url, mime, size);
-                else
-                    file(filename, encryptedFile, url, mime, size);
-
-                setUploading(false);
-            });
-      });
+    removeRunUpload(upload);
+}
+
+void
+InputBar::removeRunUpload(MediaUpload *upload)
+{
+    auto it = std::find_if(runningUploads.begin(),
+                           runningUploads.end(),
+                           [upload](const UploadHandle &h) { return h.get() == upload; });
+    if (it != runningUploads.end())
+        runningUploads.erase(it);
+
+    if (runningUploads.empty())
+        setUploading(false);
+    else
+        runningUploads.front()->startUpload();
+}
+
+void
+InputBar::startUploadFromPath(const QString &path)
+{
+    if (path.isEmpty())
+        return;
+
+    auto file = std::make_unique<QFile>(path);
+
+    if (!file->open(QIODevice::ReadOnly)) {
+        nhlog::ui()->warn(
+          "Failed to open file ({}): {}", path.toStdString(), file->errorString().toStdString());
+        return;
+    }
+
+    QMimeDatabase db;
+    auto mime = db.mimeTypeForFileNameAndData(path, file.get());
+
+    startUpload(std::move(file), path, mime.name());
+}
+
+void
+InputBar::startUploadFromMimeData(const QMimeData &source, const QString &format)
+{
+    auto file = std::make_unique<QBuffer>();
+    file->setData(source.data(format));
+
+    if (!file->open(QIODevice::ReadOnly)) {
+        nhlog::ui()->warn("Failed to open buffer: {}", file->errorString().toStdString());
+        return;
+    }
+
+    startUpload(std::move(file), QStringLiteral(""), format);
+}
+void
+InputBar::startUpload(std::unique_ptr<QIODevice> dev, const QString &orgPath, const QString &format)
+{
+    auto upload =
+      UploadHandle(new MediaUpload(std::move(dev), format, orgPath, room->isEncrypted(), this));
+    connect(upload.get(), &MediaUpload::uploadComplete, this, &InputBar::finalizeUpload);
+
+    unconfirmedUploads.push_back(std::move(upload));
+
+    nhlog::ui()->info("Uploads {}", unconfirmedUploads.size());
+    emit uploadsChanged();
+}
+
+void
+InputBar::acceptUploads()
+{
+    if (unconfirmedUploads.empty())
+        return;
+
+    bool wasntRunning = runningUploads.empty();
+    runningUploads.insert(runningUploads.end(),
+                          std::make_move_iterator(unconfirmedUploads.begin()),
+                          std::make_move_iterator(unconfirmedUploads.end()));
+    unconfirmedUploads.clear();
+    emit uploadsChanged();
+
+    if (wasntRunning) {
+        setUploading(true);
+        runningUploads.front()->startUpload();
+    }
+}
+
+void
+InputBar::declineUploads()
+{
+    unconfirmedUploads.clear();
+    emit uploadsChanged();
+}
+
+QVariantList
+InputBar::uploads() const
+{
+    QVariantList l;
+    l.reserve((int)unconfirmedUploads.size());
+
+    for (auto &e : unconfirmedUploads)
+        l.push_back(QVariant::fromValue(e.get()));
+    return l;
 }
 
 void
diff --git a/src/timeline/InputBar.h b/src/timeline/InputBar.h
index 20c3d17e8a11faad6a20f87a6947ecefa6c02124..607736b6e3ba41fae63d9e70e1ea13f560ed8d0c 100644
--- a/src/timeline/InputBar.h
+++ b/src/timeline/InputBar.h
@@ -5,10 +5,14 @@
 
 #pragma once
 
+#include <QIODevice>
 #include <QObject>
+#include <QSize>
 #include <QStringList>
 #include <QTimer>
+#include <QVariantList>
 #include <deque>
+#include <memory>
 
 #include <mtx/common.hpp>
 #include <mtx/responses/messages.hpp>
@@ -25,12 +29,85 @@ enum class MarkdownOverride
     OFF,
 };
 
+class MediaUpload : public QObject
+{
+    Q_OBJECT
+    //    Q_PROPERTY(bool uploading READ uploading NOTIFY uploadingChanged)
+    //    Q_PROPERTY(MediaType mediaType READ type NOTIFY mediaTypeChanged)
+    //    // https://stackoverflow.com/questions/33422265/pass-qimage-to-qml/68554646#68554646
+    //    Q_PROPERTY(QUrl thumbnail READ thumbnail NOTIFY thumbnailChanged)
+    //    Q_PROPERTY(QString humanSize READ humanSize NOTIFY huSizeChanged)
+    //    Q_PROPERTY(QString filename READ filename NOTIFY filenameChanged)
+    //    Q_PROPERTY(QString mimetype READ mimetype NOTIFY mimetypeChanged)
+    //    Q_PROPERTY(int height READ height NOTIFY heightChanged)
+    //    Q_PROPERTY(int width READ width NOTIFY widthChanged)
+
+    // thumbnail video
+    // https://stackoverflow.com/questions/26229633/display-on-screen-using-qabstractvideosurface
+
+public:
+    enum MediaType
+    {
+        File,
+        Image,
+        Video,
+        Audio,
+    };
+    Q_ENUM(MediaType);
+
+    explicit MediaUpload(std::unique_ptr<QIODevice> data,
+                         QString mimetype,
+                         QString originalFilename,
+                         bool encrypt,
+                         QObject *parent = nullptr);
+
+    [[nodiscard]] QString url() const { return url_; }
+    [[nodiscard]] QString mimetype() const { return mimetype_; }
+    [[nodiscard]] QString mimeClass() const { return mimeClass_; }
+    [[nodiscard]] QString filename() const { return originalFilename_; }
+    [[nodiscard]] QString blurhash() const { return blurhash_; }
+    [[nodiscard]] uint64_t size() const { return size_; }
+    [[nodiscard]] std::optional<mtx::crypto::EncryptedFile> encryptedFile_()
+    {
+        return encryptedFile;
+    }
+    [[nodiscard]] QSize dimensions() const { return dimensions_; }
+
+signals:
+    void uploadComplete(MediaUpload *self, QString url);
+    void uploadFailed(MediaUpload *self);
+
+public slots:
+    void startUpload();
+
+private slots:
+    void updateThumbnailUrl(QString url) { this->thumbnailUrl_ = std::move(url); }
+
+public:
+    // void uploadThumbnail(QImage img);
+
+    std::unique_ptr<QIODevice> source;
+    QByteArray data;
+    QString mimetype_;
+    QString mimeClass_;
+    QString originalFilename_;
+    QString blurhash_;
+    QString thumbnailUrl_;
+    QString url_;
+    std::optional<mtx::crypto::EncryptedFile> encryptedFile;
+
+    QSize dimensions_;
+    uint64_t size_ = 0;
+    bool encrypt_;
+};
+
 class InputBar : public QObject
 {
     Q_OBJECT
     Q_PROPERTY(bool uploading READ uploading NOTIFY uploadingChanged)
     Q_PROPERTY(bool containsAtRoom READ containsAtRoom NOTIFY containsAtRoomChanged)
     Q_PROPERTY(QString text READ text NOTIFY textChanged)
+    Q_PROPERTY(QVariantList uploads READ uploads NOTIFY uploadsChanged)
 
 public:
     explicit InputBar(TimelineModel *parent)
@@ -45,6 +122,8 @@ public:
         connect(&typingTimeout_, &QTimer::timeout, this, &InputBar::stopTyping);
     }
 
+    QVariantList uploads() const;
+
 public slots:
     [[nodiscard]] QString text() const;
     QString previousText();
@@ -65,15 +144,22 @@ public slots:
     void reaction(const QString &reactedEvent, const QString &reactionKey);
     void sticker(CombinedImagePackModel *model, int row);
 
+    void acceptUploads();
+    void declineUploads();
+
 private slots:
     void startTyping();
     void stopTyping();
 
+    void finalizeUpload(MediaUpload *upload, QString url);
+    void removeRunUpload(MediaUpload *upload);
+
 signals:
     void insertText(QString text);
     void textChanged(QString newText);
     void uploadingChanged(bool value);
     void containsAtRoomChanged();
+    void uploadsChanged();
 
 private:
     void emote(const QString &body, bool rainbowify);
@@ -102,7 +188,9 @@ private:
                const QString &mime,
                uint64_t dsize);
 
-    void showPreview(const QMimeData &source, const QString &path, const QStringList &formats);
+    void startUploadFromPath(const QString &path);
+    void startUploadFromMimeData(const QMimeData &source, const QString &format);
+    void startUpload(std::unique_ptr<QIODevice> dev, const QString &orgPath, const QString &format);
     void setUploading(bool value)
     {
         if (value != uploading_) {
@@ -121,4 +209,16 @@ private:
     int selectionStart = 0, selectionEnd = 0, cursorPosition = 0;
     bool uploading_      = false;
     bool containsAtRoom_ = false;
+
+    struct DeleteLaterDeleter
+    {
+        void operator()(QObject *p)
+        {
+            if (p)
+                p->deleteLater();
+        }
+    };
+    using UploadHandle = std::unique_ptr<MediaUpload, DeleteLaterDeleter>;
+    std::vector<UploadHandle> unconfirmedUploads;
+    std::vector<UploadHandle> runningUploads;
 };