diff --git a/app/assets/stylesheets/common/components/_index.scss b/app/assets/stylesheets/common/components/_index.scss index 9190ef1c9c8..ecaa07daa6e 100644 --- a/app/assets/stylesheets/common/components/_index.scss +++ b/app/assets/stylesheets/common/components/_index.scss @@ -79,6 +79,7 @@ @import "welcome-header"; @import "notifications-tracking"; @import "emoji-picker"; +@import "gifs"; @import "d-icon-grid-picker"; @import "filter-input"; @import "filter-navigation"; diff --git a/app/assets/stylesheets/common/components/gifs.scss b/app/assets/stylesheets/common/components/gifs.scss new file mode 100644 index 00000000000..9845fbab1cb --- /dev/null +++ b/app/assets/stylesheets/common/components/gifs.scss @@ -0,0 +1,104 @@ +.gifs-modal { + .d-modal__container { + --modal-max-width: 920px; + } + + &__input { + background: var(--primary-very-low); + padding: 0.5em; + position: relative; + + > input { + width: 100%; + margin-right: 1em; + margin-bottom: 0; + } + + .spinner { + position: absolute; + right: 1.2em; + top: 0.9em; + } + } + + &__content { + overflow-y: auto; + min-width: 320px; + max-height: 65vh; + margin-top: 0.5em; + + @media screen and (width >= 768px) { + min-width: 650px; + } + + @media screen and (width >= 1200px) { + min-width: 860px; + } + } + + &__box { + .gifs-result-list { + position: relative; + + .gifs-result { + position: absolute; + } + } + + div.loading-container { + display: block; + height: 10px; + } + } + + &__categories-header { + margin: 0.5em 0; + font-size: var(--font-0); + color: var(--primary-medium); + font-weight: normal; + } + + &__loading-categories { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; + } + + &__no-results { + padding: 3em 1em; + font-size: var(--font-up-2); + color: var(--primary-low-mid); + background: var(--primary-very-low); + font-weight: bold; + text-align: center; + } +} + +.gifs-result { + &__img { + width: 100%; + height: auto; + cursor: pointer; + } + + &--category { + .gifs-result__img { + object-fit: cover; + } + } + + &__category-label { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 0.5em; + background: linear-gradient(transparent, rgb(0, 0, 0, 0.7)); + color: white; + font-weight: bold; + font-size: var(--font-down-1); + text-align: center; + cursor: pointer; + } +} diff --git a/app/models/site_setting.rb b/app/models/site_setting.rb index 945ba9b5aba..1eb3a90302d 100644 --- a/app/models/site_setting.rb +++ b/app/models/site_setting.rb @@ -18,6 +18,7 @@ class SiteSetting < ActiveRecord::Base experimental flags fonts + gifs group_permissions interface legal diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 2948edfad05..d1e60103d02 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3143,6 +3143,15 @@ en: confirm_publish: "Are you sure you want to publish this draft?" publishing: "Publishing Topic…" + gifs: + modal_title: "Search GIFs" + composer_title: "Insert GIF" + no_results: "Enter a keyword above to search for GIFs." + browse_categories: "Browse by category" + bad_api_key: "Klipy API key is missing or invalid. Please check the gifs API key setting." + error_rate_limit: "We have reached the GIF API rate limits. Please wait a moment and try again." + error_search_too_long: "Please shorten your search query to under 50 characters and try again." + composer: autocomplete: username_missing: "Selected autocomplete result is missing username." @@ -6614,6 +6623,9 @@ en: notifications: title: "Notifications" header_description: "Configure how notifications are managed and delivered for users, including email preferences, push notifications, mention limits, and notification consolidation" + gifs: + title: "GIFs" + header_description: "Configure the GIF picker that lets users search and insert GIFs into posts and chat messages. Site texts for the modal can be customized in customize/site_texts." onebox: title: "Onebox" header_description: "Configure how onebox previews are generated and displayed for your site" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 9dc64a1552e..0b4d772e875 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -2664,6 +2664,13 @@ en: enable_inline_emoji_translation: "Enables translation for inline emojis (without any space or punctuation before)" emoji_deny_list: "These emoji will not be available to use in menus or shortcodes." enable_gifs: "Adds a button to easily search and insert GIFs in posts." + klipy_api_key: "Klipy API key. Required to power GIF search. Note: this key is exposed to anyone viewing your site." + klipy_file_detail: "Image format to use. WebP has smaller files that load quicker, while GIF provides compatibility with older browsers." + klipy_content_filter: "Content safety level for GIF search results." + klipy_country: "Two-letter country code for GIF search results." + klipy_locale: "Language code for GIF search results, in xx_YY format (language plus optional country)." + klipy_limit_infinite_search_results: "Limit the number of GIF results returned as users scroll, to avoid hitting API rate limits." + klipy_max_results_limit: "When 'limit infinite search results' is enabled, stop loading after this many results. Each API call retrieves 24 GIFs." approve_post_count: "The amount of posts from a new or basic user that must be approved" approve_unless_allowed_groups: "Posts created by users not in these groups must be approved. Posts created by admins and moderators are always approved." diff --git a/config/routes.rb b/config/routes.rb index 757471cf94a..b087ea1a7a6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -435,6 +435,7 @@ Discourse::Application.routes.draw do # Needed for back-end routing to work. # + get "gifs" => "site_settings#index" get "navigation" => "site_settings#index" get "notifications" => "site_settings#index" get "rate-limits" => "site_settings#index" diff --git a/config/site_settings.yml b/config/site_settings.yml index 04332efc199..feaf483f7ff 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -1636,6 +1636,50 @@ posting: default: false client: true hidden: true + area: "gifs" + upcoming_change: + status: "experimental" + impact: "feature,all_members" + learn_more_url: "https://meta.discourse.org/t/-/404829" + klipy_api_key: + default: "" + client: true + area: "gifs" + klipy_file_detail: + default: "webp" + client: true + area: "gifs" + type: enum + choices: + - webp + - gif + klipy_content_filter: + default: "high" + client: true + area: "gifs" + type: enum + choices: + - high + - medium + - low + - "off" + klipy_country: + default: "US" + client: true + area: "gifs" + klipy_locale: + default: "en_US" + client: true + area: "gifs" + klipy_limit_infinite_search_results: + default: false + client: true + area: "gifs" + klipy_max_results_limit: + default: 240 + client: true + area: "gifs" + min: 24 approve_post_count: default: 0 area: "posts_and_topics" diff --git a/frontend/discourse/admin/controllers/admin-config/gifs/settings.js b/frontend/discourse/admin/controllers/admin-config/gifs/settings.js new file mode 100644 index 00000000000..14d6b3a167d --- /dev/null +++ b/frontend/discourse/admin/controllers/admin-config/gifs/settings.js @@ -0,0 +1,3 @@ +import AdminAreaSettingsBaseController from "discourse/admin/controllers/admin-area-settings-base"; + +export default class AdminConfigGifsSettingsController extends AdminAreaSettingsBaseController {} diff --git a/frontend/discourse/admin/routes/admin-config/gifs.js b/frontend/discourse/admin/routes/admin-config/gifs.js new file mode 100644 index 00000000000..9f101ee5fad --- /dev/null +++ b/frontend/discourse/admin/routes/admin-config/gifs.js @@ -0,0 +1,8 @@ +import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "../admin-config-with-settings-route"; + +export default class AdminConfigGifsRoute extends AdminConfigWithSettingsRoute { + titleToken() { + return i18n("admin.config.gifs.title"); + } +} diff --git a/frontend/discourse/admin/routes/admin-route-map.js b/frontend/discourse/admin/routes/admin-route-map.js index d96ad5ed6ca..82fd4d7525c 100644 --- a/frontend/discourse/admin/routes/admin-route-map.js +++ b/frontend/discourse/admin/routes/admin-route-map.js @@ -379,6 +379,9 @@ export default function () { }); this.route("logo"); this.route("fonts"); + this.route("gifs", function () { + this.route("settings", { path: "/" }); + }); this.route("adminWelcomeBanner", { path: "/welcome-banner" }); this.route("navigation", function () { this.route("settings", { path: "/" }); diff --git a/frontend/discourse/admin/templates/admin-config/gifs/settings.gjs b/frontend/discourse/admin/templates/admin-config/gifs/settings.gjs new file mode 100644 index 00000000000..ee213a3b242 --- /dev/null +++ b/frontend/discourse/admin/templates/admin-config/gifs/settings.gjs @@ -0,0 +1,30 @@ +import AdminAreaSettings from "discourse/admin/components/admin-area-settings"; +import DBreadcrumbsItem from "discourse/ui-kit/d-breadcrumbs-item"; +import DPageHeader from "discourse/ui-kit/d-page-header"; +import { i18n } from "discourse-i18n"; + +export default diff --git a/frontend/discourse/app/components/gifs/result-list.gjs b/frontend/discourse/app/components/gifs/result-list.gjs new file mode 100644 index 00000000000..a148f53f662 --- /dev/null +++ b/frontend/discourse/app/components/gifs/result-list.gjs @@ -0,0 +1,66 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import didUpdate from "@ember/render-modifiers/modifiers/did-update"; +import { schedule } from "@ember/runloop"; +import { service } from "@ember/service"; +import GifsResult from "discourse/components/gifs/result"; +import loadMiniMasonry from "discourse/lib/load-minimasonry"; +import DConditionalLoadingSpinner from "discourse/ui-kit/d-conditional-loading-spinner"; +import DLoadMore from "discourse/ui-kit/d-load-more"; + +export default class GifsResultList extends Component { + @service site; + + masonry; + + willDestroy() { + super.willDestroy(...arguments); + this.masonry?.destroy(); + } + + get loadMoreEnabled() { + return this.args.content?.length > 0 && (this.args.canLoadMore ?? true); + } + + @action + async setup() { + const MiniMasonry = await loadMiniMasonry(); + + this.masonry = new MiniMasonry({ + container: ".gifs-result-list", + baseWidth: this.site.mobileView ? 145 : 200, + surroundingGutter: false, + }); + + schedule("afterRender", () => this.masonry.layout()); + } + + @action + update() { + schedule("afterRender", () => this.masonry?.layout()); + } + + +} diff --git a/frontend/discourse/app/components/gifs/result.gjs b/frontend/discourse/app/components/gifs/result.gjs new file mode 100644 index 00000000000..5495ce08de7 --- /dev/null +++ b/frontend/discourse/app/components/gifs/result.gjs @@ -0,0 +1,49 @@ +import Component from "@glimmer/component"; +import { fn } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { trustHTML } from "@ember/template"; +import dConcatClass from "discourse/ui-kit/helpers/d-concat-class"; + +export default class GifsResult extends Component { + get style() { + const { width, height } = this.args.gif; + + if (width && height) { + return trustHTML(`--aspect-ratio: ${width / height};`); + } + } + + @action + keyDown(event) { + if (event.key === "Enter") { + this.args.pick(this.args.gif); + } + } + + +} diff --git a/frontend/discourse/app/components/modal/gifs.gjs b/frontend/discourse/app/components/modal/gifs.gjs new file mode 100644 index 00000000000..fabd69961fa --- /dev/null +++ b/frontend/discourse/app/components/modal/gifs.gjs @@ -0,0 +1,338 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { Input } from "@ember/component"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import GifsResultList from "discourse/components/gifs/result-list"; +import { addUniqueValuesToArray } from "discourse/lib/array-tools"; +import discourseDebounce from "discourse/lib/debounce"; +import { autoTrackedArray } from "discourse/lib/tracked-tools"; +import DModal from "discourse/ui-kit/d-modal"; +import dLoadingSpinner from "discourse/ui-kit/helpers/d-loading-spinner"; +import { i18n } from "discourse-i18n"; + +const KLIPY_SEARCH_URL = "https://api.klipy.com/v2/search"; +const KLIPY_CATEGORIES_URL = "https://api.klipy.com/v2/categories"; +const PAGE_SIZE = 24; +const MIN_QUERY_LENGTH = 3; + +export default class GifsModal extends Component { + @service appEvents; + @service dialog; + @service siteSettings; + + @tracked categories = []; + @tracked loading = false; + @tracked loadingCategories = false; + @tracked offset = 0; + @tracked query = ""; + @tracked hasMore = true; + @autoTrackedArray currentGifs = []; + + constructor() { + super(...arguments); + this.fetchCategories(); + } + + get showingCategories() { + return this.query.length < MIN_QUERY_LENGTH && this.categories.length > 0; + } + + @action + pick(content) { + const markup = `\n![${content.title}|${content.width}x${content.height}](${content.original})\n`; + + if (this.args.model?.customPickHandler) { + this.args.model.customPickHandler(markup); + } else { + this.appEvents.trigger("composer:insert-text", markup); + } + + this.args.closeModal(); + } + + @action + async loadMore() { + if (this.loading || !this.hasMore) { + return; + } + await this.search(false); + } + + @action + refresh(event) { + this.query = event.target.value; + discourseDebounce(this, this.search, 700); + } + + @action + selectCategory(category) { + this.query = category.searchterm; + this.search(true, true); + } + + async fetchCategories() { + if (this.siteSettings.klipy_api_key === "") { + return; + } + + this.loadingCategories = true; + + try { + const params = { + key: this.siteSettings.klipy_api_key, + country: this.siteSettings.klipy_country, + locale: this.siteSettings.klipy_locale, + type: "featured", + contentfilter: this.siteSettings.klipy_content_filter, + }; + + const response = await fetch( + `${KLIPY_CATEGORIES_URL}?${new URLSearchParams(params)}` + ); + + if (this.isDestroying || this.isDestroyed) { + return; + } + + if (!response.ok) { + return; + } + + const data = await response.json(); + + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.categories = await this.loadCategoryDimensions(data.tags || []); + } catch { + // Silently fail - user can still search manually + } finally { + this.loadingCategories = false; + } + } + + async loadCategoryDimensions(tags) { + return Promise.all( + tags.map( + (tag) => + new Promise((resolve) => { + const img = new Image(); + const finish = (width, height) => { + resolve({ + title: tag.name, + preview: tag.image, + original: tag.image, + width, + height, + isCategory: true, + searchterm: tag.searchterm, + }); + }; + img.onload = () => finish(img.naturalWidth, img.naturalHeight); + img.onerror = () => finish(200, 150); + img.src = tag.image; + }) + ) + ); + } + + async search(clearResults = true, skipLengthCheck = false) { + if (clearResults) { + this.currentGifs = []; + this.offset = 0; + this.hasMore = true; + } + + const meetsLengthRequirement = + skipLengthCheck || this.query.length >= MIN_QUERY_LENGTH; + + if (!meetsLengthRequirement) { + return; + } + + const limitReached = + this.siteSettings.klipy_limit_infinite_search_results && + this.currentGifs.length >= this.siteSettings.klipy_max_results_limit; + + if (limitReached) { + this.hasMore = false; + return; + } + + this.loading = true; + + try { + if (this.siteSettings.klipy_api_key === "") { + throw new Error(i18n("gifs.bad_api_key")); + } + + const response = await fetch(this.getEndpoint(this.query, this.offset)); + + if (this.isDestroying || this.isDestroyed) { + return; + } + + if (!response.ok) { + throw new Error(await this.errorFromResponse(response)); + } + + const data = await response.json(); + if (this.isDestroying || this.isDestroyed) { + return; + } + + const fileDetail = this.siteSettings.klipy_file_detail; + const images = data.results.map((gif) => { + const mediaFormat = gif.media_formats[fileDetail]; + return { + title: gif.title, + preview: mediaFormat.url, + original: mediaFormat.url, + width: mediaFormat.dims[0], + height: mediaFormat.dims[1], + }; + }); + + if (data.next === "" || data.next == null) { + this.hasMore = false; + } else { + this.offset = data.next; + } + addUniqueValuesToArray(this.currentGifs, images); + } catch (error) { + this.dialog.alert({ message: error.message ?? error }); + } finally { + this.loading = false; + } + } + + async errorFromResponse(response) { + if (response.status === 429) { + return i18n("gifs.error_rate_limit"); + } + if (response.status === 414) { + return i18n("gifs.error_search_too_long"); + } + if (response.status === 403 || response.status === 401) { + return i18n("gifs.bad_api_key"); + } + + const body = await response.text().catch(() => ""); + + let parsed; + try { + parsed = body && JSON.parse(body); + } catch { + parsed = null; + } + + const message = this.extractErrorMessage(parsed) ?? body ?? "unknown error"; + + if (/api key is invalid|API_KEY_INVALID/i.test(message)) { + return i18n("gifs.bad_api_key"); + } + + return `Klipy status ${response.status}: ${this.redactApiKey(message)}`; + } + + extractErrorMessage(parsed) { + if (!parsed) { + return null; + } + // Klipy: { result: false, errors: { message: ["..."] } } + const klipyMessages = parsed.errors?.message; + if (Array.isArray(klipyMessages) && klipyMessages.length) { + return klipyMessages[0]; + } + // Google-style: { error: { message, details: [{ reason }] } } + return ( + parsed.error?.message ?? + (typeof parsed.error === "string" ? parsed.error : null) ?? + parsed.message ?? + null + ); + } + + redactApiKey(message) { + const apiKey = this.siteSettings.klipy_api_key; + if (!apiKey || typeof message !== "string") { + return message; + } + return message.replaceAll(apiKey, "[redacted]"); + } + + getEndpoint(query, offset) { + const params = { + key: this.siteSettings.klipy_api_key, + q: query, + country: this.siteSettings.klipy_country, + locale: this.siteSettings.klipy_locale, + contentfilter: this.siteSettings.klipy_content_filter, + media_filter: this.siteSettings.klipy_file_detail, + limit: PAGE_SIZE, + pos: offset, + }; + return `${KLIPY_SEARCH_URL}?${new URLSearchParams(params)}`; + } + + +} diff --git a/frontend/discourse/app/instance-initializers/enable-gifs.js b/frontend/discourse/app/instance-initializers/enable-gifs.js new file mode 100644 index 00000000000..411c96748fa --- /dev/null +++ b/frontend/discourse/app/instance-initializers/enable-gifs.js @@ -0,0 +1,31 @@ +import GifsModal from "discourse/components/modal/gifs"; +import { withPluginApi } from "discourse/lib/plugin-api"; + +export default { + initialize(owner) { + const siteSettings = owner.lookup("service:site-settings"); + + if (!siteSettings.enable_gifs) { + return; + } + + withPluginApi((api) => { + api.onToolbarCreate((toolbar) => { + if (!toolbar.context?.composerEvents) { + return; + } + + toolbar.addButton({ + id: "gifs", + group: "extras", + icon: "gif", + title: "gifs.composer_title", + sendAction: () => { + const modal = api.container.lookup("service:modal"); + modal.show(GifsModal); + }, + }); + }); + }); + }, +}; diff --git a/frontend/discourse/app/lib/load-minimasonry.js b/frontend/discourse/app/lib/load-minimasonry.js new file mode 100644 index 00000000000..ba1a6bd4982 --- /dev/null +++ b/frontend/discourse/app/lib/load-minimasonry.js @@ -0,0 +1,7 @@ +import { waitForPromise } from "@ember/test-waiters"; + +export default async function loadMiniMasonry() { + const promise = import("minimasonry"); + waitForPromise(promise); + return (await promise).default; +} diff --git a/frontend/discourse/app/lib/sidebar/admin-sidebar.js b/frontend/discourse/app/lib/sidebar/admin-sidebar.js index b531a380e5f..00554fd3b50 100644 --- a/frontend/discourse/app/lib/sidebar/admin-sidebar.js +++ b/frontend/discourse/app/lib/sidebar/admin-sidebar.js @@ -417,6 +417,19 @@ export default class AdminSidebarPanel extends BaseCustomSidebarPanel { ]); } + if (siteSettings.enable_gifs) { + this.adminNavManager.amendLinksToSection("appearance", [ + { + name: "admin_gifs", + route: "adminConfig.gifs.settings", + label: "admin.config.gifs.title", + description: "admin.config.gifs.header_description", + icon: "gif", + settings_area: "gifs", + }, + ]); + } + this.adminNavManager.amendLinksToSection("root", [ { name: "admin_upcoming_changes", diff --git a/frontend/discourse/package.json b/frontend/discourse/package.json index 455cf31f75d..44d3d71ee80 100644 --- a/frontend/discourse/package.json +++ b/frontend/discourse/package.json @@ -71,6 +71,7 @@ "highlight.js": "11.11.1", "immer": "^11.1.8", "jspreadsheet-ce": "^5.0.4", + "minimasonry": "^1.3.2", "moment": "2.30.1", "moment-timezone": "0.6.2", "morphlex": "^1.0.5", diff --git a/frontend/discourse/tests/acceptance/modal/gifs-test.gjs b/frontend/discourse/tests/acceptance/modal/gifs-test.gjs new file mode 100644 index 00000000000..dbd2c2ea5f3 --- /dev/null +++ b/frontend/discourse/tests/acceptance/modal/gifs-test.gjs @@ -0,0 +1,155 @@ +import { getOwner } from "@ember/owner"; +import { click, fillIn, settled, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import sinon from "sinon"; +import GifsModal from "discourse/components/modal/gifs"; +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; + +function fetchResponse(body) { + return { ok: true, status: 200, json: async () => body }; +} + +function categoriesResponse(tags = []) { + return fetchResponse({ tags }); +} + +function searchResponse({ results = [], next = "" } = {}) { + return fetchResponse({ results, next }); +} + +function gif(title, url = "https://example.com/g.gif") { + return { + title, + media_formats: { gif: { url, dims: [200, 150] } }, + }; +} + +acceptance("Modal - GIFs", function (needs) { + needs.user(); + needs.settings({ + enable_gifs: true, + klipy_api_key: "test-key", + klipy_country: "US", + klipy_locale: "en_US", + klipy_content_filter: "high", + klipy_file_detail: "gif", + klipy_limit_infinite_search_results: false, + klipy_max_results_limit: 100, + }); + + let fetchStub; + + needs.hooks.beforeEach(function () { + fetchStub = sinon.stub(window, "fetch"); + }); + + test("fetches categories when opened", async function (assert) { + fetchStub.resolves(categoriesResponse()); + + await visit("/"); + const modalService = getOwner(this).lookup("service:modal"); + modalService.show(GifsModal); + await settled(); + + assert.dom(".gifs-modal").exists("renders the modal"); + + const categoriesCall = fetchStub + .getCalls() + .find((c) => c.args[0].includes("/v2/categories")); + assert.notStrictEqual( + categoriesCall, + undefined, + "called the Klipy categories endpoint" + ); + assert.true( + categoriesCall.args[0].includes("key=test-key"), + "passes api key in query" + ); + }); + + test("search triggers a Klipy fetch with the query", async function (assert) { + fetchStub.callsFake(async (url) => { + if (url.includes("/v2/categories")) { + return categoriesResponse(); + } + return searchResponse({ results: [gif("a hello gif")] }); + }); + + await visit("/"); + const modalService = getOwner(this).lookup("service:modal"); + modalService.show(GifsModal); + await settled(); + + await fillIn(".gifs-modal input[name=query]", "hello"); + + const searchCall = fetchStub + .getCalls() + .find((c) => c.args[0].includes("/v2/search")); + + assert.notStrictEqual( + searchCall, + undefined, + "called the Klipy search endpoint" + ); + assert.true(searchCall.args[0].includes("q=hello"), "passes the query"); + assert + .dom(".gifs-modal .gifs-result") + .exists({ count: 1 }, "renders the result returned by Klipy"); + }); + + test("picking a result invokes customPickHandler with markup", async function (assert) { + fetchStub.callsFake(async (url) => { + if (url.includes("/v2/categories")) { + return categoriesResponse(); + } + return searchResponse({ + results: [gif("party parrot", "https://klipy.example/parrot.gif")], + }); + }); + + let receivedMarkup; + await visit("/"); + const modalService = getOwner(this).lookup("service:modal"); + const closed = modalService.show(GifsModal, { + model: { + customPickHandler: (markup) => { + receivedMarkup = markup; + }, + }, + }); + + await settled(); + await fillIn(".gifs-modal input[name=query]", "party"); + + await click(".gifs-modal .gifs-result"); + + assert.strictEqual( + receivedMarkup, + "\n![party parrot|200x150](https://klipy.example/parrot.gif)\n", + "invokes customPickHandler with the gif markup" + ); + assert.dom(".gifs-modal").doesNotExist("closes the modal after pick"); + + await closed; + }); + + test("renders a no results state when Klipy returns nothing", async function (assert) { + fetchStub.callsFake(async (url) => { + if (url.includes("/v2/categories")) { + return categoriesResponse(); + } + return searchResponse({ results: [], next: "" }); + }); + + await visit("/"); + const modalService = getOwner(this).lookup("service:modal"); + modalService.show(GifsModal); + await settled(); + + await fillIn(".gifs-modal input[name=query]", "nothingmatches"); + + assert + .dom(".gifs-modal .gifs-modal__no-results") + .exists("renders the no-results message"); + }); +}); diff --git a/lib/discourse_gifs.rb b/lib/discourse_gifs.rb new file mode 100644 index 00000000000..7284f05b421 --- /dev/null +++ b/lib/discourse_gifs.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module DiscourseGifs + REPO_URL = "https://github.com/discourse/discourse-gifs" + COMPONENT_NAME = "discourse-gifs" + REMOTE_URLS = [REPO_URL, "#{REPO_URL}.git"].freeze + + def self.component_installed? + component_scope.exists? + end + + def self.component_scope + Theme + .joins(:remote_theme) + .where(component: true) + .where(remote_themes: { remote_url: REMOTE_URLS }) + end +end diff --git a/lib/site_settings/label_formatter.rb b/lib/site_settings/label_formatter.rb index 4570c9a4b04..27970f0f019 100644 --- a/lib/site_settings/label_formatter.rb +++ b/lib/site_settings/label_formatter.rb @@ -28,6 +28,7 @@ module SiteSettings fg ga gb + gif gpu gpt gtm diff --git a/lib/svg_sprite.rb b/lib/svg_sprite.rb index 630fa147ede..4756f74ddda 100644 --- a/lib/svg_sprite.rb +++ b/lib/svg_sprite.rb @@ -182,6 +182,7 @@ module SvgSprite forward-step gavel gear + gif gift globe grip-lines diff --git a/lib/tasks/migrate_discourse_gifs_to_core.rake b/lib/tasks/migrate_discourse_gifs_to_core.rake new file mode 100644 index 00000000000..1b44b197256 --- /dev/null +++ b/lib/tasks/migrate_discourse_gifs_to_core.rake @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +require "discourse_gifs" + +module DiscourseGifsMigration + # Mapping from each TC provider's theme settings to core site settings. + # `value:` is an optional lookup table for non-1:1 translations. + PROVIDER_MAPPINGS = { + "giphy" => { + "giphy_file_format" => { + name: "klipy_file_detail", + }, + "giphy_content_rating" => { + name: "klipy_content_filter", + value: { + "g" => "high", + "pg" => "medium", + "pg-13" => "low", + "r" => "low", + }, + }, + "giphy_locale" => { + name: "klipy_locale", + }, + }, + "tenor" => { + "tenor_file_detail" => { + name: "klipy_file_detail", + value: { + "mediumgif" => "webp", + "tinygif" => "webp", + "nanogif" => "webp", + "gif" => "gif", + }, + }, + "tenor_content_filter" => { + name: "klipy_content_filter", + }, + "tenor_country" => { + name: "klipy_country", + }, + "tenor_locale" => { + name: "klipy_locale", + }, + }, + "klipy" => { + "klipy_api_key" => { + name: "klipy_api_key", + }, + "klipy_file_detail" => { + name: "klipy_file_detail", + }, + "klipy_content_filter" => { + name: "klipy_content_filter", + }, + "klipy_country" => { + name: "klipy_country", + }, + "klipy_locale" => { + name: "klipy_locale", + }, + }, + }.freeze + + # Settings the TC applied regardless of provider — migrated for everyone. + SHARED_MAPPINGS = { + "limit_infinite_search_results" => { + name: "klipy_limit_infinite_search_results", + }, + "max_results_limit" => { + name: "klipy_max_results_limit", + }, + }.freeze + + module_function + + def find_components + if ENV["RAILS_DB"].present? + db = ENV["RAILS_DB"] + + if !RailsMultisite::ConnectionManagement.has_db?(db) + default_db = RailsMultisite::ConnectionManagement::DEFAULT + puts "\e[31m✗ Database \e[1;101m[#{db}]\e[0m \e[31mnot found\e[0m" + puts "Using default database instead: \e[1;104m[#{default_db}]\e[0m\n\n" + db = default_db + end + + RailsMultisite::ConnectionManagement.establish_connection(db: db) + Array(find_component_in_db(db)) + else + [].tap do |components| + RailsMultisite::ConnectionManagement.each_connection do |db| + components.concat(Array(find_component_in_db(db))) + end + end + end + end + + def find_component_in_db(db) + puts "Accessing database: \e[1;104m[#{db}]\e[0m" + puts " Searching for #{DiscourseGifs::COMPONENT_NAME} theme component..." + + themes = + RemoteTheme + .where(remote_url: DiscourseGifs::REMOTE_URLS) + .includes(theme: :theme_settings) + .map(&:theme) + + if themes.length > 1 + puts " \e[33mMultiple (#{themes.length}) #{DiscourseGifs::COMPONENT_NAME} components found:\e[0m" + themes.each { |t| puts " - #{t.name} (ID: #{t.id})" } + puts " \e[33mInstall a single instance before running this task.\e[0m" + return nil + elsif themes.one? + theme = themes.first + puts " \e[1;34m✓ Found: \e[1m#{theme.name} (ID: #{theme.id})\e[0m" + return theme + end + + puts " \e[33m✗ Not found.\e[0m" + nil + end + + def migrate_component(theme, enable_gifs:) + puts "\n Migrating settings for \e[1m#{theme.name} (ID: #{theme.id})\e[0m..." + + overrides = theme.theme_settings.each_with_object({}) { |ts, h| h[ts.name] = ts.value } + # TC's default api_provider is "giphy" — applies to any site that never picked one. + provider = overrides["api_provider"].presence || "giphy" + puts " Detected provider: \e[1m#{provider}\e[0m" + + mapping = (PROVIDER_MAPPINGS[provider] || {}).merge(SHARED_MAPPINGS) + + migrated = 0 + errors = [] + + mapping.each do |tc_name, target| + raw = overrides[tc_name] + next if raw.blank? + + new_value = target[:value] ? (target[:value][raw] || raw) : raw + + begin + SiteSetting.set_and_log( + target[:name], + new_value, + Discourse.system_user, + "Migrated from #{DiscourseGifs::COMPONENT_NAME} theme component", + ) + puts " - \e[0;31m#{tc_name}: #{raw}\e[0m => \e[0;32m#{target[:name]}: #{new_value}\e[0m" + migrated += 1 + rescue StandardError => e + errors << e + puts " \e[31m- failed to migrate '#{tc_name}': \e[1m#{e.message}\e[0m" + end + end + + if enable_gifs + begin + SiteSetting.set_and_log( + :enable_gifs, + true, + Discourse.system_user, + "Migrated from #{DiscourseGifs::COMPONENT_NAME} theme component", + ) + puts " - \e[0;32menable_gifs: true\e[0m (auto-enabled per task argument)" + migrated += 1 + rescue StandardError => e + errors << e + puts " \e[31m- failed to enable enable_gifs: \e[1m#{e.message}\e[0m" + end + end + + puts " \e[1;32m✓ Migrated #{migrated} setting#{"s" if migrated != 1}\e[0m" + puts " \e[1;31m#{errors.size} error#{"s" if errors.size != 1}\e[0m" if errors.any? + end +end + +desc "Migrate #{DiscourseGifs::COMPONENT_NAME} theme component settings to core site settings. " \ + "Set ENABLE_GIFS=1 to also flip enable_gifs to true after migration." +task "themes:discourse_gifs:migrate" => :environment do + enable_gifs = %w[true yes 1].include?(ENV["ENABLE_GIFS"].to_s.strip.downcase) + + components = DiscourseGifsMigration.find_components + + if components.any? + puts "\nMigrating settings..." + puts "---------------------" + components.each { |c| DiscourseGifsMigration.migrate_component(c, enable_gifs: enable_gifs) } + else + puts "\nNo #{DiscourseGifs::COMPONENT_NAME} theme component found. Nothing to migrate." + end +end diff --git a/lib/upcoming_changes.rb b/lib/upcoming_changes.rb index e4d7da4d81d..2a843cf1245 100644 --- a/lib/upcoming_changes.rb +++ b/lib/upcoming_changes.rb @@ -26,6 +26,12 @@ module UpcomingChanges def self.should_display_enable_horizon_high_context_topic_cards? Themes::Action::HorizonHighContextTopicCardsToggled.should_display_upcoming_change? end + + # Sites already running the discourse-gifs theme component will have their + # configuration migrated separately so they don't need to opt in via the upcoming change. + def self.should_display_enable_gifs? + !DiscourseGifs.component_installed? + end end def self.user_enabled_reasons diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js index 9eb385e6468..b495479f55d 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js @@ -1,6 +1,7 @@ import { setOwner } from "@ember/owner"; import { service } from "@ember/service"; import EmojiPickerDetached from "discourse/components/emoji-picker/detached"; +import GifsModal from "discourse/components/modal/gifs"; import { bind } from "discourse/lib/decorators"; import EmbedMode from "discourse/lib/embed-mode"; import { number } from "discourse/lib/formatter"; @@ -8,6 +9,7 @@ import { replaceIcon } from "discourse/lib/icon-library"; import { withPluginApi } from "discourse/lib/plugin-api"; import { i18n } from "discourse-i18n"; import { clearChatComposerButtons } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons"; +import { buildGifPickHandler } from "discourse/plugins/chat/discourse/lib/gif-pick-handler"; import ChannelHashtagType from "discourse/plugins/chat/discourse/lib/hashtag-types/channel"; import richEditorExtension from "../../lib/rich-editor-extension"; import ChatHeaderIcon from "../components/chat/header/icon"; @@ -102,6 +104,30 @@ class ChatSetupInit { }, }); + if (this.siteSettings.enable_gifs) { + api.registerChatComposerButton({ + id: "gifs", + label: "gifs.composer_title", + icon: "gif", + position: "dropdown", + action(context) { + const modal = owner.lookup("service:modal"); + const currentUser = owner.lookup("service:current-user"); + + modal.show(GifsModal, { + model: { + customPickHandler: buildGifPickHandler({ + api, + draft: this.draft, + isThread: context === "thread", + currentUser, + }), + }, + }); + }, + }); + } + if (this.siteSettings.discourse_local_dates_enabled) { api.registerChatComposerButton({ label: "discourse_local_dates.title", diff --git a/plugins/chat/assets/javascripts/discourse/lib/gif-pick-handler.js b/plugins/chat/assets/javascripts/discourse/lib/gif-pick-handler.js new file mode 100644 index 00000000000..1a016f074fe --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/gif-pick-handler.js @@ -0,0 +1,25 @@ +// Builds the customPickHandler passed to GifsModal from the chat composer's +// GIF button. Extracted so the send + draft-reset interplay can be unit tested. +// +// The returned handler: +// - Sends the picked GIF as a chat message in the active context (channel or +// thread, with inReplyTo when replying in a channel). +// - On a successful send, resets the *correct* draft (thread when in a +// thread context, channel otherwise) for the given user. +// - On send failure, leaves the draft intact so the user can retry. +export function buildGifPickHandler({ api, draft, isThread, currentUser }) { + const draftHolder = isThread ? draft.thread : draft.channel; + + return async (message) => { + try { + await api.sendChatMessage(draft.channel.id, { + message, + threadId: isThread ? draft.thread?.id : null, + inReplyToId: !isThread ? draft.inReplyTo?.id : null, + }); + } catch { + return; + } + draftHolder?.resetDraft?.(currentUser); + }; +} diff --git a/plugins/chat/test/javascripts/unit/lib/gif-pick-handler-test.js b/plugins/chat/test/javascripts/unit/lib/gif-pick-handler-test.js new file mode 100644 index 00000000000..28818908de9 --- /dev/null +++ b/plugins/chat/test/javascripts/unit/lib/gif-pick-handler-test.js @@ -0,0 +1,123 @@ +import { module, test } from "qunit"; +import sinon from "sinon"; +import { buildGifPickHandler } from "discourse/plugins/chat/discourse/lib/gif-pick-handler"; + +function setupDraft() { + const channelDraft = { resetDraft: sinon.spy() }; + const threadDraft = { resetDraft: sinon.spy() }; + return { + draft: { + channel: { id: 7, resetDraft: channelDraft.resetDraft }, + thread: { id: 13, resetDraft: threadDraft.resetDraft }, + inReplyTo: { id: 42 }, + }, + channelDraft, + threadDraft, + }; +} + +module("Unit | Lib | buildGifPickHandler", function () { + test("sends a channel message with inReplyToId when not in a thread", async function (assert) { + const api = { sendChatMessage: sinon.stub().resolves() }; + const { draft } = setupDraft(); + const currentUser = { id: 1 }; + + const handler = buildGifPickHandler({ + api, + draft, + isThread: false, + currentUser, + }); + + await handler("![g](u)"); + + assert.true(api.sendChatMessage.calledOnce); + assert.deepEqual(api.sendChatMessage.firstCall.args, [ + 7, + { message: "![g](u)", threadId: null, inReplyToId: 42 }, + ]); + }); + + test("sends a thread message with threadId when in a thread", async function (assert) { + const api = { sendChatMessage: sinon.stub().resolves() }; + const { draft } = setupDraft(); + + const handler = buildGifPickHandler({ + api, + draft, + isThread: true, + currentUser: { id: 1 }, + }); + + await handler("![g](u)"); + + assert.deepEqual(api.sendChatMessage.firstCall.args, [ + 7, + { message: "![g](u)", threadId: 13, inReplyToId: null }, + ]); + }); + + test("resets the channel draft on a successful channel-context send", async function (assert) { + const api = { sendChatMessage: sinon.stub().resolves() }; + const { draft, channelDraft, threadDraft } = setupDraft(); + const currentUser = { id: 1 }; + + const handler = buildGifPickHandler({ + api, + draft, + isThread: false, + currentUser, + }); + + await handler("![g](u)"); + + assert.true( + channelDraft.resetDraft.calledOnceWith(currentUser), + "channel draft is reset" + ); + assert.false(threadDraft.resetDraft.called, "thread draft is left alone"); + }); + + test("resets the thread draft on a successful thread-context send", async function (assert) { + const api = { sendChatMessage: sinon.stub().resolves() }; + const { draft, channelDraft, threadDraft } = setupDraft(); + const currentUser = { id: 1 }; + + const handler = buildGifPickHandler({ + api, + draft, + isThread: true, + currentUser, + }); + + await handler("![g](u)"); + + assert.true( + threadDraft.resetDraft.calledOnceWith(currentUser), + "thread draft is reset" + ); + assert.false( + channelDraft.resetDraft.called, + "channel draft is left alone in thread context" + ); + }); + + test("does not reset the draft when sendChatMessage rejects", async function (assert) { + const api = { sendChatMessage: sinon.stub().rejects(new Error("nope")) }; + const { draft, channelDraft } = setupDraft(); + + const handler = buildGifPickHandler({ + api, + draft, + isThread: false, + currentUser: { id: 1 }, + }); + + await handler("![g](u)"); + + assert.false( + channelDraft.resetDraft.called, + "draft preserved so the user can retry" + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29d6cf55eb7..2ac87f8c772 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -333,6 +333,9 @@ importers: jspreadsheet-ce: specifier: ^5.0.4 version: 5.0.4 + minimasonry: + specifier: ^1.3.2 + version: 1.3.2 moment: specifier: 2.30.1 version: 2.30.1 @@ -5894,6 +5897,9 @@ packages: peerDependencies: webpack: ^5.0.0 + minimasonry@1.3.2: + resolution: {integrity: sha512-ygvf5JIR4NT1LbM6o8z1oz1icTkta72gXKwidvSqIVxuzVd/P21UI0Wbq1J+MStAu50Vvr8qWYNSz1W4uycqMQ==} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -14298,6 +14304,8 @@ snapshots: schema-utils: 4.3.3 tapable: 2.3.2 + minimasonry@1.3.2: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 diff --git a/public/images/upcoming_changes/enable_gifs.png b/public/images/upcoming_changes/enable_gifs.png new file mode 100644 index 00000000000..aebac7cae56 Binary files /dev/null and b/public/images/upcoming_changes/enable_gifs.png differ diff --git a/spec/lib/discourse_gifs_spec.rb b/spec/lib/discourse_gifs_spec.rb new file mode 100644 index 00000000000..062e59383ab --- /dev/null +++ b/spec/lib/discourse_gifs_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +RSpec.describe DiscourseGifs do + describe ".component_installed?" do + it "returns false when no discourse-gifs theme component exists" do + expect(described_class.component_installed?).to eq(false) + end + + it "returns true when the component is installed via the canonical URL" do + remote_theme = RemoteTheme.create!(remote_url: "https://github.com/discourse/discourse-gifs") + Fabricate(:theme, component: true, remote_theme: remote_theme) + + expect(described_class.component_installed?).to eq(true) + end + + it "returns true when the component is installed via the .git URL" do + remote_theme = + RemoteTheme.create!(remote_url: "https://github.com/discourse/discourse-gifs.git") + Fabricate(:theme, component: true, remote_theme: remote_theme) + + expect(described_class.component_installed?).to eq(true) + end + + it "returns false for forks or similarly-named repos that the migration task would not match" do + remote_theme = RemoteTheme.create!(remote_url: "https://github.com/myorg/discourse-gifs.git") + Fabricate(:theme, component: true, remote_theme: remote_theme) + + expect(described_class.component_installed?).to eq(false) + end + end +end diff --git a/spec/tasks/migrate_discourse_gifs_to_core_spec.rb b/spec/tasks/migrate_discourse_gifs_to_core_spec.rb new file mode 100644 index 00000000000..24cea6b3f45 --- /dev/null +++ b/spec/tasks/migrate_discourse_gifs_to_core_spec.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +RSpec.describe "tasks/migrate_discourse_gifs_to_core" do + before do + Rake::Task.clear + silence_warnings { Discourse::Application.load_tasks } + end + + fab!(:remote_theme) do + RemoteTheme.create!(remote_url: "https://github.com/discourse/discourse-gifs") + end + fab!(:component) { Fabricate(:theme, component: true, remote_theme: remote_theme) } + + def add_overrides(theme, overrides) + overrides.each do |name, value| + ThemeSetting.create!( + theme: theme, + name: name.to_s, + value: value.to_s, + data_type: ThemeSetting.types[:string], + ) + end + theme.reload + end + + def run_migration(theme, enable_gifs: false) + expect { + DiscourseGifsMigration.migrate_component(theme, enable_gifs: enable_gifs) + }.to output.to_stdout + end + + describe ".find_component_in_db" do + it "returns the component when a single discourse-gifs install exists" do + result = nil + + expect { result = DiscourseGifsMigration.find_component_in_db("default") }.to output( + /✓ Found/, + ).to_stdout + expect(result).to eq(component) + end + + it "matches the component when the remote_url ends in .git" do + remote_theme.update!(remote_url: "https://github.com/discourse/discourse-gifs.git") + + result = nil + expect { result = DiscourseGifsMigration.find_component_in_db("default") }.to output( + /✓ Found/, + ).to_stdout + expect(result).to eq(component) + end + + it "returns nil and warns when more than one install exists" do + duplicate_remote = + RemoteTheme.create!(remote_url: "https://github.com/discourse/discourse-gifs.git") + Fabricate(:theme, component: true, remote_theme: duplicate_remote) + + result = nil + expect { result = DiscourseGifsMigration.find_component_in_db("default") }.to output( + /Multiple \(2\) discourse-gifs components found/, + ).to_stdout + expect(result).to be_nil + end + + it "returns nil when no discourse-gifs component is installed" do + component.destroy + + result = nil + expect { result = DiscourseGifsMigration.find_component_in_db("default") }.to output( + /Not found/, + ).to_stdout + expect(result).to be_nil + end + end + + describe ".migrate_component" do + context "when the TC was configured for Giphy" do + it "maps Giphy file format directly into klipy_file_detail" do + add_overrides(component, api_provider: "giphy", giphy_file_format: "gif") + + run_migration(component) + + expect(SiteSetting.klipy_file_detail).to eq("gif") + end + + it "maps every Giphy content rating to the agreed Klipy content filter", + :aggregate_failures do + rating_mappings = { "g" => "high", "pg" => "medium", "pg-13" => "low", "r" => "low" } + + rating_mappings.each do |giphy_rating, expected_filter| + component.theme_settings.destroy_all + add_overrides(component, api_provider: "giphy", giphy_content_rating: giphy_rating) + + run_migration(component) + + expect(SiteSetting.klipy_content_filter).to eq(expected_filter), + "expected giphy '#{giphy_rating}' to map to '#{expected_filter}', got '#{SiteSetting.klipy_content_filter}'" + end + end + + it "passes the Giphy locale through unchanged" do + add_overrides(component, api_provider: "giphy", giphy_locale: "fr") + + run_migration(component) + + expect(SiteSetting.klipy_locale).to eq("fr") + end + + it "does not migrate the Giphy API key into klipy_api_key" do + add_overrides(component, api_provider: "giphy", giphy_api_key: "old-giphy-key") + original_api_key = SiteSetting.klipy_api_key + + run_migration(component) + + expect(SiteSetting.klipy_api_key).to eq(original_api_key) + end + end + + context "when the TC was configured for Tenor" do + it "maps every Tenor file detail to the matching Klipy file detail", :aggregate_failures do + detail_mappings = { + "mediumgif" => "webp", + "tinygif" => "webp", + "nanogif" => "webp", + "gif" => "gif", + } + + detail_mappings.each do |tenor_detail, expected_klipy| + component.theme_settings.destroy_all + add_overrides(component, api_provider: "tenor", tenor_file_detail: tenor_detail) + + run_migration(component) + + expect(SiteSetting.klipy_file_detail).to eq(expected_klipy), + "expected tenor '#{tenor_detail}' to map to '#{expected_klipy}', got '#{SiteSetting.klipy_file_detail}'" + end + end + + it "passes Tenor content filter, country and locale through unchanged", :aggregate_failures do + add_overrides( + component, + api_provider: "tenor", + tenor_content_filter: "medium", + tenor_country: "GB", + tenor_locale: "en_GB", + ) + + run_migration(component) + + expect(SiteSetting.klipy_content_filter).to eq("medium") + expect(SiteSetting.klipy_country).to eq("GB") + expect(SiteSetting.klipy_locale).to eq("en_GB") + end + + it "does not migrate the Tenor API key into klipy_api_key" do + add_overrides(component, api_provider: "tenor", tenor_api_key: "old-tenor-key") + original_api_key = SiteSetting.klipy_api_key + + run_migration(component) + + expect(SiteSetting.klipy_api_key).to eq(original_api_key) + end + end + + context "when the TC was already configured for Klipy" do + it "copies every Klipy setting through, including the API key", :aggregate_failures do + add_overrides( + component, + api_provider: "klipy", + klipy_api_key: "existing-klipy-key", + klipy_file_detail: "gif", + klipy_content_filter: "low", + klipy_country: "DE", + klipy_locale: "de_DE", + ) + + run_migration(component) + + expect(SiteSetting.klipy_api_key).to eq("existing-klipy-key") + expect(SiteSetting.klipy_file_detail).to eq("gif") + expect(SiteSetting.klipy_content_filter).to eq("low") + expect(SiteSetting.klipy_country).to eq("DE") + expect(SiteSetting.klipy_locale).to eq("de_DE") + end + end + + context "with shared settings that apply regardless of provider" do + it "migrates limit_infinite_search_results" do + add_overrides(component, api_provider: "tenor", limit_infinite_search_results: "true") + + run_migration(component) + + expect(SiteSetting.klipy_limit_infinite_search_results).to eq(true) + end + + it "migrates max_results_limit" do + add_overrides(component, api_provider: "giphy", max_results_limit: "96") + + run_migration(component) + + expect(SiteSetting.klipy_max_results_limit).to eq(96) + end + end + + context "when api_provider is not set in theme settings" do + it "defaults to giphy and applies giphy mappings" do + add_overrides(component, giphy_content_rating: "pg") + + run_migration(component) + + expect(SiteSetting.klipy_content_filter).to eq("medium") + end + end + + context "with the enable_gifs keyword" do + it "leaves enable_gifs untouched by default" do + SiteSetting.enable_gifs = false + add_overrides(component, api_provider: "klipy", klipy_api_key: "key") + + run_migration(component, enable_gifs: false) + + expect(SiteSetting.enable_gifs).to eq(false) + end + + it "flips enable_gifs to true when enable_gifs: true is passed" do + SiteSetting.enable_gifs = false + add_overrides(component, api_provider: "klipy", klipy_api_key: "key") + + run_migration(component, enable_gifs: true) + + expect(SiteSetting.enable_gifs).to eq(true) + end + end + + it "records migrated settings in the staff action log with an audit reason" do + add_overrides(component, api_provider: "klipy", klipy_locale: "de_DE") + + run_migration(component) + + log = + UserHistory.where( + action: UserHistory.actions[:change_site_setting], + subject: "klipy_locale", + ).last + expect(log).to be_present + expect(log.details).to include("Migrated from discourse-gifs theme component") + end + end +end diff --git a/vendor/assets/svg-icons/discourse-additional.svg b/vendor/assets/svg-icons/discourse-additional.svg index 34217c123c1..a35c45b9023 100644 --- a/vendor/assets/svg-icons/discourse-additional.svg +++ b/vendor/assets/svg-icons/discourse-additional.svg @@ -3,6 +3,11 @@ Additional SVG icons --> + + + + +