diff --git a/package.json b/package.json index 3c6b0d99a..13bbfadbe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firebotv5", - "version": "5.67.0-alpha28", + "version": "5.67.0-alpha29", "description": "Powerful all-in-one bot for Twitch streamers.", "main": "build/main.js", "scripts": { diff --git a/src/backend/plugins/plugin-manager.ts b/src/backend/plugins/plugin-manager.ts index 437220b76..156dcd390 100644 --- a/src/backend/plugins/plugin-manager.ts +++ b/src/backend/plugins/plugin-manager.ts @@ -5,10 +5,13 @@ import { randomUUID, createHash } from "crypto"; import { app } from "electron"; import type { + CommunityPluginSearchCriteria, + CommunityPluginSearchResult, InstalledPlugin, InstalledPluginConfig, LegacyCustomScript, ManagedPlugin, + ManagedPluginBase, ManagedPluginExtended, ManagedPluginUpdateRequest, Manifest, @@ -23,6 +26,7 @@ import type { PluginApiContext, PluginApiContextSource } from "./plugin-api"; import type { DisposeBag } from "./plugin-api/internal/dispose-bag"; import { PluginConfigManager } from "./plugin-config-manager"; +import { AccountAccess } from "../common/account-access"; import { ProfileManager } from "../common/profile-manager"; import { SettingsManager } from "../common/settings-manager"; import webhookConfigManager from "../webhooks/webhook-config-manager"; @@ -174,8 +178,8 @@ class PluginManager { ); frontendCommunicator.onAsync("plugin-manager:search-community-plugins", - async (query: string) => { - return await this.searchForCommunityPlugins(query); + async (criteria: CommunityPluginSearchCriteria) => { + return await this.searchForCommunityPlugins(criteria); } ); @@ -1071,15 +1075,15 @@ class PluginManager { // #region Managed (Community) Plugins - private async searchForCommunityPlugins(query: string): Promise { + private async searchForCommunityPlugins(criteria: CommunityPluginSearchCriteria): Promise { const plugins: ManagedPluginExtended[] = []; + let total = 0; try { const firebotVersionString = app.getVersion(); - const firebotVersion = parseVersion(firebotVersionString); const body = { - query, - firebotVersion + ...criteria, + firebotVersion: parseVersion(firebotVersionString) }; const response = await fetch(`${COMMUNITY_PLUGIN_SERVICE_ROOT_URL}search`, { @@ -1092,9 +1096,10 @@ class PluginManager { }); if (response.ok) { - const pluginSearchResults = await response.json() as ManagedPlugin[]; + const searchResult = await response.json() as { items: ManagedPlugin[], total: number }; + total = searchResult.total; - for (const result of pluginSearchResults) { + for (const result of searchResult.items) { const installedPlugin = PluginConfigManager.getAllItems().find(c => c.managedPluginDetails?.author === result.author && c.managedPluginDetails?.name === result.name @@ -1109,14 +1114,14 @@ class PluginManager { } else { const responseBody = await response.text(); - this._logger.error(`Failed to search community plugins for "${query}". Response: ${responseBody}`); + this._logger.error(`Failed to search community plugins. Response: ${responseBody}`); frontendCommunicator.send("showToast", { content: "Failed to search for community plugins. Check the log for more details.", className: "warning" }); } } catch (error) { - this._logger.error(`Failed to search community plugins for "${query}"`, error); + this._logger.error("Failed to search community plugins", error); frontendCommunicator.send("showToast", { content: "Failed to search for community plugins. Check the log for more details.", className: "warning" @@ -1127,7 +1132,34 @@ class PluginManager { plugin.manifest = resolvePluginManifestLinks(plugin.manifest); } - return plugins; + return { items: plugins, total }; + } + + private trackCommunityPluginDownload(plugin: ManagedPluginBase): void { + void (async () => { + try { + const streamer = AccountAccess.getAccounts().streamer; + if (streamer?.loggedIn !== true) { + return; + } + + await fetch(`${COMMUNITY_PLUGIN_SERVICE_ROOT_URL}track-download`, { + method: "POST", + body: JSON.stringify({ + author: plugin.author, + name: plugin.name, + version: plugin.version + }), + headers: { + "User-Agent": `Firebot/${app.getVersion()}`, + "Content-Type": "application/json", + "Authorization": `Bearer ${streamer.auth.access_token}` + } + }); + } catch (error) { + this._logger.debug("Failed to track community plugin download", error); + } + })(); } private async downloadAndSaveCommunityPlugin( @@ -1264,6 +1296,8 @@ class PluginManager { void this.triggerUiRefresh(); + this.trackCommunityPluginDownload(plugin); + return { success: true, installedPlugin: { @@ -1358,6 +1392,8 @@ class PluginManager { void this.triggerUiRefresh(); + this.trackCommunityPluginDownload(pluginUpdate); + return { success: true, installedPlugin: { diff --git a/src/gui/app/directives/modals/plugins/community-plugin-browser-modal.js b/src/gui/app/directives/modals/plugins/community-plugin-browser-modal.js new file mode 100644 index 000000000..459f42436 --- /dev/null +++ b/src/gui/app/directives/modals/plugins/community-plugin-browser-modal.js @@ -0,0 +1,422 @@ +"use strict"; + +(function() { + const { DateTime } = require("luxon"); + const { PluginCategory, PluginCategoryLabels, PluginFeature, PluginFeatureLabels } = require("../../shared/plugin-constants"); + + const PAGE_SIZE = 20; + const SCROLL_THRESHOLD_PX = 300; + + angular + .module("firebotApp") + .component("communityPluginBrowserModal", { + template: ` + + + `, + bindings: { + resolve: "<", + close: "&", + dismiss: "&" + }, + controller: function( + $rootScope, + $scope, + $timeout, + pluginsService, + modalFactory, + modalService, + ngToast + ) { + const $ctrl = this; + + $ctrl.openLink = $rootScope.openLinkExternally; + + $ctrl.categories = Object.values(PluginCategory); + $ctrl.categoryLabels = PluginCategoryLabels; + $ctrl.activeCategory = null; + $ctrl.searchQuery = ""; + $ctrl.sortBy = "name"; + $ctrl.sortOptions = [ + { name: "Name (A-Z)", value: "name", icon: "fa-sort-alpha-down" }, + { name: "Popular", value: "popular", icon: "fa-fire" }, + { name: "Recently Updated", value: "recently-updated", icon: "fa-clock" } + ]; + + $ctrl.features = Object.values(PluginFeature); + $ctrl.featureLabels = PluginFeatureLabels; + $ctrl.selectedFeatures = {}; + $ctrl.filtersPopoverOpen = false; + + $ctrl.plugins = []; + $ctrl.total = 0; + $ctrl.isLoading = false; + $ctrl.isLoadingMore = false; + $ctrl.isInstallingPlugin = false; + + let currentPage = 1; + let requestId = 0; + + $ctrl.hasMore = () => $ctrl.plugins.length < $ctrl.total; + + const getSelectedFeatures = () => + Object.keys($ctrl.selectedFeatures).filter(f => $ctrl.selectedFeatures[f] === true); + + $ctrl.selectedFeatureCount = () => getSelectedFeatures().length; + + const fetchPage = async (page) => { + const thisRequest = ++requestId; + const features = getSelectedFeatures(); + + // "official" is a pseudo-category treated as a filter by the api + const officialSelected = $ctrl.activeCategory === "official"; + + const result = await pluginsService.searchCommunityPlugins({ + query: $ctrl.searchQuery?.trim() || undefined, + category: officialSelected ? undefined : ($ctrl.activeCategory ?? undefined), + official: officialSelected || undefined, + features: features.length ? features : undefined, + sortBy: $ctrl.sortBy, + page, + pageSize: PAGE_SIZE + }); + + // Discard stale responses from superseded requests + if (thisRequest !== requestId) { + return null; + } + + return result ?? { items: [], total: 0 }; + }; + + $ctrl.reload = async () => { + $ctrl.isLoading = true; + + const result = await fetchPage(1); + if (result == null) { + return; + } + + currentPage = 1; + $ctrl.plugins = result.items; + $ctrl.total = result.total; + $ctrl.isLoading = false; + + const listEl = document.getElementById("pluginBrowserList"); + if (listEl) { + listEl.scrollTop = 0; + } + }; + + $ctrl.loadMore = async () => { + if ($ctrl.isLoading || $ctrl.isLoadingMore || !$ctrl.hasMore()) { + return; + } + + $ctrl.isLoadingMore = true; + + const result = await fetchPage(currentPage + 1); + if (result != null) { + currentPage += 1; + $ctrl.plugins = $ctrl.plugins.concat(result.items); + $ctrl.total = result.total; + } + + $ctrl.isLoadingMore = false; + }; + + $ctrl.selectCategory = (category) => { + if ($ctrl.activeCategory !== category) { + $ctrl.activeCategory = category; + $ctrl.reload(); + } + }; + + $ctrl.setSortBy = (sortBy) => { + if ($ctrl.sortBy !== sortBy) { + $ctrl.sortBy = sortBy; + $ctrl.reload(); + } + }; + + $ctrl.onFeatureToggled = (feature, isSelected) => { + $ctrl.selectedFeatures[feature] = isSelected === true; + $ctrl.reload(); + }; + + $ctrl.clearFeatures = () => { + $ctrl.selectedFeatures = {}; + $ctrl.reload(); + }; + + $scope.$watch("$ctrl.searchQuery", (newValue, oldValue) => { + if (newValue !== oldValue) { + $ctrl.reload(); + } + }); + + $ctrl.$onInit = () => { + $ctrl.reload(); + + $timeout(() => { + const listEl = document.getElementById("pluginBrowserList"); + listEl?.addEventListener("scroll", () => { + if (listEl.scrollTop + listEl.clientHeight >= listEl.scrollHeight - SCROLL_THRESHOLD_PX) { + $scope.$applyAsync(() => $ctrl.loadMore()); + } + }); + + angular.element("#pluginBrowserSearch").trigger("focus"); + }, 100); + }; + + $ctrl.getPluginReleaseDate = (plugin) => { + if (plugin?.manifest?.releaseDate) { + const releaseDate = DateTime.fromISO(plugin.manifest.releaseDate); + return releaseDate.toFormat("MMMM d, yyyy"); + } + + return "Unknown"; + }; + + $ctrl.hasPluginLinks = function(plugin) { + const manifest = (plugin && plugin.manifest) || {}; + return !!(manifest.repo || manifest.website || manifest.support); + }; + + $ctrl.installCommunityPlugin = async (pluginDetails) => { + $ctrl.isInstallingPlugin = true; + pluginDetails.installing = true; + + const result = await pluginsService.installCommunityPlugin(pluginDetails); + + if (result.success === true) { + pluginDetails.installed = true; + pluginDetails.installedVersion = result.installedPlugin.config.managedPluginDetails.version; + + ngToast.create({ + className: "success", + content: `${result.installedPlugin.details.manifest.name} plugin installed!` + }); + + if (!!result.installedPlugin.details.parametersSchema?.length) { + modalService.showModal({ + component: "configurePluginModal", + size: "md", + resolveObj: { + plugin: () => result.installedPlugin + } + }); + } + } else { + modalFactory.showErrorModal(result.error); + } + + pluginDetails.installing = false; + $ctrl.isInstallingPlugin = false; + }; + } + }); +}()); diff --git a/src/gui/app/directives/modals/plugins/install-community-plugin-modal.js b/src/gui/app/directives/modals/plugins/install-community-plugin-modal.js deleted file mode 100644 index d8fde0a17..000000000 --- a/src/gui/app/directives/modals/plugins/install-community-plugin-modal.js +++ /dev/null @@ -1,247 +0,0 @@ -"use strict"; - -(function() { - const { DateTime } = require("luxon"); - - angular - .module("firebotApp") - .component("installCommunityPluginModal", { - template: ` -
- -
- -
-
- - `, - bindings: { - resolve: "<", - close: "&", - dismiss: "&" - }, - controller: function( - $rootScope, - $scope, - pluginsService, - modalFactory, - modalService, - ngToast - ) { - const $ctrl = this; - - $ctrl.openLink = $rootScope.openLinkExternally; - - $ctrl.isSearching = false; - $ctrl.searchPerformed = false; - $ctrl.searchQuery = ""; - $ctrl.searchResults = []; - $ctrl.isInstallingPlugin = false; - - $ctrl.performSearch = async () => { - if (!$ctrl.searchQuery?.length) { - return; - } - - $ctrl.isSearching = true; - - const results = await pluginsService.searchCommunityPlugins($ctrl.searchQuery); - $ctrl.searchResults = results ?? []; - - $ctrl.searchPerformed = true; - $ctrl.isSearching = false; - }; - - $scope.$watch("$ctrl.searchQuery", (newValue, oldValue) => { - if (newValue !== oldValue) { - if (!!newValue?.length) { - $ctrl.performSearch(); - } else if (newValue === "") { - $ctrl.searchPerformed = false; - $ctrl.searchResults = []; - } - } - }); - - $ctrl.getPluginReleaseDate = (plugin) => { - if (plugin?.manifest?.releaseDate) { - const releaseDate = DateTime.fromISO(plugin.manifest.releaseDate); - return releaseDate.toFormat("MMMM d, yyyy"); - } - - return "Unknown"; - }; - - $ctrl.hasPluginLinks = function(plugin) { - const manifest = (plugin && plugin.manifest) || {}; - return !!(manifest.repo || manifest.website || manifest.support); - }; - - $ctrl.installCommunityPlugin = async (pluginDetails) => { - $ctrl.isInstallingPlugin = true; - pluginDetails.installing = true; - - const result = await pluginsService.installCommunityPlugin(pluginDetails); - - if (result.success === true) { - pluginDetails.installed = true; - pluginDetails.installedVersion = result.installedPlugin.config.managedPluginDetails.version; - - ngToast.create({ - className: "success", - content: `${result.installedPlugin.details.manifest.name} plugin installed!` - }); - - if (!!result.installedPlugin.details.parametersSchema?.length) { - modalService.showModal({ - component: "configurePluginModal", - size: "md", - resolveObj: { - plugin: () => result.installedPlugin - } - }); - } - } else { - modalFactory.showErrorModal(result.error); - } - - pluginDetails.installing = false; - $ctrl.isInstallingPlugin = false; - }; - } - }); -}()); \ No newline at end of file diff --git a/src/gui/app/directives/settings/categories/plugin-settings.js b/src/gui/app/directives/settings/categories/plugin-settings.js index a9ac3f86b..459b266b9 100644 --- a/src/gui/app/directives/settings/categories/plugin-settings.js +++ b/src/gui/app/directives/settings/categories/plugin-settings.js @@ -406,7 +406,7 @@ $scope.showCommunityPluginModal = () => { modalService.showModal({ - component: "installCommunityPluginModal", + component: "communityPluginBrowserModal", size: "lg" }); }; diff --git a/src/gui/app/services/plugins.service.js b/src/gui/app/services/plugins.service.js index b78b3eeaa..2ba92d1a5 100644 --- a/src/gui/app/services/plugins.service.js +++ b/src/gui/app/services/plugins.service.js @@ -83,8 +83,8 @@ ); }; - service.searchCommunityPlugins = async (searchQuery) => { - return await backendCommunicator.fireEventAsync("plugin-manager:search-community-plugins", searchQuery); + service.searchCommunityPlugins = async (criteria) => { + return await backendCommunicator.fireEventAsync("plugin-manager:search-community-plugins", criteria); }; service.installCommunityPlugin = async (pluginDetails) => { diff --git a/src/gui/scss/core/_scripts.scss b/src/gui/scss/core/_scripts.scss index 6a8cd2956..585e67cb9 100644 --- a/src/gui/scss/core/_scripts.scss +++ b/src/gui/scss/core/_scripts.scss @@ -27,3 +27,112 @@ font-size: 12px; } } + +.plugin-browser-header { + background: $light-background-color; +} + +.plugin-browser-toolbar { + display: flex; + align-items: center; + gap: 10px; + background: $light-background-color; + border-bottom: 2px solid $light-background-border; + padding: 8px 13px; +} + +.plugin-browser-categories { + background: $effect-category-bg; + display: flex; + flex-direction: column; + flex-shrink: 0; + height: 100%; + width: 170px; + padding: 5px; + overflow-y: auto; +} + +.plugin-browser-category-header { + font-weight: 700; + padding: 5px 0 5px 15px; + font-size: 13px; +} + +.plugin-browser-category { + font-weight: 300; + display: flex; + flex-direction: row; + align-items: center; + padding: 7px 10px; + border-radius: 10px; + cursor: pointer; + + &:hover { + background: $effect-category-hover-bg; + text-decoration: none; + } + + &.selected { + font-weight: 400; + background: $effect-category-selected-bg; + } +} + +.plugin-browser-list { + width: 100%; + height: 100%; + overflow-y: scroll; + padding: 15px; + background: $effect-list-bg-color; +} + +.plugin-filters-badge { + position: absolute; + top: -6px; + right: -6px; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: $primary-button-bg; + color: #fff; + font-size: 11px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.plugin-filters-popover { + min-width: 215px; + + .popover-content { + padding: 10px 12px; + } +} + +.plugin-filters-section-header { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 6px; +} + +.plugin-filters-sort-option { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 6px; + cursor: pointer; + + &:hover { + background: $effect-category-hover-bg; + } + + &.selected { + background: $effect-category-selected-bg; + } +} diff --git a/src/shared/plugin-constants.js b/src/shared/plugin-constants.js new file mode 100644 index 000000000..7031947c9 --- /dev/null +++ b/src/shared/plugin-constants.js @@ -0,0 +1,64 @@ +"use strict"; + +/** + * Enum for community plugin categories. + * @readonly + * @enum {string} + */ +const PluginCategory = Object.freeze({ + STREAM_SERVICES: "stream-services", + SOCIAL: "social", + MUSIC_AND_MEDIA: "music-media", + GAMES: "games", + OVERLAYS: "overlays", + TOOLS_AND_UTILITIES: "tools-utilities" +}); + +/** + * Display labels for community plugin categories. + * @readonly + */ +const PluginCategoryLabels = Object.freeze({ + [PluginCategory.STREAM_SERVICES]: "Stream Services", + [PluginCategory.SOCIAL]: "Social", + [PluginCategory.MUSIC_AND_MEDIA]: "Music & Media", + [PluginCategory.GAMES]: "Games/Gaming", + [PluginCategory.OVERLAYS]: "Overlays", + [PluginCategory.TOOLS_AND_UTILITIES]: "Tools & Utilities" +}); + +/** + * Enum for Firebot features a community plugin can provide. + * @readonly + * @enum {string} + */ +const PluginFeature = Object.freeze({ + EFFECTS: "effects", + EVENTS: "events", + VARIABLES: "variables", + INTEGRATIONS: "integrations", + OVERLAY_WIDGETS: "overlay-widgets", + GAMES: "games", + COMMANDS: "commands", + UI_EXTENSIONS: "ui-extensions" +}); + +/** + * Display labels for community plugin features. + * @readonly + */ +const PluginFeatureLabels = Object.freeze({ + [PluginFeature.EFFECTS]: "Effects", + [PluginFeature.EVENTS]: "Events", + [PluginFeature.VARIABLES]: "Variables", + [PluginFeature.INTEGRATIONS]: "Integrations", + [PluginFeature.OVERLAY_WIDGETS]: "Overlay Widgets", + [PluginFeature.GAMES]: "Games", + [PluginFeature.COMMANDS]: "Commands", + [PluginFeature.UI_EXTENSIONS]: "UI Extensions" +}); + +exports.PluginCategory = PluginCategory; +exports.PluginCategoryLabels = PluginCategoryLabels; +exports.PluginFeature = PluginFeature; +exports.PluginFeatureLabels = PluginFeatureLabels; diff --git a/src/types/plugins.ts b/src/types/plugins.ts index 3dabe1c7f..943004f97 100644 --- a/src/types/plugins.ts +++ b/src/types/plugins.ts @@ -43,6 +43,24 @@ type CustomIcon = { export type PluginIcon = FontAwesomeIcon | CustomIcon; +export type PluginCategory = + | "stream-services" + | "social" + | "music-media" + | "games" + | "overlays" + | "tools-utilities"; + +export type PluginFeature = + | "effects" + | "events" + | "variables" + | "integrations" + | "overlay-widgets" + | "games" + | "commands" + | "ui-extensions"; + export type ManagedPluginManifest = { name: string; author: string; @@ -56,6 +74,8 @@ export type ManagedPluginManifest = { type: "single-file" | "zip"; icon?: PluginIcon; + category?: PluginCategory; + features?: PluginFeature[]; tags?: string[]; repo?: string; website?: string; @@ -84,6 +104,23 @@ export type ManagedPluginUpdateRequest = { firebotVersion: ManifestFirebotVersion; }; +export type CommunityPluginSearchSortMode = "popular" | "recently-updated" | "name"; + +export type CommunityPluginSearchCriteria = { + query?: string; + category?: PluginCategory; + features?: PluginFeature[]; + official?: boolean; + sortBy: CommunityPluginSearchSortMode; + page: number; + pageSize: number; +}; + +export type CommunityPluginSearchResult = { + items: ManagedPluginExtended[]; + total: number; +}; + export type InstalledPluginConfig = { id: string; fileName: string;