auto download images

This commit is contained in:
Zoe
2025-09-17 03:31:46 -05:00
parent 80b83a8e93
commit b498247b2f
13 changed files with 702 additions and 259 deletions

View File

@@ -6,23 +6,14 @@
</script> </script>
<svg <svg
class="animate-spin -ml-1 mr-3" class="animate-spin"
style="width: {size}px; height: {size}px" style="width: {size}px; height: {size}px"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24">
> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path <path
class="opacity-75" class="opacity-75"
fill="currentColor" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
/>
</svg> </svg>

View File

@@ -1,14 +1,10 @@
<script lang="ts"> <script lang="ts">
import { derived, writable, type Writable } from "svelte/store"; import { derived, get, writable, type Writable } from "svelte/store";
import { WebsocketConnectionState, ws } from "$stores/websocketStore"; import { WebsocketConnectionState, ws } from "$stores/websocketStore";
import { import { isRTCConnected, dataChannelReady, peer, keyExchangeDone } from "$lib/webrtcUtil";
isRTCConnected,
dataChannelReady,
peer,
keyExchangeDone,
} from "$lib/webrtcUtil";
import { import {
advertisedOffers, advertisedOffers,
downloadedImageFiles,
fileRequestIds, fileRequestIds,
messages, messages,
receivedOffers, receivedOffers,
@@ -18,34 +14,14 @@
import { MessageType } from "$types/message"; import { MessageType } from "$types/message";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import { WebBuffer } from "../lib/buffer"; import { WebBuffer } from "../lib/buffer";
import { onDestroy } from "svelte";
import { settingsStore } from "$stores/settingsStore";
import LoadingSpinner from "./LoadingSpinner.svelte";
let inputMessage: Writable<string> = writable(""); let inputMessage: Writable<string> = writable("");
let inputFile: Writable<FileList | null | undefined> = writable(null); let inputFile: Writable<FileList | null | undefined> = writable(null);
let inputFileElement: HTMLInputElement | null = $state(null); let inputFileElement: HTMLInputElement | null = $state(null);
let initialConnectionCompleteCount = writable(0); let initialConnectionComplete = writable(false);
let initialConnectionComplete = derived(
initialConnectionCompleteCount,
(value) => value >= 3,
);
// TODO: is this the most elegant way to do this?
isRTCConnected.subscribe((value) => {
if (value) {
$initialConnectionCompleteCount++;
}
});
dataChannelReady.subscribe((value) => {
if (value) {
$initialConnectionCompleteCount++;
}
});
keyExchangeDone.subscribe((value) => {
if (value) {
$initialConnectionCompleteCount++;
}
});
const { room }: { room: Writable<Room> } = $props(); const { room }: { room: Writable<Room> } = $props();
@@ -59,6 +35,14 @@
} }
}); });
onDestroy(() => {
initialConnectionComplete.set(false);
});
downloadedImageFiles.subscribe(() => {
console.log("Auto downloaded files:", $downloadedImageFiles);
});
function sendMessage() { function sendMessage() {
if (!$peer) { if (!$peer) {
console.error("Peer not initialized"); console.error("Peer not initialized");
@@ -72,14 +56,12 @@
} }
if ($inputFile != null && $inputFile[0] !== undefined) { if ($inputFile != null && $inputFile[0] !== undefined) {
// fileSize + fileNameSize + fileNameLen + id + textLen + header // fileSize + fileNameSize + fileTypeLen + id + textLen + header
let messageLen = let messageLen =
8 + 1 + 8 + ($inputFile[0].name.length + 1) + ($inputFile[0].type.length + 1) + 8;
$inputFile[0].name.length + if ($inputMessage.length > 0) {
2 + messageLen += $inputMessage.length + 1;
8 + }
$inputMessage.length +
1;
let messageBuf = new WebBuffer(new ArrayBuffer(messageLen)); let messageBuf = new WebBuffer(new ArrayBuffer(messageLen));
let fileId = new WebBuffer( let fileId = new WebBuffer(
@@ -97,15 +79,14 @@
messageBuf.writeInt8(MessageType.FILE_OFFER); messageBuf.writeInt8(MessageType.FILE_OFFER);
messageBuf.writeBigInt64LE(BigInt($inputFile[0].size)); messageBuf.writeBigInt64LE(BigInt($inputFile[0].size));
messageBuf.writeInt16LE($inputFile[0].name.length);
messageBuf.writeString($inputFile[0].name); messageBuf.writeString($inputFile[0].name);
messageBuf.writeString($inputFile[0].type);
messageBuf.writeBigInt64LE(fileId); messageBuf.writeBigInt64LE(fileId);
messageBuf.writeString($inputMessage); if ($inputMessage.length > 0) {
messageBuf.writeString($inputMessage);
}
console.log( console.log("Sending file offer", new Uint8Array(messageBuf.buffer));
"Sending file offer",
new Uint8Array(messageBuf.buffer),
);
$messages = [ $messages = [
...$messages, ...$messages,
@@ -114,8 +95,8 @@
type: MessageType.FILE_OFFER, type: MessageType.FILE_OFFER,
data: { data: {
fileSize: BigInt($inputFile[0].size), fileSize: BigInt($inputFile[0].size),
fileNameSize: $inputFile[0].name.length,
fileName: $inputFile[0].name, fileName: $inputFile[0].name,
fileType: $inputFile[0].type,
id: fileId, id: fileId,
text: $inputMessage === "" ? null : $inputMessage, text: $inputMessage === "" ? null : $inputMessage,
}, },
@@ -155,12 +136,17 @@
$peer.send(messageBuf.buffer, WebRTCPacketType.MESSAGE); $peer.send(messageBuf.buffer, WebRTCPacketType.MESSAGE);
} }
function downloadFile(id: bigint) { function downloadFile(id: bigint, messageIdx: number, saveToDisk: boolean = true) {
if (!$peer) { if (!$peer) {
console.error("Peer not initialized"); console.error("Peer not initialized");
return; return;
} }
if ($messages[messageIdx].type !== MessageType.FILE_OFFER) {
console.error("Message is not a file offer");
return;
}
let file = $receivedOffers.get(id); let file = $receivedOffers.get(id);
if (!file) { if (!file) {
console.error("Unknown file id:", id); console.error("Unknown file id:", id);
@@ -175,7 +161,17 @@
fileRequestBuf.writeBigInt64LE(id); fileRequestBuf.writeBigInt64LE(id);
fileRequestBuf.writeBigInt64LE(requesterId); fileRequestBuf.writeBigInt64LE(requesterId);
$fileRequestIds.set(requesterId, id); $fileRequestIds.set(requesterId, { saveToDisk, offerId: id });
messages.update((messages) => {
if (messages[messageIdx].type !== MessageType.FILE_OFFER) {
console.error("Message is not a file offer");
return messages;
}
messages[messageIdx].data.downloading = saveToDisk ? "downloading" : "preview";
return messages;
});
$peer.send(fileRequestBuf.buffer, WebRTCPacketType.MESSAGE); $peer.send(fileRequestBuf.buffer, WebRTCPacketType.MESSAGE);
} }
@@ -184,6 +180,7 @@
keyExchangeDone.subscribe((value) => { keyExchangeDone.subscribe((value) => {
console.log("Key exchange done:", value, $keyExchangeDone); console.log("Key exchange done:", value, $keyExchangeDone);
if (value) { if (value) {
initialConnectionComplete.set(true);
// provide a grace period for the user to see that the connection is established // provide a grace period for the user to see that the connection is established
setTimeout(() => { setTimeout(() => {
canCloseLoadingOverlay.set(true); canCloseLoadingOverlay.set(true);
@@ -221,25 +218,23 @@
} }
</script> </script>
<p> <!-- <p>
{$room?.id} {$room?.id}
({$room?.participants}) - {$room?.connectionState} - {$ws.status} ({$room?.participants}) - {RoomConnectionState[$room?.connectionState]} - {WebsocketConnectionState[
- Initial connection {$initialConnectionComplete $ws.status
? "complete" ]} - {$isRTCConnected} - {$dataChannelReady} - {$keyExchangeDone}
: "incomplete"} - Initial connection {$initialConnectionComplete ? "complete" : "incomplete"}
</p> </p> -->
<!-- If we are in a room, connected to the websocket server, and have been informed that we are connected to the room --> <!-- If we are in a room, connected to the websocket server, and have been informed that we are connected to the room -->
{#if ($room !== null && $ws.status === WebsocketConnectionState.CONNECTED && $room.connectionState === RoomConnectionState.CONNECTED) || $room.connectionState === RoomConnectionState.RECONNECTING} {#if ($room !== null && $ws.status === WebsocketConnectionState.CONNECTED && $room.connectionState === RoomConnectionState.CONNECTED) || $room.connectionState === RoomConnectionState.RECONNECTING}
<div class="flex flex-col w-full min-h-[calc(5/12_*_100vh)]"> <div class="flex flex-col w-full h-[calc(100vh-72px)]">
<div <div
class="flex-grow flex flex-col overflow-y-auto mb-4 p-2 bg-surface rounded relative whitespace-break-spaces wrap-anywhere" class="flex-grow flex flex-col overflow-y-auto mb-4 p-2 bg-surface rounded relative whitespace-break-spaces wrap-anywhere">
>
{#if !$initialConnectionComplete || $room.connectionState === RoomConnectionState.RECONNECTING || $room.participants !== 2 || $dataChannelReady === false || !$canCloseLoadingOverlay} {#if !$initialConnectionComplete || $room.connectionState === RoomConnectionState.RECONNECTING || $room.participants !== 2 || $dataChannelReady === false || !$canCloseLoadingOverlay}
<div <div
transition:fade={{ duration: 300 }} transition:fade={{ duration: 300 }}
class="absolute top-0 left-0 bottom-0 right-0 flex justify-center items-center flex-col bg-black/55 backdrop-blur-md z-10 text-center" class="absolute top-0 left-0 bottom-0 right-0 flex justify-center items-center flex-col bg-black/55 backdrop-blur-md z-10 text-center">
>
{#if !$isRTCConnected} {#if !$isRTCConnected}
<p>Waiting for peer to connect...</p> <p>Waiting for peer to connect...</p>
{:else if !$dataChannelReady && !$initialConnectionComplete} {:else if !$dataChannelReady && !$initialConnectionComplete}
@@ -247,20 +242,11 @@
{:else if !$keyExchangeDone} {:else if !$keyExchangeDone}
<p>Establishing a secure connection with the peer...</p> <p>Establishing a secure connection with the peer...</p>
{:else if $room.connectionState === RoomConnectionState.RECONNECTING} {:else if $room.connectionState === RoomConnectionState.RECONNECTING}
<p> <p>Disconnect from peer, attempting to reconnecting...</p>
Disconnect from peer, attempting to reconnecting...
</p>
{:else if $room.participants !== 2 || $dataChannelReady === false} {:else if $room.participants !== 2 || $dataChannelReady === false}
<p> <p>Peer has disconnected, waiting for other peer to reconnect...</p>
Peer has disconnected, waiting for other peer to
<span>reconnect...</span>
</p>
{:else} {:else}
<p> <p>Successfully established a secure connection to peer!</p>
<!-- fucking completely stupid shit I have to do because svelte FORCES these to be broken into two lines, and for some reason it just puts all of the whitespace at the beginning of the line in the string, so it looks unbelievably stupid -->
Successfully established a secure connection to
<span>peer!</span>
</p>
{/if} {/if}
<div class="mt-2"> <div class="mt-2">
{#if !$keyExchangeDone || $room.participants !== 2 || $dataChannelReady === false || $room.connectionState === RoomConnectionState.RECONNECTING} {#if !$keyExchangeDone || $room.participants !== 2 || $dataChannelReady === false || $room.connectionState === RoomConnectionState.RECONNECTING}
@@ -269,43 +255,38 @@
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24">
>
<circle <circle
class="opacity-25" class="opacity-25"
cx="12" cx="12"
cy="12" cy="12"
r="10" r="10"
stroke="currentColor" stroke="currentColor"
stroke-width="4" stroke-width="4" />
/>
<path <path
class="opacity-75" class="opacity-75"
fill="currentColor" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
/>
</svg> </svg>
{:else} {:else}
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="16"
height="16" height="16"
viewBox="0 0 24 24" viewBox="0 0 24 24">
>
<path <path
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M5 13l4 4L19 7" d="M5 13l4 4L19 7" />
/>
</svg> </svg>
{/if} {/if}
</div> </div>
</div> </div>
{/if} {/if}
{#each $messages as msg} {#each $messages as msg, index}
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<p class="whitespace-nowrap"> <p class="whitespace-nowrap">
{#if msg.initiator} {#if msg.initiator}
@@ -317,45 +298,120 @@
{#if msg.type === MessageType.TEXT} {#if msg.type === MessageType.TEXT}
<p>{msg.data}</p> <p>{msg.data}</p>
{:else if msg.type === MessageType.FILE_OFFER} {:else if msg.type === MessageType.FILE_OFFER}
<div class="flex flex-col w-full mb-2"> <div class="flex flex-col mb-2 items-start">
{#if msg.data.text !== null} <!-- if the file was auto downloaded, OR we downloaded the file manually OR we are the sender of the image (regardless of auto download setting) -->
<p> {#if msg.data.fileType.startsWith("image/")}
{msg.data.text} {#if $settingsStore.autoDownloadImages || $downloadedImageFiles.has(msg.data.id) || msg.initiator}
</p> {#if msg.initiator}
{/if} <!-- just preview the image -->
<div <img
class="flex flex-col p-2 relative w-8/12 bg-primary/50 rounded" class="w-auto h-auto rounded"
> src={URL.createObjectURL(
<h3 class="font-semibold"> $advertisedOffers.get(msg.data.id)!,
{msg.data.fileName} )}
</h3> alt={msg.data.fileName} />
<p class="text-sm text-paragraph-muted"> {:else}
{msg.data.fileSize} bytes <!-- wait for the image to download in the auto download queue -->
</p> {#if $downloadedImageFiles.has(msg.data.id)}
<!-- as the initiator, we cant send ourselves a file --> <img
{#if !msg.initiator} class="max-w-3/5 h-auto rounded"
<button src={URL.createObjectURL(
onclick={() => $downloadedImageFiles.get(msg.data.id)!,
downloadFile(msg.data.id)} )}
class="absolute right-2 bottom-2 p-1 border border-[#2c3444]/80 text-paragraph hover:bg-[#1D1C1F]/60 transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed" alt={msg.data.fileName} />
> {:else}
<svg <div
xmlns="http://www.w3.org/2000/svg" class="bg-neutral-800 animate-pulse rounded w-40 h-56 text-paragraph-muted flex justify-center items-center">
width="16" <svg
height="16" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" width="24"
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path height="24"
fill="none" viewBox="0 0 24 24"
stroke="currentColor" ><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><g
stroke-linecap="round" fill="none"
stroke-linejoin="round" stroke="currentColor"
stroke-width="2" stroke-linecap="round"
d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 11l5 5l5-5m-5-7v12" stroke-linejoin="round"
/></svg stroke-width="2"
> ><path
</button> d="M15 8h.01M3 6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3z" /><path
d="m3 16l5-5c.928-.893 2.072-.893 3 0l5 5" /><path
d="m14 14l1-1c.928-.893 2.072-.893 3 0l3 3" /></g
></svg>
</div>
{/if}
{/if}
{/if} {/if}
</div> {:else}
{#if msg.data.text !== null}
<p>
{msg.data.text}
</p>
{/if}
<div
class="flex flex-col p-2 relative w-8/12 bg-primary/50 rounded">
<h3 class="font-semibold">
{msg.data.fileName}
</h3>
<p class="text-sm text-paragraph-muted">
{msg.data.fileSize} bytes
</p>
<!-- as the initiator, we cant send ourselves a file -->
{#if !msg.initiator}
<div class="flex flex-row gap-2 absolute right-2 bottom-2">
{#if msg.data.fileType.startsWith("image/")}
<button
disabled={msg.data.downloading !== undefined}
onclick={() =>
downloadFile(msg.data.id, index, false)}
class="p-1 border border-[#2c3444]/80 text-paragraph hover:bg-[#1D1C1F]/60 transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed">
{#if msg.data.downloading === "preview"}
<LoadingSpinner size="16" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
><path
d="M14 3v4a1 1 0 0 0 1 1h4" /><path
d="M12 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v4.5" /><path
d="M14 17.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0m4.5 2L21 22" /></g
></svg>
{/if}
</button>
{/if}
<button
disabled={msg.data.downloading !== undefined}
onclick={() => downloadFile(msg.data.id, index)}
class="p-1 border border-[#2c3444]/80 text-paragraph hover:bg-[#1D1C1F]/60 transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed">
{#if msg.data.downloading === "downloading"}
<LoadingSpinner size="16" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 11l5 5l5-5m-5-7v12" /></svg>
{/if}
</button>
</div>
{/if}
</div>
{/if}
</div> </div>
{:else} {:else}
<p>Unknown message type: {msg.type}</p> <p>Unknown message type: {msg.type}</p>
@@ -367,17 +423,14 @@
type="file" type="file"
bind:files={$inputFile} bind:files={$inputFile}
bind:this={inputFileElement} bind:this={inputFileElement}
class="absolute opacity-0 -top-[9999px] -left-[9999px]" class="absolute opacity-0 -top-[9999px] -left-[9999px]" />
/> <div class="flex gap-2 w-full flex-row mb-2">
<div class="flex gap-2 w-full flex-row">
<div <div
class="border rounded border-[#2c3444] focus-within:border-[#404c63] transition-colors flex-grow flex flex-col bg-[#232b3e]" class="border rounded border-[#2c3444] focus-within:border-[#404c63] transition-colors flex-grow flex flex-col bg-[#232b3e]">
>
{#if $inputFile} {#if $inputFile}
<div class="flex flex-row gap-2 p-2"> <div class="flex flex-row gap-2 p-2">
<div <div
class="p-2 flex flex-col gap-2 w-48 border rounded-md border-[#2c3444] relative" class="p-2 flex flex-col gap-2 w-48 border rounded-md border-[#2c3444] relative">
>
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -390,17 +443,11 @@
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="1" stroke-width="1"
><path ><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path
d="M14 3v4a1 1 0 0 0 1 1h4" d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2" /></g
/><path ></svg>
d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2"
/></g
></svg
>
</div> </div>
<p <p class="text-sm whitespace-nowrap overflow-hidden text-ellipsis">
class="text-sm whitespace-nowrap overflow-hidden text-ellipsis"
>
{$inputFile[0].name} {$inputFile[0].name}
</p> </p>
@@ -408,8 +455,7 @@
onclick={() => { onclick={() => {
$inputFile = null; $inputFile = null;
}} }}
class="absolute right-2 top-2 p-1 border border-[#2c3444] text-paragraph hover:bg-surface/70 transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed" class="absolute right-2 top-2 p-1 border border-[#2c3444] text-paragraph hover:bg-surface/70 transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed">
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="16"
@@ -421,9 +467,7 @@
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M18 6L6 18M6 6l12 12" d="M18 6L6 18M6 6l12 12" /></svg>
/></svg
>
</button> </button>
</div> </div>
</div> </div>
@@ -433,12 +477,9 @@
<textarea <textarea
bind:value={$inputMessage} bind:value={$inputMessage}
cols="1" cols="1"
use:autogrow={$inputMessage} use:autogrow
onkeydown={(e) => { onkeydown={(e) => {
if ( if (e.key === "Enter" && !e.getModifierState("Shift")) {
e.key === "Enter" &&
!e.getModifierState("Shift")
) {
e.preventDefault(); e.preventDefault();
sendMessage(); sendMessage();
} }
@@ -446,8 +487,7 @@
disabled={!$isRTCConnected || disabled={!$isRTCConnected ||
!$dataChannelReady || !$dataChannelReady ||
!$keyExchangeDone || !$keyExchangeDone ||
$room.connectionState === $room.connectionState === RoomConnectionState.RECONNECTING}
RoomConnectionState.RECONNECTING}
placeholder="Type your message..." placeholder="Type your message..."
class="placeholder:text-paragraph-muted flex-grow p-2 bg-[#232b3e] rounded min-h-12 class="placeholder:text-paragraph-muted flex-grow p-2 bg-[#232b3e] rounded min-h-12
focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed resize-none leading-8" focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed resize-none leading-8"
@@ -458,11 +498,9 @@
disabled={!$isRTCConnected || disabled={!$isRTCConnected ||
!$dataChannelReady || !$dataChannelReady ||
!$keyExchangeDone || !$keyExchangeDone ||
$room.connectionState === $room.connectionState === RoomConnectionState.RECONNECTING}
RoomConnectionState.RECONNECTING}
aria-label="Pick file" aria-label="Pick file"
class="not-disabled:hover:bg-primary/50 h-fit p-1 text-paragraph transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed" class="not-disabled:hover:bg-primary/50 h-fit p-1 text-paragraph transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed">
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="24"
@@ -474,19 +512,15 @@
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="m15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3L18 10a3 3 0 0 0-6-6l-6.5 6.5a4.5 4.5 0 0 0 9 9L21 13" d="m15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3L18 10a3 3 0 0 0-6-6l-6.5 6.5a4.5 4.5 0 0 0 9 9L21 13" /></svg>
/></svg
>
</button> </button>
<button <button
onclick={sendMessage} onclick={sendMessage}
disabled={!$isRTCConnected || disabled={!$isRTCConnected ||
!$dataChannelReady || !$dataChannelReady ||
!$keyExchangeDone || !$keyExchangeDone ||
$room.connectionState === $room.connectionState === RoomConnectionState.RECONNECTING}
RoomConnectionState.RECONNECTING} class="not-disabled:hover:bg-primary/50 h-fit p-1 text-paragraph transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed">
class="not-disabled:hover:bg-primary/50 h-fit p-1 text-paragraph transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="24"
@@ -498,9 +532,7 @@
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M10 14L21 3m0 0l-6.5 18a.55.55 0 0 1-1 0L10 14l-7-3.5a.55.55 0 0 1 0-1z" d="M10 14L21 3m0 0l-6.5 18a.55.55 0 0 1-1 0L10 14l-7-3.5a.55.55 0 0 1 0-1z" /></svg>
/></svg
>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,119 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import ToggleSwitch from "./ToggleSwitch.svelte";
import { settingsStore } from "$stores/settingsStore";
import { writable, type Writable } from "svelte/store";
let { open = $bindable() } = $props();
let previouslyFocusedElement: HTMLElement | null; // To restore focus later
let maxDownloadSizeinMB: Writable<number> = writable($settingsStore.maxAutoDownloadSize);
$maxDownloadSizeinMB = Math.floor($maxDownloadSizeinMB / 1024 / 1024);
maxDownloadSizeinMB.subscribe((value) => {
console.log("Max download size:", value);
$settingsStore.maxAutoDownloadSize = value * 1024 * 1024;
});
function trapFocus(node: HTMLElement) {
let focusableElements: NodeListOf<HTMLElement>;
let firstFocusableElement: HTMLElement;
let lastFocusableElement: HTMLElement;
const queryFocusable = () => {
// Select all elements that can receive focus
focusableElements = node.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
firstFocusableElement = focusableElements[0];
lastFocusableElement = focusableElements[focusableElements.length - 1];
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Tab") {
// Recalculate focusable elements on tab to account for potential dynamic content
queryFocusable();
if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === firstFocusableElement) {
lastFocusableElement.focus();
event.preventDefault();
}
} else {
// Tab
if (document.activeElement === lastFocusableElement) {
firstFocusableElement.focus();
event.preventDefault();
}
}
} else if (event.key === "Escape") {
open = false; // Close on Escape key
}
};
// Initial setup when mounted
onMount(() => {
previouslyFocusedElement = document.activeElement as HTMLElement | null; // Save reference
queryFocusable();
if (firstFocusableElement) {
firstFocusableElement.focus(); // Focus first item
}
document.addEventListener("keydown", handleKeyDown);
});
// Cleanup when destroyed
onDestroy(() => {
document.removeEventListener("keydown", handleKeyDown);
if (previouslyFocusedElement) {
previouslyFocusedElement.focus(); // Restore focus
}
});
return {
destroy() {
document.removeEventListener("keydown", handleKeyDown);
if (previouslyFocusedElement) {
previouslyFocusedElement.focus();
}
},
};
}
</script>
<!-- div to block interaction with the body -->
{#if open}
<div
class="fixed top-0 left-0 w-screen h-screen z-10 flex items-center justify-center"
use:trapFocus>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={() => (open = false)} class="absolute inset-0"></div>
<div
class="bg-surface py-4 px-6 rounded-lg border border-[#21293b] z-20 max-w-xl w-full mx-5">
<h2 class="font-bold text-center">Settings</h2>
<form class="flex flex-col gap-5" id="roomForm">
<div class="flex">
<label
for="autoDownloadImages"
class="text-paragraph block text-sm font-medium mr-2">Image Previews</label>
<ToggleSwitch
id="autoDownloadImages"
bind:checked={$settingsStore.autoDownloadImages}
className="border border-[#2c3444] bg-[#232b3e] ml-auto" />
</div>
<div class="flex items-center">
<label
for="maxAutoDownloadSize"
class="text-paragraph block text-sm font-medium mr-2"
>Max Auto Download Size (MB)</label>
<input
type="number"
id="maxAutoDownloadSize"
bind:value={$maxDownloadSizeinMB}
class="border border-[#2c3444] bg-[#232b3e] ml-auto h-fit px-2 py-1 max-w-20" />
</div>
</form>
</div>
</div>
{/if}

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import { onMount } from "svelte";
let {
checked = $bindable(),
value = $bindable(),
disabled = $bindable(),
id,
className,
...others
}: {
checked: boolean;
value?: string;
disabled?: boolean;
id?: string;
className?: string;
} = $props();
onMount(() => {
if (id) {
document.querySelector(`[for="${id}"]`)?.addEventListener("click", toggle);
}
});
function toggle() {
if (disabled) return;
checked = !checked;
}
</script>
<button
role="switch"
class="vl-toggle-switch {className}"
aria-disabled={disabled}
aria-label="label"
aria-labelledby="id"
tabindex={disabled ? -1 : 0}
aria-checked={checked}
data-state={checked ? "checked" : "unchecked"}
onclick={toggle}
{...others}>
<div></div>
</button>
<style scoped>
.vl-toggle-switch {
font-size: inherit;
cursor: pointer;
width: 2.5em;
height: 1.4em;
border-radius: 100px;
padding: 0.125rem 0.25rem;
position: relative;
transition-property: background-color, border-color;
transition-duration: 0.3s;
transition-timing-function: ease;
}
.vl-toggle-switch[aria-disabled="true"] div {
background: #919191;
}
.vl-toggle-switch div {
position: relative;
left: 0;
width: 1em;
height: 1em;
background: #f7f7f7;
border-radius: 90px;
pointer-events: none;
transition: all 0.3s;
}
.vl-toggle-switch[data-state="checked"] {
--checked-color: var(--color-accent);
background: var(--checked-color);
border-color: var(--checked-color);
}
.vl-toggle-switch[data-state="checked"] div {
left: 100%;
transform: translateX(-100%);
}
.vl-toggle-switch:active div {
width: 1.3em;
}
</style>

View File

@@ -150,20 +150,25 @@ export class WebBuffer {
this.dataView.setBigInt64(offset, value, true); this.dataView.setBigInt64(offset, value, true);
} }
// if no length is specified, reads until the end of the buffer readString(offset?: number): string {
readString(length?: number, offset?: number): string {
if (length === undefined) {
length = this.length - this.count;
}
if (offset === undefined) { if (offset === undefined) {
offset = this.count; offset = this.count;
this.count += length;
} }
let stringBytes = [];
let stringLength = 0;
// loop until we find a null byte
while (this.data.length >= offset + stringLength && this.data[offset + stringLength] !== 0) {
stringBytes.push(this.data[offset + stringLength]);
stringLength++;
}
this.count += stringLength + 1;
console.log("Read string:", stringBytes, stringLength);
let textDeccoder = new TextDecoder(); let textDeccoder = new TextDecoder();
let readTextBuf = this.data.slice(offset, offset + length); let value = textDeccoder.decode(new Uint8Array(stringBytes));
let value = textDeccoder.decode(readTextBuf);
return value; return value;
} }
@@ -171,12 +176,17 @@ export class WebBuffer {
writeString(value: string, offset?: number) { writeString(value: string, offset?: number) {
if (offset === undefined) { if (offset === undefined) {
offset = this.count; offset = this.count;
this.count += value.length; this.count += value.length + 1;
} }
// use C-style string termination
value = value + "\0";
let textEncoder = new TextEncoder(); let textEncoder = new TextEncoder();
let textBuf = textEncoder.encode(value); let textBuf = textEncoder.encode(value);
console.log("Writing string:", value, textBuf);
this.data.set(textBuf, offset); this.data.set(textBuf, offset);
} }

View File

@@ -5,12 +5,55 @@ import { browser } from '$app/environment';
import { createApplicationMessage, createCommit, createGroup, decodeMlsMessage, defaultCapabilities, defaultLifetime, emptyPskIndex, encodeMlsMessage, generateKeyPackage, getCiphersuiteFromName, getCiphersuiteImpl, joinGroup, processPrivateMessage, type CiphersuiteImpl, type ClientState, type Credential, type KeyPackage, type PrivateKeyPackage, type Proposal } from 'ts-mls'; import { createApplicationMessage, createCommit, createGroup, decodeMlsMessage, defaultCapabilities, defaultLifetime, emptyPskIndex, encodeMlsMessage, generateKeyPackage, getCiphersuiteFromName, getCiphersuiteImpl, joinGroup, processPrivateMessage, type CiphersuiteImpl, type ClientState, type Credential, type KeyPackage, type PrivateKeyPackage, type Proposal } from 'ts-mls';
import { WebSocketWebRtcMessageType } from '$types/websocket'; import { WebSocketWebRtcMessageType } from '$types/websocket';
export enum WebRTCCallbackType {
CONNECTED,
MESSAGE,
DATA_CHANNEL_STATE_CHANGE,
KEY_EXCHANGE_DONE,
NEGOTIATION_NEEDED,
ERROR,
}
type WebRTCCallback = WebRTCConnectedCallback | WebRTCMessageCallback | WebRTCDataChannelStateChangeCallback | WebRTCKeyExchangeDoneCallback | WebRTCNegotiationNeededCallback | WebRTCErrorCallback;
type GetCallbackFunction<T extends WebRTCCallbackType> =
(WebRTCCallback & { type: T })['cb'];
interface WebRTCConnectedCallback {
type: WebRTCCallbackType.CONNECTED;
cb: () => void;
}
interface WebRTCMessageCallback {
type: WebRTCCallbackType.MESSAGE;
cb: (message: { type: WebRTCPacketType, data: ArrayBuffer }, webRtcPeer: WebRTCPeer) => void;
}
interface WebRTCDataChannelStateChangeCallback {
type: WebRTCCallbackType.DATA_CHANNEL_STATE_CHANGE;
cb: (state: boolean) => void;
}
interface WebRTCKeyExchangeDoneCallback {
type: WebRTCCallbackType.KEY_EXCHANGE_DONE;
cb: () => void;
}
interface WebRTCNegotiationNeededCallback {
type: WebRTCCallbackType.NEGOTIATION_NEEDED;
cb: () => void;
}
interface WebRTCErrorCallback {
type: WebRTCCallbackType.ERROR;
cb: (error: any) => void;
}
export class WebRTCPeer { export class WebRTCPeer {
private peer: RTCPeerConnection | null = null; private peer: RTCPeerConnection | null = null;
private dataChannel: RTCDataChannel | null = null; private dataChannel: RTCDataChannel | null = null;
private isInitiator: boolean; private isInitiator: boolean;
private roomId: string; private roomId: string;
private callbacks: WebRTCPeerCallbacks; private callbacks: Map<WebRTCCallbackType, WebRTCCallback['cb'][]> = new Map();
private credential: Credential; private credential: Credential;
private clientState: ClientState | undefined; private clientState: ClientState | undefined;
private cipherSuite: CiphersuiteImpl | undefined; private cipherSuite: CiphersuiteImpl | undefined;
@@ -27,12 +70,46 @@ export class WebRTCPeer {
constructor(roomId: string, isInitiator: boolean, callbacks: WebRTCPeerCallbacks) { constructor(roomId: string, isInitiator: boolean, callbacks: WebRTCPeerCallbacks) {
this.roomId = roomId; this.roomId = roomId;
this.isInitiator = isInitiator; this.isInitiator = isInitiator;
this.callbacks = callbacks; this.callbacks = new Map();
this.addCallback(WebRTCCallbackType.CONNECTED, callbacks.onConnected);
this.addCallback(WebRTCCallbackType.MESSAGE, callbacks.onMessage);
this.addCallback(WebRTCCallbackType.DATA_CHANNEL_STATE_CHANGE, callbacks.onDataChannelStateChange);
this.addCallback(WebRTCCallbackType.KEY_EXCHANGE_DONE, callbacks.onKeyExchangeDone);
this.addCallback(WebRTCCallbackType.NEGOTIATION_NEEDED, callbacks.onNegotiationNeeded);
const id = crypto.getRandomValues(new Uint8Array(32)); const id = crypto.getRandomValues(new Uint8Array(32));
this.credential = { credentialType: "basic", identity: id }; this.credential = { credentialType: "basic", identity: id };
} }
// returns unsubscribe function
public addCallback<T extends WebRTCCallbackType>(type: T, func: GetCallbackFunction<T>): () => void {
if (this.callbacks.has(type)) {
this.callbacks.get(type)!.push(func);
return () => {
this.callbacks.get(type)!.splice(this.callbacks.get(type)!.indexOf(func), 1);
}
}
this.callbacks.set(type, [func]);
return () => {
this.callbacks.get(type)!.splice(this.callbacks.get(type)!.indexOf(func), 1);
}
}
private emitCallback<T extends WebRTCCallbackType>(type: T, ...args: Parameters<GetCallbackFunction<T>>) {
console.log("Emitting callback:", type, args, WebRTCCallbackType[type]);
console.log("Callbacks:", this.callbacks);
if (this.callbacks.has(type)) {
console.log("Emitting callback:", type, args, WebRTCCallbackType[type]);
for (let func of this.callbacks.get(type)!) {
// @ts-ignore
func.apply(this, args);
}
}
}
private sendIceCandidate(candidate: RTCIceCandidate) { private sendIceCandidate(candidate: RTCIceCandidate) {
ws.send({ ws.send({
type: WebSocketWebRtcMessageType.ICE_CANDIDATE, type: WebSocketWebRtcMessageType.ICE_CANDIDATE,
@@ -68,14 +145,14 @@ export class WebRTCPeer {
this.peer.oniceconnectionstatechange = () => { this.peer.oniceconnectionstatechange = () => {
console.log('ICE connection state changed to:', this.peer?.iceConnectionState); console.log('ICE connection state changed to:', this.peer?.iceConnectionState);
if (this.peer?.iceConnectionState === 'connected' || this.peer?.iceConnectionState === 'completed') { if (this.peer?.iceConnectionState === 'connected' || this.peer?.iceConnectionState === 'completed') {
this.callbacks.onConnected(); this.emitCallback(WebRTCCallbackType.CONNECTED);
} else if (this.peer?.iceConnectionState === 'failed') { } else if (this.peer?.iceConnectionState === 'failed') {
this.callbacks.onError('ICE connection failed'); this.emitCallback(WebRTCCallbackType.ERROR, 'ICE connection failed');
} }
}; };
this.peer.onnegotiationneeded = () => { this.peer.onnegotiationneeded = () => {
this.callbacks.onNegotiationNeeded(); this.emitCallback(WebRTCCallbackType.NEGOTIATION_NEEDED);
}; };
// 2. Create data channel // 2. Create data channel
@@ -96,7 +173,7 @@ export class WebRTCPeer {
channel.binaryType = "arraybuffer"; channel.binaryType = "arraybuffer";
channel.onopen = async () => { channel.onopen = async () => {
this.callbacks.onDataChannelStateChange(true); this.emitCallback(WebRTCCallbackType.DATA_CHANNEL_STATE_CHANGE, true);
try { try {
if (this.isInitiator) { if (this.isInitiator) {
@@ -114,7 +191,7 @@ export class WebRTCPeer {
} }
} catch (e) { } catch (e) {
console.error("Error starting key exchange:", e); console.error("Error starting key exchange:", e);
this.callbacks.onError(e); this.emitCallback(WebRTCCallbackType.ERROR, e);
} }
}; };
@@ -134,7 +211,7 @@ export class WebRTCPeer {
return; return;
} }
console.log("parsed data", data, encrypted, type); console.log("parsed data", data, encrypted, WebRTCPacketType[type]);
if (type === WebRTCPacketType.GROUP_OPEN) { if (type === WebRTCPacketType.GROUP_OPEN) {
await this.generateKeyPair(); await this.generateKeyPair();
@@ -181,7 +258,7 @@ export class WebRTCPeer {
this.send(encodedWelcomeBuf, WebRTCPacketType.WELCOME); this.send(encodedWelcomeBuf, WebRTCPacketType.WELCOME);
this.encyptionReady = true; this.encyptionReady = true;
this.callbacks.onKeyExchangeDone(); this.emitCallback(WebRTCCallbackType.KEY_EXCHANGE_DONE);
return; return;
} }
@@ -205,7 +282,7 @@ export class WebRTCPeer {
console.log("Joined group", this.clientState); console.log("Joined group", this.clientState);
this.encyptionReady = true; this.encyptionReady = true;
this.callbacks.onKeyExchangeDone(); this.emitCallback(WebRTCCallbackType.KEY_EXCHANGE_DONE);
return; return;
} }
@@ -235,17 +312,17 @@ export class WebRTCPeer {
data: data.buffer, data: data.buffer,
}; };
this.callbacks.onMessage(message, this); this.emitCallback(WebRTCCallbackType.MESSAGE, message, this);
}; };
channel.onclose = () => { channel.onclose = () => {
this.callbacks.onDataChannelStateChange(false); this.emitCallback(WebRTCCallbackType.DATA_CHANNEL_STATE_CHANGE, false);
}; };
channel.onerror = (error) => { channel.onerror = (error) => {
console.error('data channel error:', error); console.error('data channel error:', error);
this.callbacks.onError(error); this.emitCallback(WebRTCCallbackType.ERROR, error);
}; };
} }
@@ -281,7 +358,7 @@ export class WebRTCPeer {
await this.peer.setRemoteDescription(sdp); await this.peer.setRemoteDescription(sdp);
} catch (error) { } catch (error) {
console.error('Error setting remote description:', error); console.error('Error setting remote description:', error);
this.callbacks.onError(error); this.emitCallback(WebRTCCallbackType.ERROR, error);
} }
} }
@@ -304,7 +381,7 @@ export class WebRTCPeer {
} catch (error) { } catch (error) {
console.error('Error creating answer:', error); console.error('Error creating answer:', error);
this.callbacks.onError(error); this.emitCallback(WebRTCCallbackType.ERROR, error);
} }
} }
@@ -315,7 +392,7 @@ export class WebRTCPeer {
await this.peer.addIceCandidate(new RTCIceCandidate(candidate)); await this.peer.addIceCandidate(new RTCIceCandidate(candidate));
} catch (error) { } catch (error) {
console.error('Error adding ICE candidate:', error); console.error('Error adding ICE candidate:', error);
this.callbacks.onError(error); this.emitCallback(WebRTCCallbackType.ERROR, error);
} }
} }
@@ -333,7 +410,7 @@ export class WebRTCPeer {
this.keyPackage = await generateKeyPackage(this.credential, defaultCapabilities(), defaultLifetime, [], this.cipherSuite); this.keyPackage = await generateKeyPackage(this.credential, defaultCapabilities(), defaultLifetime, [], this.cipherSuite);
} catch (e) { } catch (e) {
console.error("Error generating key package:", e); console.error("Error generating key package:", e);
this.callbacks.onError(e); this.emitCallback(WebRTCCallbackType.ERROR, e);
} }
} }

View File

@@ -3,11 +3,12 @@ import { WebRTCPeer } from "$lib/webrtc";
import { WebRTCPacketType } from "$types/webrtc"; import { WebRTCPacketType } from "$types/webrtc";
import { room } from "$stores/roomStore"; import { room } from "$stores/roomStore";
import { RoomConnectionState, WebSocketErrorType, WebSocketResponseType, WebSocketRoomMessageType, WebSocketWebRtcMessageType, type Room } from "$types/websocket"; import { RoomConnectionState, WebSocketErrorType, WebSocketResponseType, WebSocketRoomMessageType, WebSocketWebRtcMessageType, type Room } from "$types/websocket";
import { advertisedOffers, fileRequestIds, messages, receivedOffers } from "$stores/messageStore"; import { advertisedOffers, downloadedImageFiles, fileRequestIds, incompleteAutoDownloadedFiles, messages, receivedOffers } from "$stores/messageStore";
import { MessageType, type Message } from "$types/message"; import { MessageType, type Message } from "$types/message";
import { type WebSocketMessage } from "$types/websocket"; import { type WebSocketMessage } from "$types/websocket";
import { WebBuffer } from "./buffer"; import { WebBuffer } from "./buffer";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { settingsStore } from "$stores/settingsStore";
export const error: Writable<string | null> = writable(null); export const error: Writable<string | null> = writable(null);
export let peer: Writable<WebRTCPeer | null> = writable(null); export let peer: Writable<WebRTCPeer | null> = writable(null);
@@ -46,7 +47,6 @@ const callbacks = {
console.log("Connected to peer"); console.log("Connected to peer");
isRTCConnected.set(true); isRTCConnected.set(true);
}, },
//! TODO: come up with a more complex room system. This is largely for testing purposes
onMessage: async (message: { type: WebRTCPacketType, data: ArrayBuffer }, webRtcPeer: WebRTCPeer) => { onMessage: async (message: { type: WebRTCPacketType, data: ArrayBuffer }, webRtcPeer: WebRTCPeer) => {
console.log("WebRTC Received message:", message); console.log("WebRTC Received message:", message);
if (message.type !== WebRTCPacketType.MESSAGE) { if (message.type !== WebRTCPacketType.MESSAGE) {
@@ -74,23 +74,41 @@ const callbacks = {
break; break;
case MessageType.FILE_OFFER: case MessageType.FILE_OFFER:
let fileSize = messageData.readBigInt64LE(); let fileSize = messageData.readBigInt64LE();
let fileNameSize = messageData.readInt16LE(); let fileName = messageData.readString();
let fileName = messageData.readString(fileNameSize); let fileType = messageData.readString();
let id = messageData.readBigInt64LE(); let id = messageData.readBigInt64LE();
get(receivedOffers).set(id, { name: fileName, size: fileSize }); get(receivedOffers).set(id, { name: fileName, size: fileSize, type: fileType });
messages.set([...get(messages), { messages.set([...get(messages), {
initiator: false, initiator: false,
type: messageType, type: messageType,
data: { data: {
fileSize, fileSize,
fileNameSize,
fileName, fileName,
fileType,
id, id,
text: messageData.peek() ? messageData.readString() : null, text: messageData.peek() ? messageData.readString() : null,
} }
}]); }]);
if (get(settingsStore).autoDownloadImages && get(settingsStore).maxAutoDownloadSize >= fileSize && fileType.startsWith("image/")) {
console.log("Auto downloading image");
let fileRequestBuf = new WebBuffer(new ArrayBuffer(1 + 8 + 8));
let requesterId = new WebBuffer(
crypto.getRandomValues(new Uint8Array(8)).buffer,
).readBigInt64LE();
fileRequestBuf.writeInt8(MessageType.FILE_REQUEST);
fileRequestBuf.writeBigInt64LE(id);
fileRequestBuf.writeBigInt64LE(requesterId);
get(fileRequestIds).set(requesterId, { saveToDisk: false, offerId: id });
webRtcPeer.send(fileRequestBuf.buffer, WebRTCPacketType.MESSAGE);
}
break; break;
case MessageType.FILE_REQUEST: case MessageType.FILE_REQUEST:
// the id that coresponds to our file offer // the id that coresponds to our file offer
@@ -171,28 +189,45 @@ const callbacks = {
break; break;
case MessageType.FILE: case MessageType.FILE:
let requestId = messageData.readBigInt64LE(); let requestId = messageData.readBigInt64LE();
let receivedOffserId = get(fileRequestIds).get(requestId); let receivedOffer = get(fileRequestIds).get(requestId);
if (!receivedOffserId) { if (!receivedOffer) {
console.error("Received file message for unknown file id:", requestId); console.error("Received file message for unknown file id:", requestId);
return; return;
} }
let file = get(receivedOffers).get(receivedOffserId); let file = get(receivedOffers).get(receivedOffer.offerId);
if (!file) { if (!file) {
console.error("Unknown file id:", requestId); console.error("Unknown file id:", requestId);
return; return;
} }
if (downloadStream === undefined) { let fileData = new Uint8Array(messageData.read());
window.addEventListener("pagehide", onPageHide);
window.addEventListener("beforeunload", beforeUnload);
// @ts-ignore console.log("recieved chunk of auto downloaded file");
downloadStream = window.streamSaver.createWriteStream(file.name, { size: Number(file.size) }); if (get(receivedOffers).get(receivedOffer.offerId)!.type.startsWith("image/")) {
downloadWriter = downloadStream!.getWriter(); if (!incompleteAutoDownloadedFiles.has(receivedOffer.offerId)) {
incompleteAutoDownloadedFiles.set(receivedOffer.offerId, new Blob([fileData.buffer]));
console.log("Auto downloaded file:", incompleteAutoDownloadedFiles.get(receivedOffer.offerId)!);
} else {
// stupidest way ever to extend a buffer
let blobParts = incompleteAutoDownloadedFiles.get(receivedOffer.offerId)!;
incompleteAutoDownloadedFiles.set(receivedOffer.offerId, new Blob([blobParts, fileData.buffer]));
// console.log("Auto downloaded file:", get(autoDownloadedFiles.get(receivedOffser.offerId)!));
}
} }
await downloadWriter!.write(new Uint8Array(messageData.read())); if (receivedOffer.saveToDisk) {
if (downloadStream === undefined) {
window.addEventListener("pagehide", onPageHide);
window.addEventListener("beforeunload", beforeUnload);
// @ts-ignore
downloadStream = window.streamSaver.createWriteStream(file.name, { size: Number(file.size) });
downloadWriter = downloadStream!.getWriter();
}
await downloadWriter!.write(new Uint8Array(fileData));
}
let fileAckBuf = new WebBuffer(new ArrayBuffer(1 + 8)); let fileAckBuf = new WebBuffer(new ArrayBuffer(1 + 8));
fileAckBuf.writeInt8(MessageType.FILE_ACK); fileAckBuf.writeInt8(MessageType.FILE_ACK);
@@ -208,6 +243,18 @@ const callbacks = {
return; return;
} }
let doneOfferId = get(fileRequestIds).get(fileDoneId)!.offerId;
if (!get(receivedOffers).has(doneOfferId)) {
console.error("Unknown file done id:", fileDoneId);
return;
}
if (incompleteAutoDownloadedFiles.has(doneOfferId)) {
console.log("completely auto downloaded file:", incompleteAutoDownloadedFiles.get(doneOfferId)!);
downloadedImageFiles.set(get(downloadedImageFiles).set(doneOfferId, incompleteAutoDownloadedFiles.get(doneOfferId)!));
incompleteAutoDownloadedFiles.delete(doneOfferId);
}
window.removeEventListener("pagehide", onPageHide); window.removeEventListener("pagehide", onPageHide);
window.removeEventListener("beforeunload", beforeUnload); window.removeEventListener("beforeunload", beforeUnload);
@@ -264,7 +311,7 @@ export async function handleMessage(event: MessageEvent) {
return; return;
case WebSocketRoomMessageType.PARTICIPANT_JOINED: case WebSocketRoomMessageType.PARTICIPANT_JOINED:
console.log("new client joined room"); console.log("new client joined room");
room.update((room) => ({ ...room, participants: room.participants + 1 })); room.update((room) => ({ ...room, participants: message.participants }));
return; return;
case WebSocketResponseType.ROOM_JOINED: case WebSocketResponseType.ROOM_JOINED:
// TODO: if a client disconnects, we need to resync the room state // TODO: if a client disconnects, we need to resync the room state
@@ -273,7 +320,7 @@ export async function handleMessage(event: MessageEvent) {
console.log("Joined room"); console.log("Joined room");
return; return;
case WebSocketRoomMessageType.PARTICIPANT_LEFT: case WebSocketRoomMessageType.PARTICIPANT_LEFT:
room.update((room) => ({ ...room, participants: room.participants - 1 })); room.update((room) => ({ ...room, participants: message.participants }));
console.log("Participant left room"); console.log("Participant left room");
return; return;
case WebSocketErrorType.ERROR: case WebSocketErrorType.ERROR:
@@ -288,7 +335,7 @@ export async function handleMessage(event: MessageEvent) {
return; return;
} }
room.update(r => ({ ...r, RTCConnectionReady: true })); room.update(r => ({ ...r, RTCConnectionReady: true, participants: message.data.participants }));
console.log("Creating peer"); console.log("Creating peer");
peer.set(new WebRTCPeer( peer.set(new WebRTCPeer(

View File

@@ -4,9 +4,35 @@
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import { WebsocketConnectionState, ws } from "$stores/websocketStore"; import { WebsocketConnectionState, ws } from "$stores/websocketStore";
import { room } from "$stores/roomStore"; import { room } from "$stores/roomStore";
import { writable } from "svelte/store";
import SettingsOverlay from "$components/SettingsOverlay.svelte";
import { settingsStore } from "$stores/settingsStore";
const settingsOverlayOpen = writable(false);
onMount(() => { onMount(() => {
ws.connect(); ws.connect();
let settings = localStorage.getItem("settings");
if (settings) {
// settingsStore = JSON.parse(settings);
let settingsObj = JSON.parse(settings);
for (let key in $settingsStore) {
// @ts-ignore
$settingsStore[key] = settingsObj[key];
}
} else {
localStorage.setItem("settings", JSON.stringify($settingsStore));
}
$effect(() => {
localStorage.setItem("settings", JSON.stringify($settingsStore));
});
settingsOverlayOpen.subscribe((value) => {
document.getElementById("app")!.style.filter = value ? "blur(10px)" : "";
document.documentElement.style.overflow = value ? "hidden" : "auto";
});
}); });
onDestroy(() => { onDestroy(() => {
@@ -42,20 +68,51 @@
<script src="https://cdn.jsdelivr.net/npm/streamsaver@2.0.3/StreamSaver.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/streamsaver@2.0.3/StreamSaver.min.js"></script>
</svelte:head> </svelte:head>
<header class="p-5"> <div
<div class="flex justify-between items-center max-w-7xl px-5 mx-auto"> id="app"
<div class="text-2xl font-bold text-white"> aria-hidden={$settingsOverlayOpen}
<a href="/" class="!text-white !no-underline">Noctis<span class="text-accent">.</span> class="transition-[filter] duration-300 ease-[cubic-bezier(0.45,_0,_0.55,_1)]">
</a> <header class="p-5">
<div class="flex justify-between items-center max-w-7xl px-5 mx-auto">
<div class="text-2xl font-bold text-white">
<a href="/" class="!text-white !no-underline"
>Noctis<span class="text-accent">.</span>
</a>
</div>
<nav class="flex gap-2 items-center">
<a
href="https://github.com/juls0730/noctis"
target="_blank"
rel="noopener noreferrer">
GitHub
</a>
<button
onclick={() => settingsOverlayOpen.set(!$settingsOverlayOpen)}
class="p-1 rounded-md hover:bg-surface/80 transition-colors cursor-pointer group">
<svg
class="group-hover:text-accent group-hover:rotate-45 transition-[colors,rotate] duration-300 ease-in-out"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
><path
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 0 0-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 0 0-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 0 0-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 0 0-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 0 0 1.066-2.573c-.94-1.543.826-3.31 2.37-2.37c1 .608 2.296.07 2.572-1.065" /><path
d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0-6 0" /></g
></svg>
</button>
</nav>
</div> </div>
<nav> </header>
<a href="https://github.com/juls0730/noctis" target="_blank" rel="noopener noreferrer">
GitHub
</a>
</nav>
</div>
</header>
<main> <main>
{@render children?.()} {@render children?.()}
</main> </main>
</div>
<SettingsOverlay bind:open={$settingsOverlayOpen} />

View File

@@ -23,7 +23,7 @@
challenge: { challenge: {
target: challengeResult.target, target: challengeResult.target,
nonce: challengeResult.nonce, nonce: challengeResult.nonce,
} },
}); });
console.log("Created room:", roomId); console.log("Created room:", roomId);
@@ -49,11 +49,11 @@
class="py-4 px-8 text-xl font-semibold bg-accent text-[#121826] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg cursor-pointer transition-[background-color,_translate,_box-shadow] ease-out duration-200 w-full inline-flex justify-center items-center gap-2.5 hover:bg-[#00f0c8] hover:-translate-y-1 hover:shadow-md shadow-accent/20"> class="py-4 px-8 text-xl font-semibold bg-accent text-[#121826] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg cursor-pointer transition-[background-color,_translate,_box-shadow] ease-out duration-200 w-full inline-flex justify-center items-center gap-2.5 hover:bg-[#00f0c8] hover:-translate-y-1 hover:shadow-md shadow-accent/20">
{#if $ws.status !== WebsocketConnectionState.CONNECTED} {#if $ws.status !== WebsocketConnectionState.CONNECTED}
<span class="flex items-center"> <span class="flex items-center">
<LoadingSpinner /> Connecting to server... <span class="mr-3"><LoadingSpinner /></span> Connecting to server...
</span> </span>
{:else if $roomLoading} {:else if $roomLoading}
<span class="flex items-center"> <span class="flex items-center">
<LoadingSpinner /> Creating Room... <span class="mr-3"><LoadingSpinner /></span> Creating Room...
</span> </span>
{:else} {:else}
<svg <svg
@@ -80,6 +80,7 @@
Enter a custom room name Enter a custom room name
</label> </label>
<input <input
tabindex={!$showRoomNameInput ? -1 : 0}
type="text" type="text"
id="roomNameInput" id="roomNameInput"
bind:value={$roomName} bind:value={$roomName}
@@ -223,7 +224,7 @@
</div> </div>
</section> </section>
<footer class="px-20 pt-3 text-center border-t border-[#21293b]"> <footer class="px-20 pt-3 pb-1 text-center border-t border-[#21293b]">
<div class="max-w-6xl px-10 mx-auto"> <div class="max-w-6xl px-10 mx-auto">
<p> <p>
&copy; {new Date().getFullYear()} Noctis - MIT License &copy; {new Date().getFullYear()} Noctis - MIT License

View File

@@ -1,15 +1,16 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import { room } from "$stores/roomStore"; import { room } from "$stores/roomStore";
import { WebsocketConnectionState, ws } from "$stores/websocketStore"; import { WebsocketConnectionState, ws } from "$stores/websocketStore";
import { RoomStatusType, WebSocketRequestType, WebSocketResponseType } from "$types/websocket"; import { RoomStatusType, WebSocketRequestType, WebSocketResponseType } from "$types/websocket";
import { dataChannelReady, error } from "$lib/webrtcUtil"; import { error, peer } from "$lib/webrtcUtil";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import RtcMessage from "$components/RTCMessage.svelte"; import RtcMessage from "$components/RTCMessage.svelte";
import { page } from "$app/state"; import { page } from "$app/state";
import LoadingSpinner from "$components/LoadingSpinner.svelte"; import LoadingSpinner from "$components/LoadingSpinner.svelte";
import { hashStringSHA256, solveChallenge } from "$lib/powUtil"; import { hashStringSHA256, solveChallenge } from "$lib/powUtil";
import { doChallenge } from "$lib/challenge"; import { doChallenge } from "$lib/challenge";
import { messages } from "$stores/messageStore";
const { roomId } = page.params; const { roomId } = page.params;
let isHost = $derived($room.host === true); let isHost = $derived($room.host === true);
@@ -25,6 +26,11 @@
roomLink = `${window.location.origin}/${roomId}`; roomLink = `${window.location.origin}/${roomId}`;
}); });
onDestroy(() => {
messages.set([]);
$peer?.close();
});
function handleCopyLink() { function handleCopyLink() {
navigator.clipboard.writeText(roomLink).then(() => { navigator.clipboard.writeText(roomLink).then(() => {
copyButtonText = "Copied!"; copyButtonText = "Copied!";
@@ -52,7 +58,7 @@
challenge: { challenge: {
target: challengeResult.target, target: challengeResult.target,
nonce: challengeResult.nonce, nonce: challengeResult.nonce,
} },
}); });
} }
@@ -63,13 +69,6 @@
goto("/"); goto("/");
} }
function handleLeave() {
if (confirm("Are you sure you want to leave? The chat history will be deleted.")) {
// In a real app, this would disconnect the P2P session and redirect.
window.location.href = "/";
}
}
ws.subscribe(async (newWs) => { ws.subscribe(async (newWs) => {
if (newWs.status === WebsocketConnectionState.CONNECTED) { if (newWs.status === WebsocketConnectionState.CONNECTED) {
if (!awaitingJoinConfirmation) { if (!awaitingJoinConfirmation) {
@@ -99,7 +98,7 @@
challenge: { challenge: {
target: challengeResult.target, target: challengeResult.target,
nonce: challengeResult.nonce, nonce: challengeResult.nonce,
} },
}); });
} }
} }
@@ -113,7 +112,7 @@
</h2> </h2>
<p class="!text-paragraph"> <p class="!text-paragraph">
click <a href="/">here</a> click <a href="/">here</a>
to go back to the homepage to go back to the homepage
</p> </p>
{/if} {/if}
@@ -144,18 +143,18 @@
{#if $ws.status !== WebsocketConnectionState.CONNECTED || roomExists === undefined} {#if $ws.status !== WebsocketConnectionState.CONNECTED || roomExists === undefined}
<h2 class="text-3xl font-bold text-white mb-2"> <h2 class="text-3xl font-bold text-white mb-2">
<span class="flex items-center"> <span class="flex items-center">
<LoadingSpinner size="24" /> Connecting to server... <span class="mr-3"><LoadingSpinner size="24" /></span> Connecting to server...
</span> </span>
</h2> </h2>
<p class="!text-paragraph"> <p class="!text-paragraph">
click <a href="/">here</a> click <a href="/">here</a>
to go back to the homepage to go back to the homepage
</p> </p>
{:else if roomExists === false} {:else if roomExists === false}
<h2 class="text-3xl font-bold text-white mb-2">That room does not exist.</h2> <h2 class="text-3xl font-bold text-white mb-2">That room does not exist.</h2>
<p class="!text-paragraph"> <p class="!text-paragraph">
click <a href="/">here</a> click <a href="/">here</a>
to go back to the homepage to go back to the homepage
</p> </p>
{:else} {:else}
<h2 class="text-3xl font-bold text-white mb-2">You're invited to chat.</h2> <h2 class="text-3xl font-bold text-white mb-2">You're invited to chat.</h2>
@@ -172,6 +171,12 @@
</button> </button>
</div> </div>
{/if} {/if}
{:else if !$room.RTCConnectionReady}
<h2 class="text-3xl font-bold text-white mb-2">
<span class="flex items-center">
<span class="mr-3"><LoadingSpinner size="24" /></span> Connecting to room...
</span>
</h2>
{:else} {:else}
<RtcMessage {room} /> <RtcMessage {room} />
{/if} {/if}

View File

@@ -3,6 +3,10 @@ import type { Message } from "$types/message";
export let messages: Writable<Message[]> = writable([]); export let messages: Writable<Message[]> = writable([]);
export let advertisedOffers = writable(new Map<bigint, File>()); export let advertisedOffers = writable(new Map<bigint, File>());
export let receivedOffers = writable(new Map<bigint, { name: string, size: bigint }>()); export let receivedOffers = writable(new Map<bigint, { name: string, size: bigint, type: string }>());
// maps request id to received file id // maps request id to received file id
export let fileRequestIds: Writable<Map<bigint, bigint>> = writable(new Map()); export let fileRequestIds: Writable<Map<bigint, { saveToDisk: boolean, offerId: bigint }>> = writable(new Map());
// maps offer id to file bytes
export let incompleteAutoDownloadedFiles: Map<bigint, Blob> = new Map();
export let downloadedImageFiles: Writable<Map<bigint, Blob>> = writable(new Map());

View File

@@ -0,0 +1,11 @@
import { writable, type Writable } from "svelte/store";
export type Settings = {
autoDownloadImages: boolean;
maxAutoDownloadSize: number;
};
export const settingsStore: Writable<Settings> = writable({
autoDownloadImages: false,
maxAutoDownloadSize: 1024 * 1024 * 10, // 10 mb
});

View File

@@ -38,12 +38,13 @@ export interface FileOfferMessage extends BaseMessage {
// 64 bit file size. chunked at 1024 bytes // 64 bit file size. chunked at 1024 bytes
fileSize: bigint; fileSize: bigint;
// 16 bit file name size
fileNameSize: number;
fileName: string; fileName: string;
fileType: string;
// 64bit randomly generated id to identify the file so that multiple files with the same name can be uploaded // 64bit randomly generated id to identify the file so that multiple files with the same name can be uploaded
id: bigint; id: bigint;
text: string | null; text: string | null;
downloading?: 'preview' | 'downloading' | 'downloaded';
}; };
} }