Add GIF search button

This commit is contained in:
Nik Rozman
2025-04-17 00:17:33 +02:00
parent fd455179f7
commit 992e7b1efe
15 changed files with 998 additions and 39 deletions

View File

@@ -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<IProps> = ({ 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<ComposerInsertPayload>({
action: Action.ComposerInsert,
text: mdLink,
timelineRenderingType,
});
}
};
return (
<div className="mx_GifPicker" role="dialog" aria-label={_t("gif_picker|dialog_label")}>
<div className="mx_GifPicker_header">
<Search
query={searchQuery}
onChange={setSearchQuery}
onEnter={() => {}}
onKeyDown={() => {}}
/>
</div>
<div className="mx_GifPicker_body" id="mx_GifPicker_body">
<Grid
key={searchQuery}
onGifClick={handleGifClick}
fetchGifs={fetchGifs}
width={360}
columns={3}
gutter={8}
noLink={true}
hideAttribution={true}
className="mx_GifPicker_list"
/>
</div>
</div>
);
};
export default GifPicker;

View File

@@ -0,0 +1,19 @@
import React from "react";
interface IProps {
url: string;
}
class Preview extends React.PureComponent<IProps> {
public render(): React.ReactNode {
return (
<div className="mx_GifPicker_footer mx_GifPicker_preview">
<div className="mx_GifPicker_preview_image">
<img src={this.props.url} alt="" />
</div>
</div>
);
}
}
export default Preview;

View File

@@ -0,0 +1,84 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Tulir Asokan <tulir@maunium.net>
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<IProps> {
public static contextType = RovingTabIndexContext;
declare public context: React.ContextType<typeof RovingTabIndexContext>;
private inputRef = React.createRef<HTMLInputElement>();
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 = (
<button
onClick={() => this.props.onChange("")}
className="mx_GifPicker_search_icon mx_GifPicker_search_clear"
title={_t("gif_picker|cancel_search_label")}
/>
);
} else {
rightButton = <span className="mx_GifPicker_search_icon" />;
}
return (
<div className="mx_GifPicker_search">
<input
autoFocus
type="text"
placeholder={_t("gif_picker|search_placeholder")}
value={this.props.query}
onChange={(ev) => this.props.onChange(ev.target.value)}
onKeyDown={this.onKeyDown}
ref={this.inputRef}
aria-activedescendant={this.context.state.activeNode?.id}
aria-controls="mx_GifPicker_body"
aria-haspopup="grid"
aria-autocomplete="list"
/>
{rightButton}
</div>
);
}
}
export default Search;

View File

@@ -0,0 +1,72 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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 classNames from "classnames";
import React, { type JSX, useContext } from "react";
import { _t } from "../../../languageHandler";
import ContextMenu, { aboveLeftOf, type MenuProps, useContextMenu } from "../../structures/ContextMenu";
import GifPicker from "../gifpicker/GifPicker";
import { CollapsibleButton } from "./CollapsibleButton";
import { OverflowMenuContext } from "./MessageComposerButtons";
import { IEventRelation } from "matrix-js-sdk/src/matrix";
interface IGifButtonProps {
addGif: (unicode: string) => boolean;
menuPosition?: MenuProps;
className?: string;
roomId: string;
relation?: IEventRelation;
timelineRenderingType: string;
}
export function GifButton({ addGif, menuPosition, className, roomId, relation, timelineRenderingType }: IGifButtonProps): JSX.Element {
const overflowMenuCloser = useContext(OverflowMenuContext);
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
let contextMenu: React.ReactElement | null = null;
if (menuDisplayed && button.current) {
const position = menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect());
const onFinished = (): void => {
closeMenu();
overflowMenuCloser?.();
};
contextMenu = (
<ContextMenu {...position} onFinished={onFinished} managed={false}>
<GifPicker
onGifSelect={addGif}
onFinished={onFinished}
roomId={roomId}
relation={relation}
timelineRenderingType={timelineRenderingType}
/>
</ContextMenu>
);
}
const computedClassName = classNames("mx_GifButton", className, {
mx_GifButton_highlight: menuDisplayed,
});
// TODO: replace ContextMenuTooltipButton with a unified representation of
// the header buttons and the right panel buttons
return (
<>
<CollapsibleButton
className={computedClassName}
iconClassName="mx_GifButton_icon"
onClick={openMenu}
title={_t("common|gif")}
inputRef={button}
/>
{contextMenu}
</>
);
}

