Add GIF search button
This commit is contained in:
100
src/components/views/gifpicker/GifPicker.tsx
Normal file
100
src/components/views/gifpicker/GifPicker.tsx
Normal 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 = ``;
|
||||
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;
|
||||
19
src/components/views/gifpicker/Preview.tsx
Normal file
19
src/components/views/gifpicker/Preview.tsx
Normal 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;
|
||||
84
src/components/views/gifpicker/Search.tsx
Normal file
84
src/components/views/gifpicker/Search.tsx
Normal 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;
|
||||
72
src/components/views/rooms/GifButton.tsx
Normal file
72
src/components/views/rooms/GifButton.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
0
src/style/index.scss
Normal file
Reference in New Issue
Block a user