Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Giphy integration #5814

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions res/css/_components.scss
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@
@import "./views/elements/_TooltipButton.scss";
@import "./views/elements/_Validation.scss";
@import "./views/emojipicker/_EmojiPicker.scss";
@import "./views/gifpicker/_GifPicker.scss";
@import "./views/groups/_GroupPublicityToggle.scss";
@import "./views/groups/_GroupRoomList.scss";
@import "./views/groups/_GroupUserSettings.scss";
Expand Down
30 changes: 30 additions & 0 deletions res/css/views/gifpicker/_GifPicker.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
.mx_GifPicker {
width: 240px;
height: 450px;
border-radius: 4px;
display: flex;
flex-direction: column;
}

.mx_GifPicker_scrollPanel {
overflow-y: scroll;
flex: 1 1 0;
max-height: 450px;
width: 240px;
}

.mx_GifPicker_initialNotice {
text-align: center;
margin: auto;
}

.mx_GifPicker_thumbnail video {
border: 2px solid $input-darker-bg-color;
border-radius: 6px;
margin-bottom: 0.8em;
cursor: pointer;

&:hover {
border-color: $strong-input-border-color;
}
}
4 changes: 4 additions & 0 deletions res/css/views/rooms/_MessageComposer.scss
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,10 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/room/composer/sticker.svg');
}

.mx_MessageComposer_gifButton::before {
mask-image: url('$(res)/img/element-icons/room/composer/gif.svg');
}

.mx_MessageComposer_buttonMenu::before {
mask-image: url('$(res)/img/image-view/more.svg');
}
Expand Down
3 changes: 3 additions & 0 deletions res/img/element-icons/room/composer/gif.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 7 additions & 2 deletions src/ContentMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,12 @@ export default class ContentMessages {
}
}

