messages, wavings and images
This commit is contained in:
@@ -1,22 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Popover from "$lib/components/popover.svelte";
|
||||
import { friends } from "$lib/listeners/friends";
|
||||
import { incomingInteraction } from "$lib/listeners/interactions";
|
||||
import { liveMetadata } from "$lib/listeners/live-metadata";
|
||||
import SceneUserPopoverContent from "./scene-user-popover-content.svelte";
|
||||
import { profile } from "$lib/listeners/profile";
|
||||
import SceneImageViewer from "./popovers/image-viewer.svelte";
|
||||
import SceneInteractionBubble from "./popovers/interaction-bubble.svelte";
|
||||
import SceneUserPopoverContent from "./popovers/user-interaction.svelte";
|
||||
import { startHitboxSync } from "./hitboxes";
|
||||
|
||||
let selectedUserId: string | null = null;
|
||||
let selectedUserX: number | null = null;
|
||||
let selectedUserId = $state<string | null>(null);
|
||||
let selectedUserX = $state<number | null>(null);
|
||||
let lockedPopoverUserId = $state<string | null>(null);
|
||||
let viewedImage = $state<{ source: string; senderName: string } | null>(null);
|
||||
|
||||
onMount(startHitboxSync);
|
||||
|
||||
$effect(() => {
|
||||
const current = $incomingInteraction;
|
||||
if (!current) return;
|
||||
const duration = current.content.type === "wave" ? 4_000 : 10_000;
|
||||
const timer = window.setTimeout(
|
||||
() => dismissInteraction(current.interactionId),
|
||||
duration,
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
});
|
||||
|
||||
function displayName(userId: string) {
|
||||
return $profile?.id === userId
|
||||
? $profile.displayName
|
||||
: ($friends.find((friend) => friend.id === userId)?.displayName ??
|
||||
"Unknown user");
|
||||
}
|
||||
|
||||
function dismissInteraction(interactionId: string) {
|
||||
incomingInteraction.update((current) =>
|
||||
current?.interactionId === interactionId ? null : current,
|
||||
);
|
||||
}
|
||||
|
||||
function setPopoverOpen(
|
||||
userId: string,
|
||||
cursorX: number,
|
||||
open: boolean,
|
||||
trigger: HTMLButtonElement,
|
||||
) {
|
||||
if (open && lockedPopoverUserId && lockedPopoverUserId !== userId) return;
|
||||
selectedUserId = open ? userId : null;
|
||||
if (!open && lockedPopoverUserId === userId) lockedPopoverUserId = null;
|
||||
selectedUserX = open
|
||||
? window.innerWidth > 0
|
||||
? trigger.getBoundingClientRect().left / window.innerWidth
|
||||
@@ -30,6 +63,8 @@
|
||||
{#each Object.entries($liveMetadata.cursorPositions) as [userId, cursor], index (userId)}
|
||||
{@const foregroundApp = $liveMetadata.foregroundApps.get(userId)}
|
||||
{@const popoverId = `scene-user-${index}`}
|
||||
{@const interaction =
|
||||
$incomingInteraction?.friendId === userId ? $incomingInteraction : null}
|
||||
{#if cursor}
|
||||
{@const renderedX =
|
||||
selectedUserId === userId ? selectedUserX : cursor.mapped.x}
|
||||
@@ -37,6 +72,18 @@
|
||||
class="absolute bottom-0 left-0 flex flex-col items-center gap-1 transition-transform duration-1000 ease-linear"
|
||||
style:transform={`translateX(${(renderedX ?? cursor.mapped.x) * 100}vw)`}
|
||||
>
|
||||
{#if interaction}
|
||||
<SceneInteractionBubble
|
||||
{interaction}
|
||||
senderName={displayName(userId)}
|
||||
onDismiss={() => dismissInteraction(interaction.interactionId)}
|
||||
onOpenImage={(source) => {
|
||||
viewedImage = { source, senderName: displayName(userId) };
|
||||
dismissInteraction(interaction.interactionId);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if foregroundApp?.ico}
|
||||
<img
|
||||
src={`data:image/png;base64,${foregroundApp.ico}`}
|
||||
@@ -55,6 +102,9 @@
|
||||
triggerClass="scene-hitbox"
|
||||
panelClass="scene-hitbox bottom-[calc(100%+0.75rem)] w-68"
|
||||
panelStyle={`left: clamp(calc(0.5rem - ${(renderedX ?? cursor.mapped.x) * 100}vw), -8rem, calc(100vw - ${(renderedX ?? cursor.mapped.x) * 100}vw - 17.5rem))`}
|
||||
closeOnOutsidePointer={lockedPopoverUserId !== userId}
|
||||
closeOnWindowBlur={lockedPopoverUserId !== userId}
|
||||
closeOnFocusOut={lockedPopoverUserId !== userId}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<img
|
||||
@@ -68,8 +118,19 @@
|
||||
titleId={`${popoverId}-title`}
|
||||
{userId}
|
||||
isLocal={userId === $liveMetadata.localId}
|
||||
{cursor}
|
||||
{foregroundApp}
|
||||
onModeChange={(active) =>
|
||||
(lockedPopoverUserId = active ? userId : null)}
|
||||
onDismiss={() => {
|
||||
selectedUserId = null;
|
||||
selectedUserX = null;
|
||||
lockedPopoverUserId = null;
|
||||
}}
|
||||
onSent={() => {
|
||||
selectedUserId = null;
|
||||
selectedUserX = null;
|
||||
lockedPopoverUserId = null;
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -77,3 +138,11 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if viewedImage}
|
||||
<SceneImageViewer
|
||||
source={viewedImage.source}
|
||||
senderName={viewedImage.senderName}
|
||||
onClose={() => (viewedImage = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { fade, scale } from "svelte/transition";
|
||||
|
||||
type Props = {
|
||||
source: string;
|
||||
senderName: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { source, senderName, onClose }: Props = $props();
|
||||
let closeButton: HTMLButtonElement;
|
||||
|
||||
onMount(() => closeButton.focus({ preventScroll: true }));
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(event) => event.key === "Escape" && onClose()} />
|
||||
|
||||
<div
|
||||
class="scene-hitbox fixed inset-0 z-50 grid place-items-center bg-black/55 p-4 backdrop-blur-sm"
|
||||
role="presentation"
|
||||
onclick={(event) => event.currentTarget === event.target && onClose()}
|
||||
transition:fade={{ duration: 150 }}
|
||||
>
|
||||
<div
|
||||
class="relative grid max-h-full max-w-full place-items-center border border-white/20 bg-black/70 p-2 shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Image from ${senderName}`}
|
||||
transition:scale={{ start: 0.96, duration: 180 }}
|
||||
>
|
||||
<img
|
||||
src={source}
|
||||
alt={`Image sent by ${senderName}`}
|
||||
class="max-h-[calc(100vh-3rem)] max-w-[calc(100vw-3rem)] object-contain"
|
||||
/>
|
||||
<div
|
||||
class="absolute right-1 top-1 flex items-center gap-2 bg-black/70 px-1 py-1 text-white"
|
||||
>
|
||||
<span class="max-w-48 truncate pl-1 text-[0.65rem]">{senderName}</span>
|
||||
<button
|
||||
bind:this={closeButton}
|
||||
type="button"
|
||||
class="grid size-6 place-items-center text-lg leading-none hover:bg-white/15 focus-visible:outline focus-visible:outline-white"
|
||||
aria-label="Close image"
|
||||
onclick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { fly } from "svelte/transition";
|
||||
import type { FriendInteractionReceived } from "$lib/bindings";
|
||||
|
||||
type Props = {
|
||||
interaction: FriendInteractionReceived;
|
||||
senderName: string;
|
||||
onDismiss: () => void;
|
||||
onOpenImage: (source: string) => void;
|
||||
};
|
||||
|
||||
let { interaction, senderName, onDismiss, onOpenImage }: Props = $props();
|
||||
let imageSource = $derived(
|
||||
interaction.content.type === "image"
|
||||
? `data:${interaction.content.mediaType};base64,${interaction.content.data}`
|
||||
: "",
|
||||
);
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="scene-hitbox absolute bottom-[calc(100%+0.65rem)] left-1/2 z-30 w-36 -translate-x-1/2 overflow-hidden border border-base-300 bg-base-100/95 text-base-content shadow-lg backdrop-blur-sm"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
transition:fly={{ y: 8, duration: 180 }}
|
||||
>
|
||||
<header class="flex items-center gap-1 border-b border-base-200 px-2 py-1">
|
||||
<span class="min-w-0 flex-1 truncate text-[0.65rem] font-medium">
|
||||
{senderName}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-5 place-items-center text-xs text-base-content/50 transition-colors hover:text-base-content"
|
||||
aria-label="Dismiss interaction"
|
||||
onclick={onDismiss}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if interaction.content.type === "wave"}
|
||||
<div class="flex items-center justify-center gap-2 px-3 py-2 text-xs">
|
||||
<span
|
||||
class="origin-bottom-right animate-[wiggle_650ms_ease-in-out_2] text-xl"
|
||||
>👋</span
|
||||
>
|
||||
<span>waved</span>
|
||||
</div>
|
||||
{:else if interaction.content.type === "text"}
|
||||
<p
|
||||
class="max-h-24 overflow-auto whitespace-pre-wrap wrap-break-words px-2 py-2 text-xs leading-relaxed"
|
||||
>
|
||||
{interaction.content.text}
|
||||
</p>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full cursor-zoom-in bg-base-200"
|
||||
aria-label={`Open image from ${senderName}`}
|
||||
onclick={() => onOpenImage(imageSource)}
|
||||
>
|
||||
<img
|
||||
src={imageSource}
|
||||
alt={`Preview sent by ${senderName}`}
|
||||
class="h-24 w-full object-cover"
|
||||
/>
|
||||
<span class="block px-2 py-1 text-[0.6rem] text-base-content/60">
|
||||
Click to enlarge
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
@keyframes wiggle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
30% {
|
||||
transform: rotate(22deg);
|
||||
}
|
||||
65% {
|
||||
transform: rotate(-12deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,236 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
commands,
|
||||
type AppMeta,
|
||||
type InteractionContent,
|
||||
} from "$lib/bindings";
|
||||
import { friends } from "$lib/listeners/friends";
|
||||
import { profile } from "$lib/listeners/profile";
|
||||
|
||||
type Props = {
|
||||
titleId: string;
|
||||
userId: string;
|
||||
isLocal: boolean;
|
||||
foregroundApp?: AppMeta;
|
||||
onModeChange: (active: boolean) => void;
|
||||
onDismiss: () => void;
|
||||
onSent: () => void;
|
||||
};
|
||||
|
||||
let {
|
||||
titleId,
|
||||
userId,
|
||||
isLocal,
|
||||
foregroundApp,
|
||||
onModeChange,
|
||||
onDismiss,
|
||||
onSent,
|
||||
}: Props = $props();
|
||||
let mode = $state<"message" | "image" | null>(null);
|
||||
let message = $state("");
|
||||
let error = $state("");
|
||||
let sending = $state(false);
|
||||
let username = $derived(
|
||||
$profile?.id === userId
|
||||
? $profile.displayName
|
||||
: ($friends.find((friend) => friend.id === userId)?.displayName ??
|
||||
"Unknown user"),
|
||||
);
|
||||
|
||||
function selectMode(nextMode: "message" | "image") {
|
||||
mode = mode === nextMode ? null : nextMode;
|
||||
onModeChange(mode !== null);
|
||||
error = "";
|
||||
}
|
||||
|
||||
async function sendContent(content: InteractionContent) {
|
||||
sending = true;
|
||||
error = "";
|
||||
try {
|
||||
await commands.sendInteraction(userId, content);
|
||||
message = "";
|
||||
mode = null;
|
||||
onSent();
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
} finally {
|
||||
sending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = message.trim();
|
||||
if (text) await sendContent({ type: "text", text });
|
||||
}
|
||||
|
||||
async function sendImage() {
|
||||
await runImageSend(() => commands.pickAndSendImage(userId));
|
||||
}
|
||||
|
||||
async function pasteImage(event: ClipboardEvent) {
|
||||
if (mode !== "image" || sending) return;
|
||||
const file = Array.from(event.clipboardData?.items ?? [])
|
||||
.find((item) => item.type.startsWith("image/"))
|
||||
?.getAsFile();
|
||||
if (!file) return;
|
||||
|
||||
event.preventDefault();
|
||||
await runImageSend(async () => {
|
||||
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
|
||||
await commands.sendImageBytes(userId, bytes);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function runImageSend(send: () => Promise<boolean>) {
|
||||
if (sending) return;
|
||||
sending = true;
|
||||
error = "";
|
||||
try {
|
||||
const sent = await send();
|
||||
if (sent) {
|
||||
mode = null;
|
||||
onSent();
|
||||
}
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
} finally {
|
||||
sending = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="w-68 overflow-hidden text-base-content" onpaste={pasteImage}>
|
||||
<header
|
||||
class="ml-2 flex w-max items-center gap-1 border border-b-0 border-base-300 bg-base-200 px-2 py-0.5"
|
||||
>
|
||||
<p id={titleId} class="max-w-52 truncate text-xs">{username}</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="relative flex flex-col gap-3 border border-base-200 bg-base-100 p-2 text-sm shadow-lg"
|
||||
>
|
||||
{#if mode !== null}
|
||||
<button
|
||||
class="absolute right-1 top-1 grid size-6 place-items-center text-lg leading-none btn btn-square btn-ghost"
|
||||
aria-label="Close interaction popover"
|
||||
onclick={onDismiss}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h3 class="text-xs text-base-content/50">Currently enjoying</h3>
|
||||
<div class="mt-1 flex min-w-0 items-center gap-2">
|
||||
{#if foregroundApp?.ico}
|
||||
<img
|
||||
src={`data:image/png;base64,${foregroundApp.ico}`}
|
||||
alt=""
|
||||
class="size-6 shrink-0 object-contain"
|
||||
/>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm">
|
||||
{foregroundApp?.local ??
|
||||
foregroundApp?.unlocal ??
|
||||
"Waiting for data"}
|
||||
</p>
|
||||
{#if foregroundApp?.local && foregroundApp.unlocal}
|
||||
<p class="truncate text-xs text-base-content/50">
|
||||
{foregroundApp.unlocal}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if !isLocal}
|
||||
<section class="border-t border-base-200 pt-2 w-full">
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class:!bg-primary={mode === "message"}
|
||||
class:!text-primary-content={mode === "message"}
|
||||
class="btn btn-ghost btn-xs"
|
||||
aria-pressed={mode === "message"}
|
||||
onclick={() => selectMode("message")}
|
||||
>
|
||||
Message
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
disabled={sending}
|
||||
onclick={() => sendContent({ type: "wave" })}
|
||||
>
|
||||
Wave
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class:!bg-primary={mode === "image"}
|
||||
class:!text-primary-content={mode === "image"}
|
||||
class="btn btn-ghost btn-xs"
|
||||
aria-pressed={mode === "image"}
|
||||
onclick={() => selectMode("image")}
|
||||
>
|
||||
Image
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if mode === "message"}
|
||||
<form
|
||||
class="mt-2 grid gap-1 border-l-2 border-primary bg-base-200/55 p-2"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void sendMessage();
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
bind:value={message}
|
||||
maxlength="500"
|
||||
rows="3"
|
||||
class="textarea textarea-xs w-full resize-none bg-base-100"
|
||||
placeholder={`Write to ${username}`}
|
||||
aria-label={`Message ${username}`}></textarea>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-[0.6rem] text-base-content/45">
|
||||
{message.length}/500
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-xs"
|
||||
disabled={sending || !message.trim()}
|
||||
>
|
||||
{sending ? "Sending…" : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else if mode === "image"}
|
||||
<div
|
||||
class="mt-2 grid min-h-24 place-items-center border border-primary/60 bg-primary/5 p-2 text-center"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs">Choose or paste an image</p>
|
||||
<p class="mt-0.5 text-[0.6rem] text-base-content/45">
|
||||
Compressed to 480 px · 150 KiB
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-xs mt-2"
|
||||
disabled={sending}
|
||||
onclick={sendImage}
|
||||
>
|
||||
{sending ? "Compressing…" : "Choose image"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="mt-2 text-xs text-error" role="alert">{error}</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { AppMeta, CursorPositions } from "$lib/bindings";
|
||||
import { friends } from "$lib/listeners/friends";
|
||||
import { profile } from "$lib/listeners/profile";
|
||||
|
||||
type Props = {
|
||||
titleId: string;
|
||||
userId: string;
|
||||
isLocal: boolean;
|
||||
cursor: CursorPositions;
|
||||
foregroundApp?: AppMeta;
|
||||
};
|
||||
|
||||
let { titleId, userId, isLocal, cursor, foregroundApp }: Props = $props();
|
||||
let username = $derived(
|
||||
$profile?.id === userId
|
||||
? $profile.displayName
|
||||
: ($friends.find((friend) => friend.id === userId)?.displayName ??
|
||||
"Unknown user"),
|
||||
);
|
||||
|
||||
function compactId(id: string) {
|
||||
return id.length > 18 ? `${id.slice(0, 9)}…${id.slice(-7)}` : id;
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="w-68 overflow-hidden text-base-content">
|
||||
<header
|
||||
class="flex w-max items-center ml-2 gap-1 px-2 py-0.5 bg-base-200 border border-base-300 border-b-0"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p id={titleId} class="truncate text-xs">{username}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="grid gap-3 p-2 text-sm bg-base-100 border border-base-200 shadow-lg"
|
||||
>
|
||||
<section>
|
||||
<h3 class="text-xs text-base-content/50">Currently enjoying</h3>
|
||||
<div class="mt-1 flex min-w-0 items-center gap-2">
|
||||
{#if foregroundApp?.ico}
|
||||
<img
|
||||
src={`data:image/png;base64,${foregroundApp.ico}`}
|
||||
alt=""
|
||||
class="size-6 shrink-0 object-contain"
|
||||
/>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm">
|
||||
{foregroundApp?.local ??
|
||||
foregroundApp?.unlocal ??
|
||||
"Waiting for data"}
|
||||
</p>
|
||||
{#if foregroundApp?.local && foregroundApp.unlocal}
|
||||
<p class="truncate text-xs text-base-content/50">
|
||||
{foregroundApp.unlocal}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
Reference in New Issue
Block a user