diff --git a/package.json b/package.json index a5884e455a..a686e8ab1c 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,8 @@ "@fontsource/inconsolata": "^5", "@fontsource/inter": "^5", "@formatjs/intl-segmenter": "^11.5.7", + "@giphy/js-fetch-api": "^5.6.0", + "@giphy/react-components": "^10.0.1", "@matrix-org/analytics-events": "^0.29.2", "@matrix-org/emojibase-bindings": "^1.3.4", "@matrix-org/react-sdk-module-api": "^2.4.0", @@ -152,6 +154,7 @@ "rfc4648": "^1.4.0", "sanitize-filename": "^1.6.3", "sanitize-html": "2.15.0", + "styled-components": "^6.1.17", "tar-js": "^0.3.0", "temporal-polyfill": "^0.3.0", "ua-parser-js": "^1.0.2", diff --git a/res/css/_components.pcss b/res/css/_components.pcss index b58a53a325..0c04a6751d 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -220,6 +220,7 @@ @import "./views/elements/_ToggleSwitch.pcss"; @import "./views/elements/_Validation.pcss"; @import "./views/emojipicker/_EmojiPicker.pcss"; +@import "./views/gifpicker/_GifPicker.pcss"; @import "./views/location/_LocationPicker.pcss"; @import "./views/messages/_CallEvent.pcss"; @import "./views/messages/_CreateEvent.pcss"; diff --git a/res/css/views/gifpicker/_GifPicker.pcss b/res/css/views/gifpicker/_GifPicker.pcss new file mode 100644 index 0000000000..36e03d29f3 --- /dev/null +++ b/res/css/views/gifpicker/_GifPicker.pcss @@ -0,0 +1,232 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2019 Tulir Asokan + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +.mx_GifPicker { + width: 340px; + height: 450px; + + border-radius: 4px; + + display: flex; + flex-direction: column; +} + +.mx_GifPicker_body { + height: 340px; + flex: 1; + overflow-y: scroll; + scrollbar-width: thin; + scrollbar-color: rgb(0, 0, 0, 0.2) transparent; +} + +.mx_GifPicker_header { + padding: 4px 8px 0; + border-bottom: 1px solid $message-action-bar-border-color; +} + +.mx_GifPicker_anchor { + border: none; + padding: 8px 8px 6px; + border-bottom: 2px solid transparent; + background-color: transparent; + border-radius: 4px 4px 0 0; + + width: 36px; + height: 38px; + + &:not(:disabled) { + cursor: pointer; + } + + &:not(:disabled):hover { + background-color: $focus-bg-color; + border-bottom: 2px solid $accent; + } +} + +.mx_GifPicker_anchor::before { + background-color: $primary-content; + content: ""; + display: inline-block; + mask-size: 100%; + mask-repeat: no-repeat; + width: 100%; + height: 100%; +} + +.mx_GifPicker_anchor:disabled::before { + background-color: $focus-bg-color; +} + +.mx_GifPicker_anchor_visible { + border-bottom: 2px solid $accent; +} + +.mx_GifPicker_search { + margin: 8px; + border-radius: 4px; + border: 1px solid $input-border-color; + background-color: $background; + display: flex; + + input { + flex: 1; + border: none; + padding: 8px 12px; + border-radius: 4px 0; + + &::placeholder { + color: var(--cpd-color-text-secondary); + } + } + + button { + border: none; + background-color: inherit; + margin: 0; + padding: 8px; + align-self: center; + width: 32px; + height: 32px; + } +} + +.mx_GifPicker_search_clear { + cursor: pointer; +} + +.mx_GifPicker_search_icon { + width: 16px; + margin: 8px; +} + +.mx_GifPicker_search_icon:not(.mx_GifPicker_search_clear) { + pointer-events: none; +} + +.mx_GifPicker_search_icon::after { + mask: url("$(res)/img/emojipicker/search.svg") no-repeat; + mask-size: 100%; + background-color: $primary-content; + content: ""; + display: inline-block; + width: 100%; + height: 100%; +} + +.mx_GifPicker_search_clear::after { + mask-image: url("$(res)/img/emojipicker/delete.svg"); +} + +.mx_GifPicker_category { + padding: 0 12px; + display: flex; + flex-direction: column; + align-items: center; +} + +.mx_GifPicker_category_label { + width: 304px; +} + +.mx_GifPicker_list { + width: 304px; + padding: 4px; + margin: 4px; +} + +.mx_GifPicker_item_wrapper { + display: inline-block; + list-style: none; + width: 38px; + cursor: pointer; + + &:focus-within { + background-color: $focus-bg-color; + } +} + +.mx_GifPicker_body .mx_GifPicker_item_wrapper[tabindex="0"] .mx_GifPicker_item { + background-color: $focus-bg-color; +} + +.mx_GifPicker_item { + display: inline-block; + font-size: $font-20px; + padding: 5px; + width: 100%; + height: 100%; + box-sizing: border-box; + text-align: center; + border-radius: 4px; + + &:hover { + background-color: $focus-bg-color; + } +} + +.mx_GifPicker_item_selected { + color: rgb(0, 0, 0, 0.5); + border: 1px solid $accent; + padding: 4px; +} + +.mx_GifPicker_category_label, +.mx_GifPicker_preview_name { + font-size: $font-16px; + font-weight: var(--cpd-font-weight-semibold); + margin: 0; +} + +.mx_GifPicker_footer { + border-top: 1px solid $message-action-bar-border-color; + min-height: 72px; + + display: flex; + align-items: center; +} + +.mx_GifPicker_footer .mx_GifPicker_preview { + flex: 1; + display: flex; + align-items: center; + padding: 0 8px; +} + +.mx_GifPicker_preview_text { + display: flex; + flex: 1; + overflow: hidden; + padding-top: 1rem; + padding-bottom: 1rem; + flex-direction: column; +} + +.mx_GifPicker_name { + text-transform: capitalize; +} + +.mx_GifPicker_shortcode { + color: $light-fg-color; + overflow-wrap: break-word; + font: var(--cpd-font-body-md-regular); + + &::before, + &::after { + content: ":"; + } +} + +.mx_GifPicker_quick { + flex-direction: column; + justify-content: space-around; +} + +.mx_GifPicker_quick_header .mx_GifPicker_name { + margin-right: 4px; +} diff --git a/res/css/views/rooms/_MessageComposer.pcss b/res/css/views/rooms/_MessageComposer.pcss index 8b92b682ec..96673619e5 100644 --- a/res/css/views/rooms/_MessageComposer.pcss +++ b/res/css/views/rooms/_MessageComposer.pcss @@ -173,6 +173,14 @@ Please see LICENSE files in the repository root for full details. } } +.mx_GifButton_icon { + mask-image: url("$(res)/img/element-icons/room/composer/gif.svg"); + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; +} + + .mx_MessageComposer_input_error { animation: 0.2s visualbell; } diff --git a/res/img/element-icons/room/composer/gif.svg b/res/img/element-icons/room/composer/gif.svg new file mode 100644 index 0000000000..dd4f797349 --- /dev/null +++ b/res/img/element-icons/room/composer/gif.svg @@ -0,0 +1,65 @@ + + + + + + + + + + image/svg+xml + + + + + GIF + diff --git a/src/components/views/gifpicker/GifPicker.tsx b/src/components/views/gifpicker/GifPicker.tsx new file mode 100644 index 0000000000..2fe3f31d26 --- /dev/null +++ b/src/components/views/gifpicker/GifPicker.tsx @@ -0,0 +1,100 @@ +import React, { useState } from "react"; +import { Grid } from "@giphy/react-components"; +import { GiphyFetch } from "@giphy/js-fetch-api"; +import { IGif } from "@giphy/js-types"; +import { _t } from "../../../languageHandler"; +import Search from "./Search"; +import ContentMessages from "../../../ContentMessages"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import dis from "../../../dispatcher/dispatcher"; +import { Action } from "../../../dispatcher/actions"; +import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; +import { IEventRelation } from "matrix-js-sdk/src/matrix"; + +const GIPHY_API_KEY = "x"; // TODO: Move to config +const gf = new GiphyFetch(GIPHY_API_KEY); + +interface IProps { + onGifSelect: (url: string) => void; + onFinished: () => void; + roomId: string; + relation?: IEventRelation; + timelineRenderingType: string; +} + +export const GifPicker: React.FC = ({ onGifSelect, onFinished, roomId, relation, timelineRenderingType }) => { + const [searchQuery, setSearchQuery] = useState(""); + + const fetchGifs = (offset: number) => { + if (!searchQuery) { + return gf.trending({ offset, limit: 10 }); + } + return gf.search(searchQuery, { offset, limit: 10 }); + }; + + const handleGifClick = async (gif: IGif) => { + try { + const imageUrl = gif.images.downsized.url; + console.log('Fetching GIF:', imageUrl); + + const response = await fetch(imageUrl); + if (!response.ok) { + throw new Error(`Failed to fetch GIF: ${response.status}`); + } + + const blob = await response.blob(); + console.log('Image blob:', blob.type, blob.size); + + // Create a File object from the blob + const file = new File([blob], "giphy.gif", { type: blob.type }); + + // Use Element's ContentMessages to handle the file upload + ContentMessages.sharedInstance().sendContentListToRoom( + [file], + roomId, + relation, + MatrixClientPeg.get(), + timelineRenderingType + ); + + onFinished(); + } catch (error) { + console.error("Failed to process GIF:", error); + // Fallback to markdown link + const mdLink = `![](${gif.images.downsized.url})`; + dis.dispatch({ + action: Action.ComposerInsert, + text: mdLink, + timelineRenderingType, + }); + } + }; + + return ( +
+
+ {}} + onKeyDown={() => {}} + /> +
+
+ +
+
+ ); +}; + +export default GifPicker; diff --git a/src/components/views/gifpicker/Preview.tsx b/src/components/views/gifpicker/Preview.tsx new file mode 100644 index 0000000000..18d3b284ec --- /dev/null +++ b/src/components/views/gifpicker/Preview.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +interface IProps { + url: string; +} + +class Preview extends React.PureComponent { + public render(): React.ReactNode { + return ( +
+
+ +
+
+ ); + } +} + +export default Preview; diff --git a/src/components/views/gifpicker/Search.tsx b/src/components/views/gifpicker/Search.tsx new file mode 100644 index 0000000000..5a9c63ee02 --- /dev/null +++ b/src/components/views/gifpicker/Search.tsx @@ -0,0 +1,84 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2019 Tulir Asokan + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type JSX } from "react"; + +import { _t } from "../../../languageHandler"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { RovingTabIndexContext } from "../../../accessibility/RovingTabIndex"; + +interface IProps { + query: string; + onChange(value: string): void; + onEnter(): void; + onKeyDown(event: React.KeyboardEvent): void; +} + +class Search extends React.PureComponent { + public static contextType = RovingTabIndexContext; + declare public context: React.ContextType; + + private inputRef = React.createRef(); + + public componentDidMount(): void { + // For some reason, neither the autoFocus nor just calling focus() here worked, so here's a window.setTimeout + window.setTimeout(() => this.inputRef.current?.focus(), 0); + } + + private onKeyDown = (ev: React.KeyboardEvent): void => { + const action = getKeyBindingsManager().getAccessibilityAction(ev); + switch (action) { + case KeyBindingAction.Enter: + this.props.onEnter(); + ev.stopPropagation(); + ev.preventDefault(); + break; + + default: + this.props.onKeyDown(ev); + } + }; + + public render(): React.ReactNode { + let rightButton: JSX.Element; + if (this.props.query) { + rightButton = ( +