View File

@@ -95,6 +95,8 @@ interface IState {
isMenuOpen: boolean;
isStickerPickerOpen: boolean;
showStickersButton: boolean;
isGifPickerOpen: boolean;
showGifButton: boolean;
showPollsButton: boolean;
isWysiwygLabEnabled: boolean;
isRichTextEnabled: boolean;
@@ -145,6 +147,9 @@ export class MessageComposer extends React.Component<IProps, IState> {
recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast
isMenuOpen: false,
isStickerPickerOpen: false,
isGifPickerOpen: false,
// showGifButton: SettingsStore.getValue("MessageComposerInput.showGifButton"),
showGifButton: true,
showStickersButton: SettingsStore.getValue("MessageComposerInput.showStickersButton"),
showPollsButton: SettingsStore.getValue("MessageComposerInput.showPollsButton"),
isWysiwygLabEnabled: isWysiwygLabEnabled,
@@ -241,6 +246,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
SettingsStore.monitorSetting("MessageComposerInput.showStickersButton", null);
SettingsStore.monitorSetting("MessageComposerInput.showPollsButton", null);
SettingsStore.monitorSetting("MessageComposerInput.showGifButton", null);
SettingsStore.monitorSetting("feature_wysiwyg_composer", null);
this.dispatcherRef = dis.register(this.onAction);
@@ -297,6 +303,13 @@ export class MessageComposer extends React.Component<IProps, IState> {
}
break;
}
case "MessageComposerInput.showGifButton": {
const showGifButton = SettingsStore.getValue("MessageComposerInput.showGifButton");
if (this.state.showGifButton !== showGifButton) {
this.setState({ showGifButton });
}
break;
}
}
}
}
@@ -388,6 +401,22 @@ export class MessageComposer extends React.Component<IProps, IState> {
return true;
};
private addGif = (unicode: string): boolean => {
if (this.state.isWysiwygLabEnabled) {
this.setState({
composerContent: this.state.composerContent + unicode,
isComposerEmpty: false
});
} else {
dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert,
text: unicode,
timelineRenderingType: this.context.timelineRenderingType,
});
}
return true;
};
private sendMessage = async (): Promise<void> => {
if (this.state.haveRecording && this.voiceRecordingButton.current) {
// There shouldn't be any text message to send when a voice recording is active, so
@@ -483,6 +512,13 @@ export class MessageComposer extends React.Component<IProps, IState> {
});
};
private setGifPickerOpen = (isGifPickerOpen: boolean): void => {
this.setState({
isGifPickerOpen,
isMenuOpen: false,
});
};
private toggleStickerPickerOpen = (): void => {
this.setStickerPickerOpen(!this.state.isStickerPickerOpen);
};
@@ -661,18 +697,20 @@ export class MessageComposer extends React.Component<IProps, IState> {
{canSendMessages && (
<MessageComposerButtons
addEmoji={this.addEmoji}
addGif={this.addGif}
haveRecording={this.state.haveRecording}
isMenuOpen={this.state.isMenuOpen}
isStickerPickerOpen={this.state.isStickerPickerOpen}
isGifPickerOpen={this.state.isGifPickerOpen}
menuPosition={menuPosition}
relation={this.props.relation}
onRecordStartEndClick={this.onRecordStartEndClick}
setStickerPickerOpen={this.setStickerPickerOpen}
showLocationButton={
!window.electron && SettingsStore.getValue(UIFeature.LocationSharing)
}
setGifPickerOpen={this.setGifPickerOpen}
showLocationButton={!window.electron && SettingsStore.getValue(UIFeature.LocationSharing)}
showPollsButton={this.state.showPollsButton}
showStickersButton={this.showStickersButton}
showGifButton={this.state.showGifButton}
isRichTextEnabled={this.state.isRichTextEnabled}
onComposerModeClick={this.onRichTextToggle}
toggleButtonMenu={this.toggleButtonMenu}

View File

@@ -35,19 +35,24 @@ import { filterBoolean } from "../../../utils/arrays";
import { useSettingValue } from "../../../hooks/useSettings";
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
import { GifButton } from "./GifButton.tsx";
interface IProps {
addEmoji: (emoji: string) => boolean;
addGif: (unicode: string) => boolean;
haveRecording: boolean;
isMenuOpen: boolean;
isStickerPickerOpen: boolean;
isGifPickerOpen: boolean;
menuPosition?: MenuProps;
onRecordStartEndClick: () => void;
relation?: IEventRelation;
setStickerPickerOpen: (isStickerPickerOpen: boolean) => void;
setGifPickerOpen: (isGifPickerOpen: boolean) => void;
showLocationButton: boolean;
showPollsButton: boolean;
showStickersButton: boolean;
showGifButton: boolean;
toggleButtonMenu: () => void;
isRichTextEnabled: boolean;
onComposerModeClick: () => void;
@@ -77,7 +82,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
onClick={props.onComposerModeClick}
/>
) : (
emojiButton(props)
[emojiButton(props), gifButton(props, room, matrixClient)]
),
];
moreButtons = [
@@ -96,7 +101,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
onClick={props.onComposerModeClick}
/>
) : (
emojiButton(props)
[emojiButton(props), gifButton(props, room, matrixClient)]
),
uploadButton(), // props passed via UploadButtonContext
];
@@ -154,6 +159,21 @@ function emojiButton(props: IProps): ReactElement {
);
}
function gifButton(props: IProps, room: Room, matrixClient: MatrixClient): ReactElement {
const timelineRenderingType = useScopedRoomContext("timelineRenderingType");
return (
<GifButton
key="gif_button"
addGif={props.addGif}
menuPosition={props.menuPosition}
className="mx_MessageComposer_button"
roomId={room.roomId}
relation={props.relation}
timelineRenderingType={timelineRenderingType.timelineRenderingType}
/>
);
}
function uploadButton(): ReactElement {
return <UploadButton key="controls_upload" />;
}

