mirror of
https://github.com/discourse/discourse.git
synced 2026-09-05 04:40:41 -05:00
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:
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class SiteSetting < ActiveRecord::Base
|
||||
experimental
|
||||
flags
|
||||
fonts
|
||||
gifs
|
||||
group_permissions
|
||||
interface
|
||||
legal
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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("fonts");
|
||||
this.route("gifs", function () {
|
||||
this.route("settings", { path: "/" });
|
||||
});
|
||||
this.route("adminWelcomeBanner", { path: "/welcome-banner" });
|
||||
this.route("navigation", function () {
|
||||
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\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", [
|
||||
{
|
||||
name: "admin_upcoming_changes",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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\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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -28,6 +28,7 @@ module SiteSettings
|
||||
fg
|
||||
ga
|
||||
gb
|
||||
gif
|
||||
gpu
|
||||
gpt
|
||||
gtm
|
||||
|
||||
@@ -182,6 +182,7 @@ module SvgSprite
|
||||
forward-step
|
||||
gavel
|
||||
gear
|
||||
gif
|
||||
gift
|
||||
globe
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("");
|
||||
|
||||
assert.true(api.sendChatMessage.calledOnce);
|
||||
assert.deepEqual(api.sendChatMessage.firstCall.args, [
|
||||
7,
|
||||
{ message: "", 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("");
|
||||
|
||||
assert.deepEqual(api.sendChatMessage.firstCall.args, [
|
||||
7,
|
||||
{ message: "", 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("");
|
||||
|
||||
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("");
|
||||
|
||||
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("");
|
||||
|
||||
assert.false(
|
||||
channelDraft.resetDraft.called,
|
||||
"draft preserved so the user can retry"
|
||||
);
|
||||
});
|
||||
});
|
||||
Generated
+8
@@ -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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
@@ -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
|
||||
@@ -3,6 +3,11 @@
|
||||
Additional SVG icons
|
||||
-->
|
||||
<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-->
|
||||
<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"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
Reference in New Issue
Block a user