async sendContentListToRoom(files: File[], roomId: string, matrixClient: MatrixClient) {
async sendContentListToRoom(
files: File[],
roomId: string,
matrixClient: MatrixClient,
showConfirmationDialogs = true,
) {
if (matrixClient.isGuest()) {
dis.dispatch({ action: 'require_registration' });
return;
Expand Down Expand Up @@ -486,7 +491,7 @@ export default class ContentMessages {
if (!shouldContinue) return;
}

let uploadAll = false;
let uploadAll = !showConfirmationDialogs;
// Promise to complete before sending next file into room, used for synchronisation of file-sending
// to match the order the files were specified in
let promBefore: Promise<any> = Promise.resolve();
Expand Down
4 changes: 2 additions & 2 deletions src/components/structures/RoomView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -752,7 +752,7 @@ export default class RoomView extends React.Component<IProps, IState> {
break;
case 'picture_snapshot':
ContentMessages.sharedInstance().sendContentListToRoom(
[payload.file], this.state.room.roomId, this.context);
[payload.file], this.state.room.roomId, this.context, true);
break;
case 'notifier_enabled':
case Action.UploadStarted:
Expand Down Expand Up @@ -1246,7 +1246,7 @@ export default class RoomView extends React.Component<IProps, IState> {
ev.stopPropagation();
ev.preventDefault();
ContentMessages.sharedInstance().sendContentListToRoom(
ev.dataTransfer.files, this.state.room.roomId, this.context,
Array.from(ev.dataTransfer.files), this.state.room.roomId, this.context, true,
);
dis.fire(Action.FocusSendMessageComposer);

Expand Down
31 changes: 31 additions & 0 deletions src/components/views/gifpicker/Gif.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/* eslint-disable camelcase */

// a gif, as returned by the giphy endpoint
export interface Gif {
id: string;
title: string;
images: {
preview_gif: {
url: string;
width: string;
height: string;
};
fixed_width: {
url: string;
width: string;
height: string;
webp: string;
mp4: string;
};
original: {
height: string;
width: string;
mp4: string;
webp: string;
url: string;
};
downsized: {
url: string;
};
};
}
155 changes: 155 additions & 0 deletions src/components/views/gifpicker/GifPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/* eslint-disable camelcase */
import React from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Search from "../emojipicker/Search";
import ScrollPanel from "../../structures/ScrollPanel";
import Spinner from "../elements/Spinner";
import GifThumbnail from "./GifThumbnail";
import { DebouncedFunc, throttle, uniqBy } from "lodash";
import { Gif } from "./Gif";

const API_KEY = "hhMBhTnD7k8BgyoZCM9UNM1vRsPcgzaC";

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably shouldn't be checked into git.
Unrelated though, this work is amazing :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

This is a public key so it is not a security issue, as the request will be done client-side and anyone can inspect it.
Giphy/giphy-js#120 (comment)

If this is approved by the product team a production key is needed in order to increase the limits by Giphy.


async function searchGifs(
filter: string,
offset: number,
): Promise<[Gif[], { offset: number, total_count: number }]> {
if (!filter) {
return [[], { offset: 0, total_count: 0 }];
}
const response = await fetch(
`https://api.giphy.com/v1/gifs/search?q=${encodeURIComponent(
filter,
)}&api_key=${API_KEY}&limit=5&rating=g&offset=${offset}`,
);
const content = await response.json();
return [content.data, content.pagination];
}

function gifPaginator(filter): DebouncedFunc<() => Promise<Gif[]>> {
let gifs = [];
let depleted = false;
return throttle(async () => {
if (depleted) {
return gifs;
}
const [newGifs, { total_count }] = await searchGifs(
filter,
gifs.length,
);
gifs = gifs.concat(newGifs);
if (total_count === gifs.length) {
depleted = true;
}
const uniqueGifs: Gif[] = uniqBy(gifs, "id");
return uniqueGifs;
}, 1000);
}

interface IProps {
addGif(gif: Gif): void;
}

interface IState {
filter: string;
scrollTop: number;
// initial estimation of height, dialog is hardcoded to 450px height.
// should be enough to never have blank rows of emojis as
// 3 rows of overflow are also rendered. The actual value is updated on scroll.
viewportHeight: number;
loading: boolean;
gifs: Gif[];
}

@replaceableComponent("views.gifpicker.GifPicker")
export default class GifPicker extends React.Component<IProps, IState> {
paginator: DebouncedFunc<() => Promise<Gif[]>>;
canceled = false;

constructor(props) {
super(props);

this.state = {
filter: "",
scrollTop: 0,
viewportHeight: 280,
loading: false,
gifs: [],
};

this.onChangeFilter = this.onChangeFilter.bind(this);
this.onEnterFilter = this.onEnterFilter.bind(this);
this.onFillRequest = this.onFillRequest.bind(this);
this.paginator = gifPaginator("");
}

onChangeFilter(filter: string) {
this.paginator = gifPaginator(filter);
this.setState({ filter, gifs: [] });
}

async onEnterFilter() {
this.setState({ loading: true });

const gifs = await this.paginator();
if (this.canceled) return;

this.setState({ loading: false, gifs });
}

async onFillRequest(backwards: boolean) {
if (backwards || !this.state.filter || this.canceled) {
return false;
}

this.setState({ loading: true });
const gifs = await this.paginator();
if (this.canceled) {
return false;
}
this.setState({
loading: false,
gifs,
});
return false;
}

componentWillUnmount() {
this.canceled = true;
this.paginator.cancel();
}

render() {
const initialNotice = (
<div className="mx_GifPicker_initialNotice">
Type to search GIFs
</div>
);

return (
<div className="mx_GifPicker">
<Search
query={this.state.filter}
onChange={this.onChangeFilter}
onEnter={this.onEnterFilter}
/>
{ this.state.gifs.length === 0 && initialNotice }
<ScrollPanel
className="mx_GifPicker_scrollPanel"
onFillRequest={this.onFillRequest}
stickyBottom={false}
startAtBottom={false}
>
{ this.state.gifs.map((gif) => (
<GifThumbnail
key={gif.id}
gif={gif}
onClick={this.props.addGif}
/>
)) }
{ this.state.loading && <Spinner /> }
</ScrollPanel>
</div>
);
}
}
31 changes: 31 additions & 0 deletions src/components/views/gifpicker/GifThumbnail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { Gif } from './Gif';

interface IProps {
gif: Gif;
onClick(gif: Gif): void;
}

interface IState {
}

@replaceableComponent("views.gifpicker.GifThumbnail")
export default class GifThumbnail extends React.Component<IProps, IState> {
constructor(props) {
super(props);
}

render() {
const gif = this.props.gif;
const rendition = gif.images.fixed_width;
return (
<div onClick={() => this.props.onClick(gif)} className="mx_GifPicker_thumbnail">
<video autoPlay loop muted playsInline width={rendition.width} height={rendition.height}>
<source src={rendition.mp4} type="video/mp4" />
Unsupported file type.
</video>
</div>
);
}
}
46 changes: 46 additions & 0 deletions src/components/views/rooms/GifButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import classNames from 'classnames';
import React from 'react';
import { _t } from '../../../languageHandler';
import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu';
import { Gif } from '../gifpicker/Gif';
import GifPicker from '../gifpicker/GifPicker';

const GifButton = ({ addGif }) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();

let contextMenu;
if (menuDisplayed) {
const buttonRect = button.current.getBoundingClientRect();

const onAddGif = (gif: Gif) => {
closeMenu();
addGif(gif);
};

contextMenu = <ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
<GifPicker addGif={onAddGif} />
</ContextMenu>;
}

const className = classNames(
"mx_MessageComposer_button",
"mx_MessageComposer_gifButton",
{
"mx_MessageComposer_button_highlight": menuDisplayed,
},
);

return <>
<ContextMenuTooltipButton
className={className}
onClick={openMenu}
isExpanded={menuDisplayed}
title={_t('GIF picker')}
inputRef={button}
/>

{ contextMenu }
</>;
};

export default GifButton;
Loading