195 lines
5.2 KiB
Svelte
195 lines
5.2 KiB
Svelte
<script lang="ts">
|
|
import { MatrixAvatar } from "$lib/components/ui";
|
|
import { getReadReceiptsForEvent } from "$lib/matrix";
|
|
import type { Message } from "$lib/matrix/types";
|
|
import { formatTime } from "./utils";
|
|
import {
|
|
MessageContent,
|
|
MessageMedia,
|
|
MessageReactions,
|
|
MessageActions,
|
|
MessageReadReceipts,
|
|
} from "./parts";
|
|
|
|
interface ReplyPreview {
|
|
senderName: string;
|
|
content: string;
|
|
senderAvatar: string | null;
|
|
hasAttachment: boolean;
|
|
}
|
|
|
|
interface Props {
|
|
message: Message;
|
|
isGrouped?: boolean;
|
|
isOwnMessage?: boolean;
|
|
isPinned?: boolean;
|
|
currentUserId?: string;
|
|
replyPreview?: ReplyPreview | null;
|
|
onReact?: (emoji: string) => void;
|
|
onToggleReaction?: (emoji: string, reactionEventId: string | null) => void;
|
|
onEdit?: () => void;
|
|
onDelete?: () => void;
|
|
onReply?: () => void;
|
|
onPin?: () => void;
|
|
onScrollToMessage?: (eventId: string) => void;
|
|
}
|
|
|
|
let {
|
|
message,
|
|
isGrouped = false,
|
|
isOwnMessage = false,
|
|
isPinned = false,
|
|
currentUserId = "",
|
|
replyPreview = null,
|
|
onReact,
|
|
onToggleReaction,
|
|
onEdit,
|
|
onDelete,
|
|
onReply,
|
|
onPin,
|
|
onScrollToMessage,
|
|
}: Props = $props();
|
|
|
|
let showActions = $state(false);
|
|
|
|
// Get read receipts for own messages
|
|
const readReceipts = $derived(
|
|
isOwnMessage
|
|
? getReadReceiptsForEvent(message.roomId, message.eventId)
|
|
: [],
|
|
);
|
|
|
|
// Check if message has media
|
|
const hasMedia = $derived(
|
|
["image", "video", "audio", "file"].includes(message.type) && message.media,
|
|
);
|
|
</script>
|
|
|
|
<div
|
|
class="group relative px-5 py-0.5 hover:bg-light/[0.02] transition-colors {message.isPending
|
|
? 'opacity-40'
|
|
: ''}"
|
|
onmouseenter={() => (showActions = true)}
|
|
onmouseleave={() => (showActions = false)}
|
|
role="article"
|
|
id="message-{message.eventId}"
|
|
>
|
|
<!-- Reply preview -->
|
|
{#if replyPreview && message.replyTo}
|
|
<button
|
|
class="flex items-center gap-1.5 ml-12 mt-1 mb-0.5 text-[11px] hover:opacity-80 transition-opacity cursor-pointer"
|
|
onclick={() => onScrollToMessage?.(message.replyTo!)}
|
|
>
|
|
<div class="w-0.5 h-3 bg-primary/40 rounded-full shrink-0"></div>
|
|
<MatrixAvatar
|
|
mxcUrl={replyPreview.senderAvatar}
|
|
name={replyPreview.senderName}
|
|
size="xs"
|
|
/>
|
|
<span class="text-light/50 font-body">{replyPreview.senderName}</span>
|
|
<span class="text-light/30 truncate max-w-xs">
|
|
{#if replyPreview.hasAttachment}
|
|
<span
|
|
class="material-symbols-rounded align-middle mr-0.5"
|
|
style="font-size: 12px;">image</span
|
|
>
|
|
{/if}
|
|
{replyPreview.content}
|
|
</span>
|
|
</button>
|
|
{/if}
|
|
|
|
{#if isGrouped}
|
|
<!-- Grouped message -->
|
|
<div class="flex gap-3">
|
|
<div class="w-9 shrink-0 flex items-center justify-center">
|
|
<span
|
|
class="text-[10px] text-light/20 opacity-0 group-hover:opacity-100 transition-opacity select-none"
|
|
>
|
|
{formatTime(message.timestamp)}
|
|
</span>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
{#if hasMedia && message.media}
|
|
<MessageMedia
|
|
type={message.type as "image" | "video" | "audio" | "file"}
|
|
media={message.media}
|
|
altText={message.content}
|
|
/>
|
|
{:else}
|
|
<MessageContent
|
|
content={message.content}
|
|
isEdited={message.isEdited}
|
|
isRedacted={message.isRedacted}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<!-- Full message with avatar -->
|
|
<div class="flex gap-3 mt-3 first:mt-0">
|
|
<div class="w-9 shrink-0 pt-0.5">
|
|
<MatrixAvatar
|
|
mxcUrl={message.senderAvatar}
|
|
name={message.senderName}
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-baseline gap-2 mb-px">
|
|
<span
|
|
class="text-[13px] font-heading text-white hover:underline cursor-pointer"
|
|
>
|
|
{message.senderName}
|
|
</span>
|
|
<span class="text-[10px] text-light/25 select-none">
|
|
{formatTime(message.timestamp)}
|
|
</span>
|
|
</div>
|
|
{#if hasMedia && message.media}
|
|
<MessageMedia
|
|
type={message.type as "image" | "video" | "audio" | "file"}
|
|
media={message.media}
|
|
altText={message.content}
|
|
/>
|
|
{:else}
|
|
<MessageContent
|
|
content={message.content}
|
|
isEdited={message.isEdited}
|
|
isRedacted={message.isRedacted}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Reactions -->
|
|
<MessageReactions
|
|
reactions={message.reactions}
|
|
{currentUserId}
|
|
isRedacted={message.isRedacted}
|
|
{onReact}
|
|
{onToggleReaction}
|
|
/>
|
|
|
|
<!-- Read receipts (own messages only) -->
|
|
{#if isOwnMessage}
|
|
<MessageReadReceipts receipts={readReceipts} />
|
|
{/if}
|
|
|
|
<!-- Action buttons (show on hover) -->
|
|
{#if showActions && !message.isRedacted}
|
|
<MessageActions
|
|
{isOwnMessage}
|
|
{isPinned}
|
|
messageContent={message.content}
|
|
messageEventId={message.eventId}
|
|
{onReact}
|
|
{onReply}
|
|
{onEdit}
|
|
{onDelete}
|
|
{onPin}
|
|
/>
|
|
{/if}
|
|
</div>
|