feat: Message context menus

This commit is contained in:
April Hall 2025-02-25 10:25:48 -05:00
parent 8706b3eef6
commit c7b46233b2
No known key found for this signature in database
GPG Key ID: A49AC35CB186266C
17 changed files with 333 additions and 26 deletions

View File

@ -25,6 +25,7 @@ io.on('connection', async (socket) => {
const sender = authdb.getUser(msg.id);
io!.emit('message', {
user: sender.username,
uid: msg.id,
message: msg.content,
imageSrc: sender.image,
channel: msg.channel,

View File

@ -2,33 +2,58 @@
import { type TypeMessage } from '$lib/types';
import Prose from '$lib/components/prose.svelte';
import renderMarkdown from '$lib/functions/renderMarkdown';
const { message, imageSrc, user, timestamp }: TypeMessage = $props();
import * as ContextMenu from '$lib/components/ui/context-menu';
const { message, imageSrc, user, timestamp, uid }: TypeMessage = $props();
let epoch: number = Math.floor(timestamp.getTime() / 1000);
function copy(itemName: string, content: string | number) {
navigator.clipboard
.writeText(content as string)
.then(() => {
console.info(`Successfully copied ${itemName} to clipboard`);
// dispatchToast('Successfully copied to clipboard.', true);
})
.catch((e) => {
console.error(`Error copying ${itemName}: ${(e as Error).message}`);
// dispatchToast('Copying failed. (See console)', false);
});
}
</script>
<div class="flex w-full p-2 hover:bg-zinc-200 dark:hover:bg-stone-900">
<div class="avatar mr-2 rounded-sm">
<div class="h-12 w-12 overflow-hidden rounded-lg border bg-white">
<img src={imageSrc} alt="Profile image for {user}" />
<ContextMenu.Root>
<ContextMenu.Trigger class="flex w-full p-2 hover:bg-zinc-200 dark:hover:bg-stone-900">
<div class="avatar mr-2 rounded-sm">
<div class="h-12 w-12 overflow-hidden rounded-lg border bg-white">
<img src={imageSrc} alt="Profile image for {user}" />
</div>
</div>
</div>
<div class="w-full">
<p class="inline-size-full flex items-center gap-1 break-words">
<span class="font-bold">{user}</span>
<span>·</span>
<span class="text-muted-foreground"
>{timestamp.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour12: true,
hour: 'numeric',
minute: 'numeric',
})}</span
>
</p>
<Prose class="inline-size-full text-wrap break-words font-sans">{@html renderMarkdown(message)}</Prose>
</div>
</div>
<div class="w-full">
<p class="inline-size-full flex items-center gap-1 break-words">
<span class="font-bold">{user}</span>
<span>·</span>
<span class="text-muted-foreground"
>{timestamp.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour12: true,
hour: 'numeric',
minute: 'numeric',
})}</span
>
</p>
<Prose class="inline-size-full text-wrap break-words font-sans">{@html renderMarkdown(message)}</Prose>
</div>
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item class="cursor-pointer" onclick={() => copy('username', user)}>Copy Username</ContextMenu.Item>
<ContextMenu.Item class="cursor-pointer" onclick={() => copy('user ID', uid)}>Copy User ID</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item class="cursor-pointer" onclick={() => copy('message', message)}>Copy message content</ContextMenu.Item>
<ContextMenu.Item class="cursor-pointer" onclick={() => copy('timestamp', epoch)}>Copy message epoch</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
<style>
.inline-size-full {

View File

@ -0,0 +1,35 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import Check from 'svelte-radix/Check.svelte';
import { cn } from '$lib/utils.js';
type $$Props = ContextMenuPrimitive.CheckboxItemProps;
type $$Events = ContextMenuPrimitive.CheckboxItemEvents;
let className: $$Props['class'] = undefined;
export { className as class };
export let checked: $$Props['checked'] = undefined;
</script>
<ContextMenuPrimitive.CheckboxItem
bind:checked
class={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
className,
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.CheckboxIndicator>
<Check class="h-4 w-4" />
</ContextMenuPrimitive.CheckboxIndicator>
</span>
<slot />
</ContextMenuPrimitive.CheckboxItem>

View File

@ -0,0 +1,21 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import { cn, flyAndScale } from '$lib/utils.js';
type $$Props = ContextMenuPrimitive.ContentProps;
let className: $$Props['class'] = undefined;
export let transition: $$Props['transition'] = flyAndScale;
export let transitionConfig: $$Props['transitionConfig'] = undefined;
export { className as class };
</script>
<ContextMenuPrimitive.Content
{transition}
{transitionConfig}
class={cn('z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md focus:outline-none', className)}
{...$$restProps}
on:keydown
>
<slot />
</ContextMenuPrimitive.Content>

View File

@ -0,0 +1,31 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
type $$Props = ContextMenuPrimitive.ItemProps & {
inset?: boolean;
};
type $$Events = ContextMenuPrimitive.ItemEvents;
let className: $$Props['class'] = undefined;
export let inset: $$Props['inset'] = undefined;
export { className as class };
</script>
<ContextMenuPrimitive.Item
class={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
inset && 'pl-8',
className,
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
>
<slot />
</ContextMenuPrimitive.Item>

View File

@ -0,0 +1,16 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
type $$Props = ContextMenuPrimitive.LabelProps & {
inset?: boolean;
};
let className: $$Props['class'] = undefined;
export let inset: $$Props['inset'] = undefined;
export { className as class };
</script>
<ContextMenuPrimitive.Label class={cn('px-2 py-1.5 text-sm font-semibold text-foreground', inset && 'pl-8', className)} {...$$restProps}>
<slot />
</ContextMenuPrimitive.Label>

View File

@ -0,0 +1,11 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
type $$Props = ContextMenuPrimitive.RadioGroupProps;
export let value: $$Props['value'] = undefined;
</script>
<ContextMenuPrimitive.RadioGroup {...$$restProps} bind:value>
<slot />
</ContextMenuPrimitive.RadioGroup>

View File

@ -0,0 +1,35 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import DotFilled from 'svelte-radix/DotFilled.svelte';
import { cn } from '$lib/utils.js';
type $$Props = ContextMenuPrimitive.RadioItemProps;
type $$Events = ContextMenuPrimitive.RadioItemEvents;
let className: $$Props['class'] = undefined;
export let value: $$Props['value'];
export { className as class };
</script>
<ContextMenuPrimitive.RadioItem
class={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
className,
)}
{value}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.RadioIndicator>
<DotFilled class="h-4 w-4 fill-current" />
</ContextMenuPrimitive.RadioIndicator>
</span>
<slot />
</ContextMenuPrimitive.RadioItem>

View File

@ -0,0 +1,11 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
type $$Props = ContextMenuPrimitive.SeparatorProps;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<ContextMenuPrimitive.Separator class={cn('-mx-1 my-1 h-px bg-border', className)} {...$$restProps} />

View File

@ -0,0 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
type $$Props = HTMLAttributes<HTMLSpanElement>;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<span class={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...$$restProps}>
<slot />
</span>

View File

@ -0,0 +1,23 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import { cn, flyAndScale } from '$lib/utils.js';
type $$Props = ContextMenuPrimitive.SubContentProps;
let className: $$Props['class'] = undefined;
export let transition: $$Props['transition'] = flyAndScale;
export let transitionConfig: $$Props['transitionConfig'] = undefined;
export { className as class };
</script>
<ContextMenuPrimitive.SubContent
{transition}
{transitionConfig}
class={cn('z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-lg focus:outline-none', className)}
{...$$restProps}
on:keydown
on:focusout
on:pointermove
>
<slot />
</ContextMenuPrimitive.SubContent>

View File

@ -0,0 +1,32 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import ChevronRight from 'svelte-radix/ChevronRight.svelte';
import { cn } from '$lib/utils.js';
type $$Props = ContextMenuPrimitive.SubTriggerProps & {
inset?: boolean;
};
type $$Events = ContextMenuPrimitive.SubTriggerEvents;
let className: $$Props['class'] = undefined;
export let inset: $$Props['inset'] = undefined;
export { className as class };
</script>
<ContextMenuPrimitive.SubTrigger
class={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[state=open]:bg-accent data-[highlighted]:text-accent-foreground data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className,
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerleave
on:pointermove
>
<slot />
<ChevronRight class="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>

View File

@ -0,0 +1,49 @@
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import Item from './context-menu-item.svelte';
import Label from './context-menu-label.svelte';
import Content from './context-menu-content.svelte';
import Shortcut from './context-menu-shortcut.svelte';
import RadioItem from './context-menu-radio-item.svelte';
import Separator from './context-menu-separator.svelte';
import RadioGroup from './context-menu-radio-group.svelte';
import SubContent from './context-menu-sub-content.svelte';
import SubTrigger from './context-menu-sub-trigger.svelte';
import CheckboxItem from './context-menu-checkbox-item.svelte';
const Sub = ContextMenuPrimitive.Sub;
const Root = ContextMenuPrimitive.Root;
const Trigger = ContextMenuPrimitive.Trigger;
const Group = ContextMenuPrimitive.Group;
export {
Sub,
Root,
Item,
Label,
Group,
Trigger,
Content,
Shortcut,
Separator,
RadioItem,
SubContent,
SubTrigger,
RadioGroup,
CheckboxItem,
//
Root as ContextMenu,
Sub as ContextMenuSub,
Item as ContextMenuItem,
Label as ContextMenuLabel,
Group as ContextMenuGroup,
Content as ContextMenuContent,
Trigger as ContextMenuTrigger,
Shortcut as ContextMenuShortcut,
RadioItem as ContextMenuRadioItem,
Separator as ContextMenuSeparator,
RadioGroup as ContextMenuRadioGroup,
SubContent as ContextMenuSubContent,
SubTrigger as ContextMenuSubTrigger,
CheckboxItem as ContextMenuCheckboxItem,
};

View File

@ -33,6 +33,7 @@ export function startupSocketIOServer(httpServer: HttpServer | null) {
const sender = authdb.getUser(msg.id);
io!.emit('message', {
user: sender.username,
uid: msg.id,
message: msg.content,
imageSrc: sender.image,
channel: msg.channel,

View File

@ -2,6 +2,7 @@ export interface TypeMessage {
message: string;
imageSrc: string;
user: string;
uid: string;
timestamp: Date;
}
@ -10,5 +11,6 @@ export interface TypeFullMessage {
message: string;
imageSrc: string;
user: string;
uid: string;
timestamp: Date;
}

View File

@ -29,6 +29,7 @@ export async function load({ params, request }): Promise<ChannelLoad> {
return {
message: value.message_content,
user: sender.username,
uid: value.sender,
imageSrc: sender.image,
channel: value.channel,
timestamp: new Date(value.timestamp),

View File

@ -70,8 +70,8 @@
</svelte:head>
{#snippet message(messages: TypeMessage[])}
{#each messages as message}
<Message imageSrc={message.imageSrc} user={message.user} message={message.message} timestamp={message.timestamp} />
{#each messages as message, i}
<Message imageSrc={message.imageSrc} user={message.user} message={message.message} timestamp={message.timestamp} uid={message.uid} />
{/each}
{/snippet}