diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx
index 5da62e0..672dc70 100644
--- a/src/app/components/RenderMessageContent.tsx
+++ b/src/app/components/RenderMessageContent.tsx
@@ -25,7 +25,7 @@ import {
VideoContent,
VideoFrameThumbnail,
} 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 { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer';
@@ -71,7 +71,8 @@ export function RenderMessageContent({
// Separate special URLs from generic ones
const youtubeUrls = filteredUrls.filter(isYouTubeUrl);
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 (
<>
@@ -83,6 +84,10 @@ export function RenderMessageContent({
{tiktokUrls.map((url) => (
))}
+ {/* Render Giphy embeds */}
+ {giphyUrls.map((url) => (
+
+ ))}
{/* Render standard URL previews for other links */}
{otherUrls.length > 0 && (
@@ -247,6 +252,7 @@ export function RenderMessageContent({
mimeType={mimeType}
url={url}
encInfo={encInfo}
+ autoPlay={mediaAutoLoad}
{...props}
renderThumbnail={
mediaAutoLoad
diff --git a/src/app/components/create-room/utils.ts b/src/app/components/create-room/utils.ts
index a0ca748..86091bf 100644
--- a/src/app/components/create-room/utils.ts
+++ b/src/app/components/create-room/utils.ts
@@ -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 = {
version: string;
type?: RoomType;
@@ -98,6 +108,8 @@ export type CreateRoomData = {
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise => {
const initialState: ICreateRoomStateEvent[] = [];
+ initialState.push(createRoomPowerLevelsState());
+
if (data.encryption) {
initialState.push(createRoomEncryptionState());
}
diff --git a/src/app/components/media/Video.tsx b/src/app/components/media/Video.tsx
index 3018347..15d49f3 100644
--- a/src/app/components/media/Video.tsx
+++ b/src/app/components/media/Video.tsx
@@ -96,7 +96,14 @@ export const Video = forwardRef(
if (disableControls) {
return (
// eslint-disable-next-line jsx-a11y/media-has-caption
-
+
);
}
@@ -108,7 +115,14 @@ export const Video = forwardRef(
onMouseLeave={() => setShowControls(false)}
>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
-
+
{showControls && (
diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx
index 8dc3bb3..544b7ce 100644
--- a/src/app/components/message/MsgTypeRenderers.tsx
+++ b/src/app/components/message/MsgTypeRenderers.tsx
@@ -243,7 +243,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
const filename = content.filename ?? content.body ?? 'Video';
return (
-
+
(
- ({ className, outlined, ...props }, ref) => (
+ ({ className, outlined, transparent, ...props }, ref) => (
diff --git a/src/app/components/url-preview/GiphyEmbed.css.tsx b/src/app/components/url-preview/GiphyEmbed.css.tsx
new file mode 100644
index 0000000..606b8d4
--- /dev/null
+++ b/src/app/components/url-preview/GiphyEmbed.css.tsx
@@ -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',
+ },
+]);
diff --git a/src/app/components/url-preview/GiphyEmbed.tsx b/src/app/components/url-preview/GiphyEmbed.tsx
new file mode 100644
index 0000000..ddb6fcb
--- /dev/null
+++ b/src/app/components/url-preview/GiphyEmbed.tsx
@@ -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(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 (
+
+
+
+ );
+});
diff --git a/src/app/components/url-preview/index.ts b/src/app/components/url-preview/index.ts
index dc55f00..94e59c2 100644
--- a/src/app/components/url-preview/index.ts
+++ b/src/app/components/url-preview/index.ts
@@ -2,3 +2,4 @@ export * from './UrlPreview';
export * from './UrlPreviewCard';
export * from './YouTubeEmbed';
export * from './TikTokEmbed';
+export * from './GiphyEmbed';
diff --git a/src/app/features/call/CallService.ts b/src/app/features/call/CallService.ts
index 452ea24..fd1aaae 100644
--- a/src/app/features/call/CallService.ts
+++ b/src/app/features/call/CallService.ts
@@ -182,6 +182,25 @@ export class CallService {
): Promise {
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) {
const content: CallMemberEventContent = {
'm.calls': [
diff --git a/src/app/features/create-chat/CreateChat.tsx b/src/app/features/create-chat/CreateChat.tsx
index faa4511..92e20a0 100644
--- a/src/app/features/create-chat/CreateChat.tsx
+++ b/src/app/features/create-chat/CreateChat.tsx
@@ -9,7 +9,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { ErrorCode } from '../../cs-errorcode';
import { millisecondsToMinutes } from '../../utils/common';
-import { createRoomEncryptionState } from '../../components/create-room';
+import { createRoomEncryptionState, createRoomPowerLevelsState } from '../../components/create-room';
import { useAlive } from '../../hooks/useAlive';
import { getDirectRoomPath } from '../../pages/pathUtils';
@@ -29,6 +29,7 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
async (userId, encrypted) => {
const initialState: ICreateRoomStateEvent[] = [];
+ initialState.push(createRoomPowerLevelsState());
if (encrypted) initialState.push(createRoomEncryptionState());
const result = await mx.createRoom({
diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx
index ae3cab0..6d7a7f7 100644
--- a/src/app/features/room/RoomView.tsx
+++ b/src/app/features/room/RoomView.tsx
@@ -88,7 +88,13 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
if (portalContainer && portalContainer.children.length > 0) {
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);
}
},
diff --git a/src/app/features/room/ThreadView.tsx b/src/app/features/room/ThreadView.tsx
index 5f6b26a..a32615f 100644
--- a/src/app/features/room/ThreadView.tsx
+++ b/src/app/features/room/ThreadView.tsx
@@ -31,8 +31,11 @@ import {
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { ReactEditor } from 'slate-react';
+import { isKeyHotkey } from 'is-hotkey';
import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useKeyDown } from '../../hooks/useKeyDown';
+import { editableActiveElement } from '../../utils/dom';
import {
useEditor,
createMentionElement,
@@ -100,6 +103,37 @@ type ThreadViewProps = {
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.
* 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(null);
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.
useEffect(() => {
// eslint-disable-next-line no-console
diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts
index c02b0e9..d116661 100644
--- a/src/app/hooks/useCommands.ts
+++ b/src/app/hooks/useCommands.ts
@@ -26,7 +26,7 @@ import { useRoomNavigate } from './useRoomNavigate';
import { Membership, StateEvent } from '../../types/matrix/room';
import { getStateEvent } from '../utils/room';
import { splitWithSpace } from '../utils/common';
-import { createRoomEncryptionState } from '../components/create-room';
+import { createRoomEncryptionState, createRoomPowerLevelsState } from '../components/create-room';
export const SHRUG = '¯\\_(ツ)_/¯';
export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻';
@@ -218,7 +218,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
invite: userIds,
visibility: Visibility.Private,
preset: Preset.TrustedPrivateChat,
- initial_state: [createRoomEncryptionState()],
+ initial_state: [createRoomPowerLevelsState(), createRoomEncryptionState()],
});
addRoomIdToMDirect(mx, result.room_id, userIds[0]);
navigateRoom(result.room_id);