diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index 4b2afe9f2a..75b4611f0b 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -187,6 +187,8 @@ set(ayugram_files ayu/ui/boxes/donate_qr_box.h ayu/ui/boxes/donate_info_box.cpp ayu/ui/boxes/donate_info_box.h + ayu/ui/boxes/plugin_info_box.cpp + ayu/ui/boxes/plugin_info_box.h ayu/ui/components/image_view.cpp ayu/ui/components/image_view.h ayu/ui/components/icon_picker.cpp diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 465ea2a3a9..900448b117 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -7856,6 +7856,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "ayu_ContextCopyCallbackData" = "Copy Callback Data"; "ayu_RepeatMessage" = "Repeat Message"; "ayu_RegisterURLScheme" = "Register URL Scheme"; +"ayu_PluginVersion" = "Version"; +"ayu_PluginNoDescription" = "No description provided."; +"ayu_PluginNoAuthor" = "Unknown Author"; +"ayu_PluginsNotAvailable" = "Plugins are not supported by AyuGram Desktop yet."; "ayu_SessionTerminated" = "Session **{item}** was terminated. You may still browse cached messages."; "ayu_InvalidConnectionLayerNote" = "You should re-login due to an error during the update."; "ayu_LocalPremiumNotice" = "You're using **local** Telegram Premium.\nIt **won't** give you any benefits.\n**Enjoy the star near your nickname!**"; diff --git a/Telegram/SourceFiles/ayu/ui/ayu_styles.style b/Telegram/SourceFiles/ayu/ui/ayu_styles.style index f99ab10677..ea972ea2ee 100644 --- a/Telegram/SourceFiles/ayu/ui/ayu_styles.style +++ b/Telegram/SourceFiles/ayu/ui/ayu_styles.style @@ -88,3 +88,22 @@ registrationDateImportantTooltipLabel: FlatLabel(defaultImportantTooltipLabel) { font: font(12px); } } + +pluginShowInFolderIcon: icon {{ "menu/show_in_folder", boxTitleCloseFg }}; +pluginShowInFolderIconOver: icon {{ "menu/show_in_folder", boxTitleCloseFgOver }}; +pluginShowInFolder: IconButton(defaultIconButton) { + width: boxTitleHeight; + height: boxTitleHeight; + + icon: pluginShowInFolderIcon; + iconOver: pluginShowInFolderIconOver; + + rippleAreaPosition: point(4px, 4px); + rippleAreaSize: 40px; + ripple: defaultRippleAnimationBgOver; +} +maxPluginDescriptionHeight: 400px; +pluginVersionAuthorLabel: FlatLabel(boxLabel) { + align: align(center); +} +pluginChipSkip: 6px; \ No newline at end of file diff --git a/Telegram/SourceFiles/ayu/ui/boxes/plugin_info_box.cpp b/Telegram/SourceFiles/ayu/ui/boxes/plugin_info_box.cpp new file mode 100644 index 0000000000..d45a6080ee --- /dev/null +++ b/Telegram/SourceFiles/ayu/ui/boxes/plugin_info_box.cpp @@ -0,0 +1,488 @@ +// This is the source code of AyuGram for Desktop. +// +// We do not and cannot prevent the use of our code, +// but be respectful and credit the original author. +// +// Copyright @Radolyn, 2025 +#include "ayu/ui/boxes/plugin_info_box.h" + +#include "apiwrap.h" +#include "core/file_utilities.h" +#include "core/ui_integration.h" +#include "data/data_document.h" +#include "data/data_document_media.h" +#include "data/data_file_origin.h" +#include "data/data_session.h" +#include "data/stickers/data_stickers_set.h" +#include "lang/lang_keys.h" +#include "chat_helpers/stickers_lottie.h" +#include "history/view/media/history_view_sticker_player.h" +#include "main/main_session.h" +#include "styles/style_ayu_styles.h" +#include "styles/style_boxes.h" +#include "styles/style_chat.h" +#include "styles/style_giveaway.h" +#include "styles/style_layers.h" +#include "styles/style_premium.h" +#include "ui/painter.h" +#include "ui/rect.h" +#include "ui/vertical_list.h" +#include "ui/image/image_prepare.h" +#include "ui/layers/generic_box.h" +#include "ui/text/text_utilities.h" +#include "ui/widgets/buttons.h" +#include "ui/widgets/labels.h" +#include "ui/widgets/scroll_area.h" +#include "window/window_session_controller.h" + +#include +#include + + +namespace Ui { +namespace { + +QString ExtractField(const QByteArray &data, const QString &key) { + const auto re = QRegularExpression( + u"__%1__\\s*=\\s*[\"']((?:\\\\.|[^\"'\\\\])*)[\"']"_q.arg(key)); + const auto match = re.match(QString::fromUtf8(data)); + if (!match.hasMatch()) { + return {}; + } + auto result = match.captured(1); + result.replace(u"\\n"_q, u"\n"_q); + result.replace(u"\\t"_q, u"\t"_q); + result.replace(u"\\\""_q, u"\""_q); + result.replace(u"\\'"_q, u"'"_q); + result.replace(u"\\\\"_q, u"\\"_q); + return result; +} + +void FillPluginInfoBox( + not_null box, + not_null controller, + const QString &pluginPath, + PluginMetadata metadata, + DocumentData *stickerDoc) { + box->setStyle(st::giveawayGiftCodeBox); + box->setNoContentMargin(true); + box->setWidth(st::aboutWidth); + box->verticalLayout()->resizeToWidth(box->width()); + + box->addTopButton(st::pluginShowInFolder, + [=] + { + File::ShowInFolder(pluginPath); + }); + + Ui::AddSkip(box->verticalLayout()); + Ui::AddSkip(box->verticalLayout()); + Ui::AddSkip(box->verticalLayout()); + + if (stickerDoc) { + const auto stickerSize = st::maxStickerSize / 3; + const auto radius = stickerSize / 5; + const auto widget = box->verticalLayout()->add( + object_ptr(box->verticalLayout())); + widget->resize(Size(stickerSize)); + + const auto media = stickerDoc->createMediaView(); + media->checkStickerLarge(); + const auto sticker = stickerDoc->sticker(); + + const auto player = widget->lifetime().make_state< + std::unique_ptr>(); + + const auto createPlayer = [=] { + if (sticker->isLottie()) { + *player = std::make_unique( + ChatHelpers::LottiePlayerFromDocument( + media.get(), + ChatHelpers::StickerLottieSize::StickerSet, + Size(stickerSize), + Lottie::Quality::High)); + } else if (sticker->isWebm()) { + *player = std::make_unique( + media->owner()->location(), + media->bytes(), + Size(stickerSize)); + } else { + *player = std::make_unique( + media->owner()->location(), + media->bytes(), + Size(stickerSize)); + } + (*player)->setRepaintCallback([=] { widget->update(); }); + }; + + if (media->loaded()) { + createPlayer(); + } else { + rpl::single() | rpl::then( + stickerDoc->owner().session().downloaderTaskFinished() + ) | rpl::filter([=] { + return media->loaded(); + }) | rpl::take(1) | rpl::on_next([=] { + createPlayer(); + }, widget->lifetime()); + } + + widget->paintRequest( + ) | rpl::on_next([=] { + if (!*player || !(*player)->ready()) { + return; + } + const auto frame = (*player)->frame( + Size(stickerSize), + QColor(0, 0, 0, 0), + false, + crl::now(), + false); + if (frame.image.isNull()) { + return; + } + auto p = QPainter(widget); + PainterHighQualityEnabler hq(p); + const auto x = (widget->width() - stickerSize) / 2; + QPainterPath path; + path.addRoundedRect( + QRectF(x, 0, stickerSize, stickerSize), + radius, + radius); + p.setClipPath(path); + p.drawImage( + QRect(x, 0, stickerSize, stickerSize), + frame.image); + (*player)->markFrameShown(); + }, widget->lifetime()); + + Ui::AddSkip(box->verticalLayout()); + Ui::AddSkip(box->verticalLayout()); + } + + box->verticalLayout()->add( + object_ptr( + box->verticalLayout(), + rpl::single(metadata.name), + st::boxTitle), + st::boxRowPadding, + style::al_top); + + { + const auto versionLabel = box->verticalLayout()->add( + object_ptr( + box->verticalLayout(), + st::pluginVersionAuthorLabel), + st::boxRowPadding, + style::al_justify); + + const auto versionPrefix = tr::ayu_PluginVersion(tr::now) + + u" "_q + + metadata.version; + + if (metadata.author.isEmpty()) { + versionLabel->setText( + versionPrefix + + u" \u2022 "_q + + tr::ayu_PluginNoAuthor(tr::now)); + } else { + auto text = TextWithEntities{ + versionPrefix + u" \u2022 "_q, + }; + static const auto usernameRe = QRegularExpression( + u"@([A-Za-z0-9_]+)"_q); + const auto &author = metadata.author; + auto it = usernameRe.globalMatch(author); + auto lastEnd = 0; + while (it.hasNext()) { + const auto match = it.next(); + if (match.capturedStart() > lastEnd) { + text.append(TextWithEntities{ + author.mid(lastEnd, + match.capturedStart() - lastEnd) + }); + } + text.append(Ui::Text::Link( + match.captured(0), + controller->session().createInternalLinkFull( + match.captured(1)))); + lastEnd = match.capturedEnd(); + } + if (lastEnd < author.size()) { + text.append(TextWithEntities{ + author.mid(lastEnd) + }); + } + if (lastEnd == 0) { + text.append(TextWithEntities{author}); + } + versionLabel->setMarkedText( + text, + Core::TextContext({ + .session = &controller->session(), + })); + } + } + + Ui::AddSkip(box->verticalLayout()); + Ui::AddSkip(box->verticalLayout()); + + { + const auto hasDescription = !metadata.description.isEmpty(); + auto descText = hasDescription + ? TextWithEntities{metadata.description} + : TextWithEntities{tr::ayu_PluginNoDescription(tr::now)}; + if (hasDescription) { + TextUtilities::ParseEntities( + descText, + TextParseLinks | TextParseMentions | TextParseMarkdown); + } + + const auto availableWidth = box->width() + - st::boxRowPadding.left() + - st::boxRowPadding.right(); + + const auto container = box->verticalLayout()->add( + object_ptr(box->verticalLayout()), + st::boxRowPadding); + + const auto scroll = Ui::CreateChild( + container, + st::boxScroll); + const auto descLabel = scroll->setOwnedWidget( + object_ptr(scroll, st::boxLabel)); + descLabel->setMarkedText( + descText, + Core::TextContext({ + .session = &controller->session(), + })); + descLabel->resizeToWidth(availableWidth); + + const auto needsScroll = + descLabel->height() > st::maxPluginDescriptionHeight; + const auto scrollPad = needsScroll + ? (st::boxScroll.width + st::lineWidth) + : 0; + if (needsScroll) { + descLabel->resizeToWidth(availableWidth - scrollPad); + } + + const auto containerHeight = std::min( + descLabel->height(), + st::maxPluginDescriptionHeight); + container->resize(availableWidth, containerHeight); + + container->sizeValue( + ) | rpl::on_next([=](QSize size) + { + scroll->setGeometry(0, 0, size.width(), size.height()); + descLabel->resizeToWidth(size.width() - scrollPad); + }, + container->lifetime()); + + Ui::AddSkip(box->verticalLayout()); + } + + if (!metadata.requirements.isEmpty()) { + const auto chipsContainer = box->verticalLayout()->add( + object_ptr(box->verticalLayout()), + st::boxRowPadding); + + auto chips = std::vector(); + for (const auto &dep : metadata.requirements) { + const auto chip = Ui::CreateChild( + chipsContainer, + rpl::single(dep), + st::defaultTableSmallButton); + chip->setTextTransform( + Ui::RoundButton::TextTransform::NoTransform); + chip->setClickedCallback([dep] { + File::OpenUrl( + u"https://pypi.org/project/"_q + dep + u"/"_q); + }); + chips.push_back(chip); + } + + const auto chipsPtr = chipsContainer->lifetime().make_state< + std::vector>(std::move(chips)); + + chipsContainer->sizeValue( + ) | rpl::on_next([=](QSize size) + { + const auto skip = st::pluginChipSkip; + auto x = 0; + auto y = 0; + auto widthLeft = size.width(); + auto firstRow = true; + for (const auto chip : *chipsPtr) { + const auto chipWidth = chip->width(); + const auto chipHeight = chip->height(); + if (!firstRow && chipWidth > widthLeft) { + x = 0; + y += chipHeight + skip; + widthLeft = size.width(); + } + firstRow = false; + chip->moveToLeft(x, y); + x += chipWidth + skip; + widthLeft = size.width() - x; + } + if (!chipsPtr->empty()) { + const auto lastChip = chipsPtr->back(); + chipsContainer->resize( + size.width(), + lastChip->y() + lastChip->height()); + } + }, + chipsContainer->lifetime()); + + Ui::AddSkip(box->verticalLayout()); + } + + Ui::AddSkip(box->verticalLayout()); + + box->verticalLayout()->add( + object_ptr( + box->verticalLayout(), + tr::ayu_PluginsNotAvailable(), + st::boxDividerLabel), + st::boxRowPadding); + + Ui::AddSkip(box->verticalLayout()); + + const auto closeButton = box->addButton( + tr::lng_close(), + [=] { box->closeBox(); }); + const auto buttonWidth = box->width() + - rect::m::sum::h(st::starrefFooterBox.buttonPadding); + closeButton->widthValue() | rpl::filter([=] + { + return (closeButton->widthNoMargins() != buttonWidth); + }) | rpl::on_next([=] + { + closeButton->resizeToWidth(buttonWidth); + }, + closeButton->lifetime()); +} + +} + +PluginMetadata ParsePluginMetadata(const QByteArray &data) { + auto metadata = PluginMetadata(); + + metadata.id = ExtractField(data, u"id"_q); + metadata.name = ExtractField(data, u"name"_q).left(100); + metadata.description = ExtractField(data, u"description"_q).left(8000); + metadata.author = ExtractField(data, u"author"_q).left(200); + metadata.version = ExtractField(data, u"version"_q).left(32); + metadata.icon = ExtractField(data, u"icon"_q); + metadata.minVersion = ExtractField(data, u"min_version"_q); + + const auto reqString = ExtractField(data, u"requirements"_q); + if (!reqString.isEmpty()) { + for (const auto &part : reqString.split(',')) { + const auto trimmed = part.trimmed(); + if (!trimmed.isEmpty()) { + metadata.requirements.append(trimmed); + } + } + } + + if (metadata.version.isEmpty()) { + metadata.version = u"1.0"_q; + } + + static const auto idRegex = QRegularExpression( + u"^[a-zA-Z][a-zA-Z0-9_-]{1,31}$"_q); + if (metadata.id.isEmpty() + || !idRegex.match(metadata.id).hasMatch()) { + return {}; + } + + if (metadata.name.isEmpty()) { + return {}; + } + + return metadata; +} + +void ShowPluginInfoBox( + not_null controller, + const QString &pluginPath, + PluginMetadata metadata) { + const auto showBox = [controller, pluginPath]( + PluginMetadata md, + DocumentData *stickerDoc) + { + controller->show(Box( + FillPluginInfoBox, + controller, + pluginPath, + std::move(md), + stickerDoc)); + }; + + if (metadata.icon.isEmpty()) { + showBox(std::move(metadata), nullptr); + return; + } + + const auto parts = metadata.icon.split('/'); + if (parts.size() != 2) { + showBox(std::move(metadata), nullptr); + return; + } + + const auto shortName = parts[0]; + const auto index = parts[1].toInt(); + const auto session = &controller->session(); + const auto shared = std::make_shared( + std::move(metadata)); + + session->api().request(MTPmessages_GetStickerSet( + Data::InputStickerSet({ + .shortName = shortName, + }), + MTP_int(0) + )).done([=](const MTPmessages_StickerSet &result) + { + auto doc = (DocumentData*) nullptr; + result.match([&](const MTPDmessages_stickerSet &data) + { + const auto &v = data.vdocuments().v; + if (index >= 0 && index < v.size()) { + doc = session->data().processDocument(v[index]); + } + }, + [](const MTPDmessages_stickerSetNotModified &) + { + }); + + if (!doc) { + showBox(std::move(*shared), nullptr); + return; + } + + const auto media = doc->createMediaView(); + media->checkStickerLarge(); + + if (media->loaded()) { + showBox(std::move(*shared), doc); + return; + } + + rpl::single() | rpl::then( + session->downloaderTaskFinished() + ) | rpl::filter([=] { + return media->loaded(); + }) | rpl::take(1) | rpl::on_next([=] { + showBox(std::move(*shared), doc); + }, session->lifetime()); + media->automaticLoad(doc->stickerSetOrigin(), nullptr); + }).fail([=](const MTP::Error &) + { + showBox(std::move(*shared), nullptr); + }).send(); +} + +} diff --git a/Telegram/SourceFiles/ayu/ui/boxes/plugin_info_box.h b/Telegram/SourceFiles/ayu/ui/boxes/plugin_info_box.h new file mode 100644 index 0000000000..c712379318 --- /dev/null +++ b/Telegram/SourceFiles/ayu/ui/boxes/plugin_info_box.h @@ -0,0 +1,37 @@ +// This is the source code of AyuGram for Desktop. +// +// We do not and cannot prevent the use of our code, +// but be respectful and credit the original author. +// +// Copyright @Radolyn, 2025 +#pragma once + +#include + +namespace Window { +class SessionController; +} + +namespace Ui { + +class GenericBox; + +struct PluginMetadata { + QString id; + QString name; + QString description; + QString author; + QString version; + QString icon; + QString minVersion; + QStringList requirements; +}; + +[[nodiscard]] PluginMetadata ParsePluginMetadata(const QByteArray &data); + +void ShowPluginInfoBox( + not_null controller, + const QString& pluginPath, + PluginMetadata metadata); + +} // namespace Ui diff --git a/Telegram/SourceFiles/data/data_document_resolver.cpp b/Telegram/SourceFiles/data/data_document_resolver.cpp index fc83ca8e39..441bda6298 100644 --- a/Telegram/SourceFiles/data/data_document_resolver.cpp +++ b/Telegram/SourceFiles/data/data_document_resolver.cpp @@ -36,6 +36,11 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include #include +// AyuGram includes +#include "ayu/ui/boxes/plugin_info_box.h" +#include + + namespace Data { namespace { @@ -239,6 +244,34 @@ void ResolveDocument( } return false; }; + const auto openPluginInfo = [&] { + // image size limit is fine too ig (64MB) + if (document->size >= Images::kReadBytesLimit) { + return false; + } + if (controller + && document->filename().endsWith( + u".plugin"_q, + Qt::CaseInsensitive) + && !document->filepath(true).isEmpty()) { + const auto path = document->location(true).name(); + auto file = QFile(path); + if (file.open(QIODevice::ReadOnly)) { + const auto data = file.readAll(); + file.close(); + auto metadata = Ui::ParsePluginMetadata(data); + if (!metadata.id.isEmpty() + && !metadata.name.isEmpty()) { + Ui::ShowPluginInfoBox( + controller, + path, + std::move(metadata)); + return true; + } + } + } + return false; + }; const auto &location = document->location(true); if (document->isTheme() && media->loaded(true)) { showDocument(); @@ -259,7 +292,7 @@ void ResolveDocument( } } else { document->saveFromDataSilent(); - if (!openImageInApp()) { + if (!openPluginInfo() && !openImageInApp()) { if (!document->filepath(true).isEmpty()) { LaunchWithWarning(location.name(), item); } else if (document->status == FileReady