DEV: move discourse gifs to core (#40162)

Moves the theme component from Discourse Gifs and brings it into core as
an upcoming change. This PR also brings gifs under a single provider
(Klipy).
This commit is contained in:
David Battersby
2026-06-10 11:35:44 +04:00
committed by GitHub
parent 4bccfdebe2
commit 031e17658c
32 changed files with 1559 additions and 0 deletions
@@ -79,6 +79,7 @@
@import "welcome-header"; @import "welcome-header";
@import "notifications-tracking"; @import "notifications-tracking";
@import "emoji-picker"; @import "emoji-picker";
@import "gifs";
@import "d-icon-grid-picker"; @import "d-icon-grid-picker";
@import "filter-input"; @import "filter-input";
@import "filter-navigation"; @import "filter-navigation";
@@ -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;
}
}
+1
View File
@@ -18,6 +18,7 @@ class SiteSetting < ActiveRecord::Base
experimental experimental
flags flags
fonts fonts
gifs
group_permissions group_permissions
interface interface
legal legal
+12
View File
@@ -3143,6 +3143,15 @@ en:
confirm_publish: "Are you sure you want to publish this draft?" confirm_publish: "Are you sure you want to publish this draft?"
publishing: "Publishing Topic…" 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: composer:
autocomplete: autocomplete:
username_missing: "Selected autocomplete result is missing username." username_missing: "Selected autocomplete result is missing username."
@@ -6614,6 +6623,9 @@ en:
notifications: notifications:
title: "Notifications" title: "Notifications"
header_description: "Configure how notifications are managed and delivered for users, including email preferences, push notifications, mention limits, and notification consolidation" 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: onebox:
title: "Onebox" title: "Onebox"
header_description: "Configure how onebox previews are generated and displayed for your site" header_description: "Configure how onebox previews are generated and displayed for your site"
+7
View File
@@ -2664,6 +2664,13 @@ en:
enable_inline_emoji_translation: "Enables translation for inline emojis (without any space or punctuation before)" 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." 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." 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_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." approve_unless_allowed_groups: "Posts created by users not in these groups must be approved. Posts created by admins and moderators are always approved."
+1
View File
@@ -435,6 +435,7 @@ Discourse::Application.routes.draw do
# Needed for back-end routing to work. # Needed for back-end routing to work.
# #
get "gifs" => "site_settings#index"
get "navigation" => "site_settings#index" get "navigation" => "site_settings#index"
get "notifications" => "site_settings#index" get "notifications" => "site_settings#index"
get "rate-limits" => "site_settings#index" get "rate-limits" => "site_settings#index"
+44
View File
@@ -1636,6 +1636,50 @@ posting:
default: false default: false
client: true client: true
hidden: 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: approve_post_count:
default: 0 default: 0
area: "posts_and_topics" area: "posts_and_topics"
@@ -0,0 +1,3 @@
import AdminAreaSettingsBaseController from "discourse/admin/controllers/admin-area-settings-base";
export default class AdminConfigGifsSettingsController extends AdminAreaSettingsBaseController {}
@@ -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");
}
}
@@ -379,6 +379,9 @@ export default function () {
}); });
this.route("logo"); this.route("logo");
this.route("fonts"); this.route("fonts");
this.route("gifs", function () {
this.route("settings", { path: "/" });
});
this.route("adminWelcomeBanner", { path: "/welcome-banner" }); this.route("adminWelcomeBanner", { path: "/welcome-banner" });
this.route("navigation", function () { this.route("navigation", function () {
this.route("settings", { path: "/" }); this.route("settings", { path: "/" });
@@ -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 <template>
<DPageHeader
@hideTabs={{true}}
@titleLabel={{i18n "admin.config.gifs.title"}}
@descriptionLabel={{i18n "admin.config.gifs.header_description"}}
>
<:breadcrumbs>
<DBreadcrumbsItem @path="/admin" @label={{i18n "admin_title"}} />
<DBreadcrumbsItem
@path="/admin/config/gifs"
@label={{i18n "admin.config.gifs.title"}}
/>
</:breadcrumbs>
</DPageHeader>
<div class="admin-config-page__main-area">
<AdminAreaSettings
@showBreadcrumb={{false}}
@area="gifs"
@path="/admin/config/gifs"
@filter={{@controller.filter}}
@adminSettingsFilterChangedCallback={{@controller.adminSettingsFilterChangedCallback}}
/>
</div>
</template>
@@ -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());
}
<template>
<div
{{didInsert this.setup}}
{{didUpdate this.update @content.length}}
class="gifs-result-list"
>
{{#each @content key="preview" as |result|}}
<GifsResult @gif={{result}} @pick={{@pick}} />
{{/each}}
</div>
{{#if @loadMore}}
<DLoadMore
@action={{@loadMore}}
@root=".gifs-modal__content"
@isLoading={{@loading}}
@enabled={{this.loadMoreEnabled}}
/>
{{/if}}
<DConditionalLoadingSpinner @condition={{@loading}} />
</template>
}
@@ -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);
}
}
<template>
<div
{{on "click" (fn @pick @gif)}}
{{on "keydown" this.keyDown}}
role="button"
tabindex="0"
class={{dConcatClass
"gifs-result"
(if @gif.isCategory "gifs-result--category")
}}
>
<img
class="gifs-result__img"
alt={{@gif.title}}
title={{@gif.title}}
src={{@gif.preview}}
style={{this.style}}
width={{@gif.width}}
height={{@gif.height}}
/>
{{#if @gif.isCategory}}
<span class="gifs-result__category-label">{{@gif.title}}</span>
{{/if}}
</div>
</template>
}
@@ -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)}`;
}
<template>
<DModal
@title={{i18n "gifs.modal_title"}}
@closeModal={{@closeModal}}
id="gifs-modal"
class="gifs-modal"
>
<:body>
<div class="gifs-modal__input">
<Input
{{on "input" this.refresh}}
@type="text"
@value={{this.query}}
name="query"
autofocus
/>
{{#if this.loading}}
{{dLoadingSpinner size="small"}}
{{/if}}
</div>
{{#if this.currentGifs.length}}
<div class="gifs-modal__content">
<div class="gifs-modal__box">
<GifsResultList
@content={{this.currentGifs}}
@pick={{this.pick}}
@loading={{this.loading}}
@loadMore={{this.loadMore}}
@canLoadMore={{this.hasMore}}
/>
</div>
</div>
{{else if this.showingCategories}}
<div class="gifs-modal__content">
<h3 class="gifs-modal__categories-header">{{i18n
"gifs.browse_categories"
}}</h3>
<div class="gifs-modal__box">
<GifsResultList
@content={{this.categories}}
@pick={{this.selectCategory}}
@loading={{false}}
/>
</div>
</div>
{{else if this.loadingCategories}}
<div class="gifs-modal__loading-categories">
{{dLoadingSpinner size="medium"}}
</div>
{{else}}
<div class="gifs-modal__no-results">{{i18n "gifs.no_results"}}</div>
{{/if}}
</:body>
</DModal>
</template>
}
@@ -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);
},
});
});
});
},
};
@@ -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;
}
@@ -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", [ this.adminNavManager.amendLinksToSection("root", [
{ {
name: "admin_upcoming_changes", name: "admin_upcoming_changes",
+1
View File
@@ -71,6 +71,7 @@
"highlight.js": "11.11.1", "highlight.js": "11.11.1",
"immer": "^11.1.8", "immer": "^11.1.8",
"jspreadsheet-ce": "^5.0.4", "jspreadsheet-ce": "^5.0.4",
"minimasonry": "^1.3.2",
"moment": "2.30.1", "moment": "2.30.1",
"moment-timezone": "0.6.2", "moment-timezone": "0.6.2",
"morphlex": "^1.0.5", "morphlex": "^1.0.5",
@@ -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");
});
});
+18
View File
@@ -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
+1
View File
@@ -28,6 +28,7 @@ module SiteSettings
fg fg
ga ga
gb gb
gif
gpu gpu
gpt gpt
gtm gtm
+1
View File
@@ -182,6 +182,7 @@ module SvgSprite
forward-step forward-step
gavel gavel
gear gear
gif
gift gift
globe globe
grip-lines grip-lines
@@ -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
+6
View File
@@ -26,6 +26,12 @@ module UpcomingChanges
def self.should_display_enable_horizon_high_context_topic_cards? def self.should_display_enable_horizon_high_context_topic_cards?
Themes::Action::HorizonHighContextTopicCardsToggled.should_display_upcoming_change? Themes::Action::HorizonHighContextTopicCardsToggled.should_display_upcoming_change?
end 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 end
def self.user_enabled_reasons def self.user_enabled_reasons
@@ -1,6 +1,7 @@
import { setOwner } from "@ember/owner"; import { setOwner } from "@ember/owner";
import { service } from "@ember/service"; import { service } from "@ember/service";
import EmojiPickerDetached from "discourse/components/emoji-picker/detached"; import EmojiPickerDetached from "discourse/components/emoji-picker/detached";
import GifsModal from "discourse/components/modal/gifs";
import { bind } from "discourse/lib/decorators"; import { bind } from "discourse/lib/decorators";
import EmbedMode from "discourse/lib/embed-mode"; import EmbedMode from "discourse/lib/embed-mode";
import { number } from "discourse/lib/formatter"; 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 { withPluginApi } from "discourse/lib/plugin-api";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
import { clearChatComposerButtons } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons"; 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 ChannelHashtagType from "discourse/plugins/chat/discourse/lib/hashtag-types/channel";
import richEditorExtension from "../../lib/rich-editor-extension"; import richEditorExtension from "../../lib/rich-editor-extension";
import ChatHeaderIcon from "../components/chat/header/icon"; 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) { if (this.siteSettings.discourse_local_dates_enabled) {
api.registerChatComposerButton({ api.registerChatComposerButton({
label: "discourse_local_dates.title", label: "discourse_local_dates.title",
@@ -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);
};
}
@@ -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"
);
});
});
+8
View File
@@ -333,6 +333,9 @@ importers:
jspreadsheet-ce: jspreadsheet-ce:
specifier: ^5.0.4 specifier: ^5.0.4
version: 5.0.4 version: 5.0.4
minimasonry:
specifier: ^1.3.2
version: 1.3.2
moment: moment:
specifier: 2.30.1 specifier: 2.30.1
version: 2.30.1 version: 2.30.1
@@ -5894,6 +5897,9 @@ packages:
peerDependencies: peerDependencies:
webpack: ^5.0.0 webpack: ^5.0.0
minimasonry@1.3.2:
resolution: {integrity: sha512-ygvf5JIR4NT1LbM6o8z1oz1icTkta72gXKwidvSqIVxuzVd/P21UI0Wbq1J+MStAu50Vvr8qWYNSz1W4uycqMQ==}
minimatch@10.2.5: minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
@@ -14298,6 +14304,8 @@ snapshots:
schema-utils: 4.3.3 schema-utils: 4.3.3
tapable: 2.3.2 tapable: 2.3.2
minimasonry@1.3.2: {}
minimatch@10.2.5: minimatch@10.2.5:
dependencies: dependencies:
brace-expansion: 5.0.6 brace-expansion: 5.0.6
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

+31
View File
@@ -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
@@ -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
+5
View File
@@ -3,6 +3,11 @@
Additional SVG icons Additional SVG icons
--> -->
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;"> <svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<!-- GIF icon used by the gifs composer button -->
<symbol id="gif" viewBox="0 0 1024 1024">
<path d="M944 299H692c-4.4 0-8 3.6-8 8v406c0 4.4 3.6 8 8 8h59.2c4.4 0 8-3.6 8-8V549.9h168.2c4.4 0 8-3.6 8-8V495c0-4.4-3.6-8-8-8H759.2V364.2H944c4.4 0 8-3.6 8-8V307c0-4.4-3.6-8-8-8zm-356 1h-56c-4.4 0-8 3.6-8 8v406c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V308c0-4.4-3.6-8-8-8zM452 500.9H290.5c-4.4 0-8 3.6-8 8v43.7c0 4.4 3.6 8 8 8h94.9l-.3 8.9c-1.2 58.8-45.6 98.5-110.9 98.5c-76.2 0-123.9-59.7-123.9-156.7c0-95.8 46.8-155.2 121.5-155.2c54.8 0 93.1 26.9 108.5 75.4h76.2c-13.6-87.2-86-143.4-184.7-143.4C150 288 72 375.2 72 511.9C72 650.2 149.1 736 273 736c114.1 0 187-70.7 187-181.6v-45.5c0-4.4-3.6-8-8-8z"/>
</symbol>
<!-- Discourse chat search icon from font-awesome message & search icons--> <!-- Discourse chat search icon from font-awesome message & search icons-->
<symbol id="discourse-chat-search" viewBox="0 0 620 512"> <symbol id="discourse-chat-search" viewBox="0 0 620 512">
<path d="M416 0C462.482 0 501.272 33.0746 510.109 76.9623C511.663 84.68 503.194 89.685 496.126 86.2177C471.04 73.9114 442.827 67 413 67C308.618 67 224 151.618 224 256C224 317.524 253.398 372.179 298.912 406.691C302.687 409.553 300.738 416 296 416C290.8 416 285.8 417.7 281.6 420.8L166.4 507.2C162.2 510.3 157.2 512 152 512C138.7 512 128 501.3 128 488V426C128 420.477 123.523 416 118 416H96C43 416 0 373 0 320V96C0 43 43 0 96 0H416Z"/> <path d="M416 0C462.482 0 501.272 33.0746 510.109 76.9623C511.663 84.68 503.194 89.685 496.126 86.2177C471.04 73.9114 442.827 67 413 67C308.618 67 224 151.618 224 256C224 317.524 253.398 372.179 298.912 406.691C302.687 409.553 300.738 416 296 416C290.8 416 285.8 417.7 281.6 420.8L166.4 507.2C162.2 510.3 157.2 512 152 512C138.7 512 128 501.3 128 488V426C128 420.477 123.523 416 118 416H96C43 416 0 373 0 320V96C0 43 43 0 96 0H416Z"/>

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB