Skip to content
Merged
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
44 changes: 41 additions & 3 deletions packages/core/src/matrix/client/providers/matrix-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export interface EditRoomMessageInput {
message: string;
}

export interface RedactRoomEventInput {
roomId: string;
eventId: string;
}
/**
* Thrown when some attachment events were committed but a later send step failed.
* Callers should restore only `attachments.slice(sentAttachmentCount)` and optionally caption text.
Expand Down Expand Up @@ -179,6 +183,7 @@ interface MatrixContextType {
createRoom: (title: string) => Promise<{ roomId: string }>;
sendMessage: (params: SendMessageInput) => Promise<void>;
editRoomMessage: (params: EditRoomMessageInput) => Promise<void>;
redactRoomEvent: (params: RedactRoomEventInput) => Promise<void>;
toggleReaction: (params: ToggleReactionInput) => Promise<void>;
getRoomMessages: (roomId: string) => Message[] | null;
getPinnedMessageIds: (roomId: string) => string[];
Expand Down Expand Up @@ -665,7 +670,7 @@ export const MatrixProvider: React.FC<MatrixProviderProps> = ({ children }) => {
const sender = targetEv.getSender();
const uid = client.getUserId();
if (!sender || !uid || sender !== uid) {
throw new Error('You can only edit your own messages');
throw new Error('Cannot edit events you do not own');
}

const originalContent = targetEv.getContent() as {
Expand All @@ -684,7 +689,7 @@ export const MatrixProvider: React.FC<MatrixProviderProps> = ({ children }) => {

if (replyToId?.trim()) {
const {
eventId: resolvedTargetId,
eventId: resolvedReplyTargetId,
sender: replyTargetSender,
body: targetBody,
} = await resolveReplyTargetForSend(client, roomId, replyToId);
Expand All @@ -698,7 +703,7 @@ export const MatrixProvider: React.FC<MatrixProviderProps> = ({ children }) => {
...rich,
'm.relates_to': {
'm.in_reply_to': {
event_id: resolvedTargetId,
event_id: resolvedReplyTargetId,
},
},
} as RoomMessageEventContent;
Expand Down Expand Up @@ -726,6 +731,35 @@ export const MatrixProvider: React.FC<MatrixProviderProps> = ({ children }) => {
[client],
);

const redactRoomEvent = React.useCallback(
async ({ roomId, eventId }: RedactRoomEventInput) => {
if (!client) {
throw new Error('Client should be specified');
}
if (!roomId?.trim() || !eventId?.trim()) {
return;
}
const room = client.getRoom(roomId);
if (!room) {
throw new Error('Room not found');
}
const ev =
room.findEventById(eventId) ??
(typeof room.getPendingEvent === 'function'
? room.getPendingEvent(eventId)
: null);
if (!ev) {
throw new Error('Message not found');
}
const uid = client.getUserId();
const sender = ev.getSender();
if (!uid || !sender || sender !== uid) {
throw new Error('Cannot redact events you do not own');
}
await client.redactEvent(roomId, eventId);
},
[client],
);
const getPinnedMessageIds = React.useCallback(
(roomId: string): string[] => {
if (!client) {
Expand Down Expand Up @@ -1079,6 +1113,7 @@ export const MatrixProvider: React.FC<MatrixProviderProps> = ({ children }) => {
createRoom,
sendMessage,
editRoomMessage,
redactRoomEvent,
toggleReaction,
getRoomMessages,
getPinnedMessageIds,
Expand Down Expand Up @@ -1107,6 +1142,9 @@ const noopMatrixContext: MatrixContextType = {
editRoomMessage: async () => {
throw new Error('Matrix unavailable');
},
redactRoomEvent: async () => {
throw new Error('Matrix unavailable');
},
toggleReaction: async () => {
throw new Error('Matrix unavailable');
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,29 +281,35 @@ export function HumanChatPanelChatBar({
});
}, [colonOpen]);

const autoResize = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height =
Math.min(textareaRef.current.scrollHeight, 160) + 'px';
}
}, []);

useEffect(() => {
const isOpen = Boolean(replyPreview);
if (isOpen && !replyPreviewWasOpenRef.current) {
textareaRef.current?.focus();
if (isOpen) {
if (!replyPreviewWasOpenRef.current) {
textareaRef.current?.focus();
}
autoResize();
}
replyPreviewWasOpenRef.current = isOpen;
}, [replyPreview]);
}, [replyPreview, autoResize]);

useEffect(() => {
const isOpen = Boolean(editPreview);
if (isOpen && !editPreviewWasOpenRef.current) {
textareaRef.current?.focus();
if (isOpen) {
if (!editPreviewWasOpenRef.current) {
textareaRef.current?.focus();
}
autoResize();
}
editPreviewWasOpenRef.current = isOpen;
}, [editPreview]);

const autoResize = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height =
Math.min(textareaRef.current.scrollHeight, 160) + 'px';
}
}, []);
}, [editPreview, autoResize]);

const syncColonState = useCallback((val: string, cursor: number) => {
const requestId = ++colonRequestIdRef.current;
Expand Down Expand Up @@ -343,6 +349,10 @@ export function HumanChatPanelChatBar({
syncColonState(value, el.selectionStart ?? value.length);
}, [value, syncColonState]);

useEffect(() => {
autoResize();
}, [value, autoResize]);

const applyColonChoice = useCallback(
(entry: EmojiIndexEntry) => {
const el = textareaRef.current;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
'use client';

import { Fragment, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { useFormatter, useTranslations } from 'next-intl';
import type { TranslationValues } from 'next-intl';
import {
Smile,
SmilePlus,
Pencil,
Reply,
MoreHorizontal,
FileIcon,
ExternalLink,
Image as ImageIcon,
Expand All @@ -20,6 +20,10 @@ import { useMatrix } from '@hypha-platform/core/client';
import { PersonAvatar } from '../../people/components/person-avatar';

import { HumanChatPanelEmojiPicker } from './human-chat-panel-emoji-picker';
import {
HumanChatPanelMessageOverflow,
pushRecentChatReaction,
} from './human-chat-panel-message-overflow';
import { ChatMessageRichText } from './parse-simple-matrix-html';
import {
type ChatPanelAttachmentMedia,
Expand Down Expand Up @@ -490,12 +494,18 @@ type HumanChatPanelMessageBubbleProps = {
onRowPointerLeave?: () => void;
/** Notify when the hover-bar emoji picker opens/closes (parent may lock visibility). */
onHoverReactPickerOpenChange?: (open: boolean) => void;
/** Active Matrix room (for message link + overflow). */
roomId?: string | null;
/** Logged-in Matrix user id (delete permission + recent reactions). */
currentUserId?: string | null;
message: {
id: string;
role: 'user' | 'member';
isSynthetic?: boolean;
parts?: UIMessagePart[];
senderName?: string;
/** Author MXID when known (overflow delete). */
senderMatrixId?: string;
avatarUrl?: string;
timestamp?: Date;
reactions?: Reaction[];
Expand All @@ -520,6 +530,7 @@ type HumanChatPanelMessageBubbleProps = {
onReply?: () => void;
/** When set, Edit is enabled (own text messages only; parent omits otherwise). */
onEdit?: () => void;
onDeleteMessage?: (messageId: string) => void | Promise<void>;
/** When set, user can open react picker (omit for welcome). */
onReact?: (emoji: string) => void | Promise<void>;
};
Expand Down Expand Up @@ -708,8 +719,11 @@ export function HumanChatPanelMessageBubble({
onHoverReactPickerOpenChange,
message,
isStreaming,
roomId,
currentUserId,
onReply,
onEdit,
onDeleteMessage,
onReact,
}: HumanChatPanelMessageBubbleProps) {
const t = useTranslations('HumanChatPanel');
Expand Down Expand Up @@ -799,7 +813,7 @@ export function HumanChatPanelMessageBubble({
reactions.length - MAX_VISIBLE_REACTIONS,
);

return (
const row = (moreSlot: ReactNode | null) => (
<div
data-testid="chat-message"
className={cn(
Expand Down Expand Up @@ -1227,6 +1241,7 @@ export function HumanChatPanelMessageBubble({
open={inlineReactPickerOpen}
onOpenChange={setInlineReactPickerOpen}
onEmojiSelect={(native) => {
pushRecentChatReaction(native);
void onReact(native);
}}
ariaLabel={t('addReactionButton')}
Expand Down Expand Up @@ -1264,7 +1279,10 @@ export function HumanChatPanelMessageBubble({
onHoverReactPickerOpenChange?.(open);
}}
onEmojiSelect={(native) => {
if (onReact) void onReact(native);
if (onReact) {
pushRecentChatReaction(native);
void onReact(native);
}
}}
ariaLabel={t('emojiPickerReactToMessage')}
align="end"
Expand Down Expand Up @@ -1300,16 +1318,36 @@ export function HumanChatPanelMessageBubble({
>
<Reply className="h-3 w-3" strokeWidth={2} />
</button>
<button
type="button"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-sm p-0 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground [&_svg]:block"
aria-label={t('moreButton')}
disabled
aria-disabled
>
<MoreHorizontal className="h-3 w-3" strokeWidth={2} />
</button>
{moreSlot}
</div>
</div>
);

if (message.isSynthetic) {
return row(null);
}

return (
<HumanChatPanelMessageOverflow
roomId={roomId ?? null}
messageId={message.id}
disabled={false}
canReact={canReact}
onReact={onReact}
onEdit={onEdit}
onReply={onReply}
menuCanEdit={canEdit}
menuCanReply={canReply}
onDeleteMessage={onDeleteMessage}
currentUserId={currentUserId}
senderMatrixId={message.senderMatrixId}
message={{
parts: message.parts,
media: message.media,
mediaSlots: message.mediaSlots,
}}
>
{row}
</HumanChatPanelMessageOverflow>
);
}
Loading
Loading