feat: add Giphy support with embed component and power levels state management
This commit is contained in:
@@ -25,7 +25,7 @@ import {
|
|||||||
VideoContent,
|
VideoContent,
|
||||||
VideoFrameThumbnail,
|
VideoFrameThumbnail,
|
||||||
} from './message';
|
} from './message';
|
||||||
import { UrlPreviewCard, UrlPreviewHolder, YouTubeEmbed, isYouTubeUrl, TikTokEmbed, isTikTokUrl } from './url-preview';
|
import { UrlPreviewCard, UrlPreviewHolder, YouTubeEmbed, isYouTubeUrl, TikTokEmbed, isTikTokUrl, GiphyEmbed, isGiphyUrl } from './url-preview';
|
||||||
import { Image, MediaControl, Video } from './media';
|
import { Image, MediaControl, Video } from './media';
|
||||||
import { ImageViewer } from './image-viewer';
|
import { ImageViewer } from './image-viewer';
|
||||||
import { PdfViewer } from './Pdf-viewer';
|
import { PdfViewer } from './Pdf-viewer';
|
||||||
@@ -71,7 +71,8 @@ export function RenderMessageContent({
|
|||||||
// Separate special URLs from generic ones
|
// Separate special URLs from generic ones
|
||||||
const youtubeUrls = filteredUrls.filter(isYouTubeUrl);
|
const youtubeUrls = filteredUrls.filter(isYouTubeUrl);
|
||||||
const tiktokUrls = filteredUrls.filter(isTikTokUrl);
|
const tiktokUrls = filteredUrls.filter(isTikTokUrl);
|
||||||
const otherUrls = filteredUrls.filter((url) => !isYouTubeUrl(url) && !isTikTokUrl(url));
|
const giphyUrls = filteredUrls.filter(isGiphyUrl);
|
||||||
|
const otherUrls = filteredUrls.filter((url) => !isYouTubeUrl(url) && !isTikTokUrl(url) && !isGiphyUrl(url));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -83,6 +84,10 @@ export function RenderMessageContent({
|
|||||||
{tiktokUrls.map((url) => (
|
{tiktokUrls.map((url) => (
|
||||||
<TikTokEmbed key={url} url={url} />
|
<TikTokEmbed key={url} url={url} />
|
||||||
))}
|
))}
|
||||||
|
{/* Render Giphy embeds */}
|
||||||
|
{giphyUrls.map((url) => (
|
||||||
|
<GiphyEmbed key={url} url={url} />
|
||||||
|
))}
|
||||||
{/* Render standard URL previews for other links */}
|
{/* Render standard URL previews for other links */}
|
||||||
{otherUrls.length > 0 && (
|
{otherUrls.length > 0 && (
|
||||||
<UrlPreviewHolder>
|
<UrlPreviewHolder>
|
||||||
@@ -247,6 +252,7 @@ export function RenderMessageContent({
|
|||||||
mimeType={mimeType}
|
mimeType={mimeType}
|
||||||
url={url}
|
url={url}
|
||||||
encInfo={encInfo}
|
encInfo={encInfo}
|
||||||
|
autoPlay={mediaAutoLoad}
|
||||||
{...props}
|
{...props}
|
||||||
renderThumbnail={
|
renderThumbnail={
|
||||||
mediaAutoLoad
|
mediaAutoLoad
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ export const createRoomEncryptionState = () => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const createRoomPowerLevelsState = () => ({
|
||||||
|
type: StateEvent.RoomPowerLevels,
|
||||||
|
state_key: '',
|
||||||
|
content: {
|
||||||
|
events: {
|
||||||
|
'org.matrix.msc3401.call.member': 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export type CreateRoomData = {
|
export type CreateRoomData = {
|
||||||
version: string;
|
version: string;
|
||||||
type?: RoomType;
|
type?: RoomType;
|
||||||
@@ -98,6 +108,8 @@ export type CreateRoomData = {
|
|||||||
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
|
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
|
||||||
const initialState: ICreateRoomStateEvent[] = [];
|
const initialState: ICreateRoomStateEvent[] = [];
|
||||||
|
|
||||||
|
initialState.push(createRoomPowerLevelsState());
|
||||||
|
|
||||||
if (data.encryption) {
|
if (data.encryption) {
|
||||||
initialState.push(createRoomEncryptionState());
|
initialState.push(createRoomEncryptionState());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,14 @@ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
|||||||
if (disableControls) {
|
if (disableControls) {
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||||
<video className={classNames(css.Video, className)} {...props} ref={videoRef} />
|
<video
|
||||||
|
className={classNames(css.Video, className)}
|
||||||
|
{...props}
|
||||||
|
ref={videoRef}
|
||||||
|
loop={props.autoPlay}
|
||||||
|
muted={props.autoPlay}
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +115,14 @@ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
|||||||
onMouseLeave={() => setShowControls(false)}
|
onMouseLeave={() => setShowControls(false)}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||||
<video className={classNames(css.Video, className)} {...props} ref={videoRef} />
|
<video
|
||||||
|
className={classNames(css.Video, className)}
|
||||||
|
{...props}
|
||||||
|
ref={videoRef}
|
||||||
|
loop={props.autoPlay}
|
||||||
|
muted={props.autoPlay}
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
|
||||||
{showControls && (
|
{showControls && (
|
||||||
<Box className={videoCss.VideoControlsOverlay} gap="200">
|
<Box className={videoCss.VideoControlsOverlay} gap="200">
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
|||||||
const filename = content.filename ?? content.body ?? 'Video';
|
const filename = content.filename ?? content.body ?? 'Video';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Attachment outlined={outlined}>
|
<Attachment outlined={outlined} transparent>
|
||||||
<AttachmentHeader>
|
<AttachmentHeader>
|
||||||
<FileHeader
|
<FileHeader
|
||||||
body={filename}
|
body={filename}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export const Attachment = recipe({
|
|||||||
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
|
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
transparent: {
|
||||||
|
true: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import classNames from 'classnames';
|
|||||||
import * as css from './Attachment.css';
|
import * as css from './Attachment.css';
|
||||||
|
|
||||||
export const Attachment = as<'div', css.AttachmentVariants>(
|
export const Attachment = as<'div', css.AttachmentVariants>(
|
||||||
({ className, outlined, ...props }, ref) => (
|
({ className, outlined, transparent, ...props }, ref) => (
|
||||||
<Box
|
<Box
|
||||||
display="InlineFlex"
|
display="InlineFlex"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
className={classNames(css.Attachment({ outlined }), className)}
|
className={classNames(css.Attachment({ outlined, transparent }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
|
|||||||
22
src/app/components/url-preview/GiphyEmbed.css.tsx
Normal file
22
src/app/components/url-preview/GiphyEmbed.css.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { DefaultReset, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const GiphyEmbed = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
width: toRem(480),
|
||||||
|
maxWidth: '100%',
|
||||||
|
display: 'block',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const GiphyVideo = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
border: 'none',
|
||||||
|
display: 'block',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
]);
|
||||||
64
src/app/components/url-preview/GiphyEmbed.tsx
Normal file
64
src/app/components/url-preview/GiphyEmbed.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { as } from 'folds';
|
||||||
|
import * as css from './GiphyEmbed.css';
|
||||||
|
|
||||||
|
const GIPHY_URL_PATTERNS = [
|
||||||
|
/(?:https?:\/\/)?(?:www\.)?giphy\.com\/gifs\/(?:[\w-]+-)([a-zA-Z0-9]+)(?:\?|$)/,
|
||||||
|
/(?:https?:\/\/)?(?:www\.)?giphy\.com\/embed\/([a-zA-Z0-9]+)(?:\?|$)/,
|
||||||
|
/(?:https?:\/\/)?(?:media\.)?giphy\.com\/media\/([a-zA-Z0-9]+)/,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isGiphyUrl(url: string): boolean {
|
||||||
|
return GIPHY_URL_PATTERNS.some((pattern) => pattern.test(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractGiphyId(url: string): string | null {
|
||||||
|
let gifId: string | null = null;
|
||||||
|
GIPHY_URL_PATTERNS.some((pattern) => {
|
||||||
|
const match = url.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
const [, id] = match;
|
||||||
|
if (id) {
|
||||||
|
gifId = id;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return gifId;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GiphyEmbedProps = {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GiphyEmbed = as<'div', GiphyEmbedProps>(({ url, ...props }, ref) => {
|
||||||
|
const gifId = extractGiphyId(url);
|
||||||
|
const [mediaUrl, setMediaUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!gifId) return;
|
||||||
|
|
||||||
|
// Try to get the direct media URL from Giphy
|
||||||
|
// Format: https://i.giphy.com/media/{ID}/giphy.mp4 or giphy.gif
|
||||||
|
const mp4Url = `https://i.giphy.com/media/${gifId}/giphy.mp4`;
|
||||||
|
setMediaUrl(mp4Url);
|
||||||
|
}, [gifId]);
|
||||||
|
|
||||||
|
if (!gifId || !mediaUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.GiphyEmbed} {...props} ref={ref}>
|
||||||
|
<video
|
||||||
|
className={css.GiphyVideo}
|
||||||
|
src={mediaUrl}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -2,3 +2,4 @@ export * from './UrlPreview';
|
|||||||
export * from './UrlPreviewCard';
|
export * from './UrlPreviewCard';
|
||||||
export * from './YouTubeEmbed';
|
export * from './YouTubeEmbed';
|
||||||
export * from './TikTokEmbed';
|
export * from './TikTokEmbed';
|
||||||
|
export * from './GiphyEmbed';
|
||||||
|
|||||||
@@ -182,6 +182,25 @@ export class CallService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const stateKey = this.config.userId;
|
const stateKey = this.config.userId;
|
||||||
|
|
||||||
|
// Check if user has permission to send this state event
|
||||||
|
const room = this.matrixClient.getRoom(roomId);
|
||||||
|
if (room) {
|
||||||
|
const powerLevelsEvent = room.currentState.getStateEvents('m.room.power_levels', '');
|
||||||
|
const powerLevelsContent = powerLevelsEvent?.getContent() || {};
|
||||||
|
|
||||||
|
const myPower = powerLevelsContent.users?.[this.config.userId] ?? powerLevelsContent.users_default ?? 0;
|
||||||
|
const requiredPower = powerLevelsContent.events?.[CALL_MEMBER_EVENT_TYPE] ?? powerLevelsContent.state_default ?? 50;
|
||||||
|
|
||||||
|
if (myPower < requiredPower) {
|
||||||
|
console.warn(
|
||||||
|
`Cannot send call member event: insufficient permissions. ` +
|
||||||
|
`User power level (${myPower}) < required power level (${requiredPower}). ` +
|
||||||
|
`Call will work locally but room members won't see your call status.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
const content: CallMemberEventContent = {
|
const content: CallMemberEventContent = {
|
||||||
'm.calls': [
|
'm.calls': [
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { ErrorCode } from '../../cs-errorcode';
|
import { ErrorCode } from '../../cs-errorcode';
|
||||||
import { millisecondsToMinutes } from '../../utils/common';
|
import { millisecondsToMinutes } from '../../utils/common';
|
||||||
import { createRoomEncryptionState } from '../../components/create-room';
|
import { createRoomEncryptionState, createRoomPowerLevelsState } from '../../components/create-room';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { getDirectRoomPath } from '../../pages/pathUtils';
|
import { getDirectRoomPath } from '../../pages/pathUtils';
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
|
|||||||
async (userId, encrypted) => {
|
async (userId, encrypted) => {
|
||||||
const initialState: ICreateRoomStateEvent[] = [];
|
const initialState: ICreateRoomStateEvent[] = [];
|
||||||
|
|
||||||
|
initialState.push(createRoomPowerLevelsState());
|
||||||
if (encrypted) initialState.push(createRoomEncryptionState());
|
if (encrypted) initialState.push(createRoomEncryptionState());
|
||||||
|
|
||||||
const result = await mx.createRoom({
|
const result = await mx.createRoom({
|
||||||
|
|||||||
@@ -88,7 +88,13 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
if (portalContainer && portalContainer.children.length > 0) {
|
if (portalContainer && portalContainer.children.length > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (shouldFocusMessageField(evt) || isKeyHotkey('mod+v', evt)) {
|
if (shouldFocusMessageField(evt)) {
|
||||||
|
evt.preventDefault();
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
if (evt.key.length === 1) {
|
||||||
|
editor.insertText(evt.key);
|
||||||
|
}
|
||||||
|
} else if (isKeyHotkey('mod+v', evt)) {
|
||||||
ReactEditor.focus(editor);
|
ReactEditor.focus(editor);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,8 +31,11 @@ import {
|
|||||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
|
import { editableActiveElement } from '../../utils/dom';
|
||||||
import {
|
import {
|
||||||
useEditor,
|
useEditor,
|
||||||
createMentionElement,
|
createMentionElement,
|
||||||
@@ -100,6 +103,37 @@ type ThreadViewProps = {
|
|||||||
threadRootId: string;
|
threadRootId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FN_KEYS_REGEX = /^F\d+$/;
|
||||||
|
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||||
|
const { code } = evt;
|
||||||
|
if (evt.metaKey || evt.altKey || evt.ctrlKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FN_KEYS_REGEX.test(code)) return false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
code.startsWith('OS') ||
|
||||||
|
code.startsWith('Meta') ||
|
||||||
|
code.startsWith('Shift') ||
|
||||||
|
code.startsWith('Alt') ||
|
||||||
|
code.startsWith('Control') ||
|
||||||
|
code.startsWith('Arrow') ||
|
||||||
|
code.startsWith('Page') ||
|
||||||
|
code.startsWith('End') ||
|
||||||
|
code.startsWith('Home') ||
|
||||||
|
code === 'Tab' ||
|
||||||
|
code === 'Space' ||
|
||||||
|
code === 'Enter' ||
|
||||||
|
code === 'NumLock' ||
|
||||||
|
code === 'ScrollLock'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a thread inline, replacing the main room timeline.
|
* Renders a thread inline, replacing the main room timeline.
|
||||||
* Structured as direct flex siblings of `Page` to match the RoomView layout contract.
|
* Structured as direct flex siblings of `Page` to match the RoomView layout contract.
|
||||||
@@ -285,6 +319,29 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
|||||||
const inputRef = useRef<HTMLDivElement>(null);
|
const inputRef = useRef<HTMLDivElement>(null);
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
|
|
||||||
|
useKeyDown(
|
||||||
|
window,
|
||||||
|
useCallback(
|
||||||
|
(evt) => {
|
||||||
|
if (editableActiveElement()) return;
|
||||||
|
const portalContainer = document.getElementById('portalContainer');
|
||||||
|
if (portalContainer && portalContainer.children.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shouldFocusMessageField(evt)) {
|
||||||
|
evt.preventDefault();
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
if (evt.key.length === 1) {
|
||||||
|
editor.insertText(evt.key);
|
||||||
|
}
|
||||||
|
} else if (isKeyHotkey('mod+v', evt)) {
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// Subscribe to thread and room timeline events.
|
// Subscribe to thread and room timeline events.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { useRoomNavigate } from './useRoomNavigate';
|
|||||||
import { Membership, StateEvent } from '../../types/matrix/room';
|
import { Membership, StateEvent } from '../../types/matrix/room';
|
||||||
import { getStateEvent } from '../utils/room';
|
import { getStateEvent } from '../utils/room';
|
||||||
import { splitWithSpace } from '../utils/common';
|
import { splitWithSpace } from '../utils/common';
|
||||||
import { createRoomEncryptionState } from '../components/create-room';
|
import { createRoomEncryptionState, createRoomPowerLevelsState } from '../components/create-room';
|
||||||
|
|
||||||
export const SHRUG = '¯\\_(ツ)_/¯';
|
export const SHRUG = '¯\\_(ツ)_/¯';
|
||||||
export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻';
|
export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻';
|
||||||
@@ -218,7 +218,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
|||||||
invite: userIds,
|
invite: userIds,
|
||||||
visibility: Visibility.Private,
|
visibility: Visibility.Private,
|
||||||
preset: Preset.TrustedPrivateChat,
|
preset: Preset.TrustedPrivateChat,
|
||||||
initial_state: [createRoomEncryptionState()],
|
initial_state: [createRoomPowerLevelsState(), createRoomEncryptionState()],
|
||||||
});
|
});
|
||||||
addRoomIdToMDirect(mx, result.room_id, userIds[0]);
|
addRoomIdToMDirect(mx, result.room_id, userIds[0]);
|
||||||
navigateRoom(result.room_id);
|
navigateRoom(result.room_id);
|
||||||
|
|||||||
Reference in New Issue
Block a user