View File

@@ -1,4 +1,9 @@
{
"gif_picker": {
"search_placeholder": "Search for GIFs",
"dialog_label": "Search for GIFs dialog",
"cancel_search_label": "Cancel search"
},
"a11y": {
"emoji_picker": "Emoji picker",
"jump_first_invite": "Jump to first invite.",
@@ -505,6 +510,7 @@
"general": "General",
"go_to_settings": "Go to Settings",
"guest": "Guest",
"gif": "GIF",
"help": "Help",
"historical": "Historical",
"home": "Home",
@@ -2816,6 +2822,7 @@
},
"prompt_invite": "Prompt before sending invites to potentially invalid matrix IDs",
"replace_plain_emoji": "Automatically replace plain text Emoji",
"show_gif_button": "Show GIF button",
"security": {
"analytics_description": "Share anonymous data to help us identify issues. Nothing personal. No third parties.",
"bulk_options_accept_all_invites": "Accept all %(invitedRooms)s invites",

View File

@@ -225,6 +225,7 @@ export interface Settings {
"MessageComposerInput.suggestEmoji": IBaseSetting<boolean>;
"MessageComposerInput.showStickersButton": IBaseSetting<boolean>;
"MessageComposerInput.showPollsButton": IBaseSetting<boolean>;
"MessageComposerInput.showGifButton": IBaseSetting<boolean>;
"MessageComposerInput.insertTrailingColon": IBaseSetting<boolean>;
"Notifications.alwaysShowBadgeCounts": IBaseSetting<boolean>;
"Notifications.showbold": IBaseSetting<boolean>;
@@ -675,6 +676,11 @@ export const SETTINGS: Settings = {
default: true,
invertedSettingName: "MessageComposerInput.dontSuggestEmoji",
},
"MessageComposerInput.showGifButton": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("settings|show_gif_button"),
default: true,
},
"MessageComposerInput.showStickersButton": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("settings|show_stickers_button"),

0
src/style/index.scss Normal file
View File