auto download images
This commit is contained in:
@@ -6,23 +6,14 @@
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class="animate-spin -ml-1 mr-3"
|
||||
class="animate-spin"
|
||||
style="width: {size}px; height: {size}px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
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>
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<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 {
|
||||
isRTCConnected,
|
||||
dataChannelReady,
|
||||
peer,
|
||||
keyExchangeDone,
|
||||
} from "$lib/webrtcUtil";
|
||||
import { isRTCConnected, dataChannelReady, peer, keyExchangeDone } from "$lib/webrtcUtil";
|
||||
import {
|
||||
advertisedOffers,
|
||||
downloadedImageFiles,
|
||||
fileRequestIds,
|
||||
messages,
|
||||
receivedOffers,
|
||||
@@ -18,34 +14,14 @@
|
||||
import { MessageType } from "$types/message";
|
||||
import { fade } from "svelte/transition";
|
||||
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 inputFile: Writable<FileList | null | undefined> = writable(null);
|
||||
let inputFileElement: HTMLInputElement | null = $state(null);
|
||||
let initialConnectionCompleteCount = writable(0);
|
||||
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++;
|
||||
}
|
||||
});
|
||||
let initialConnectionComplete = writable(false);
|
||||
|
||||
const { room }: { room: Writable<Room> } = $props();
|
||||
|
||||
@@ -59,6 +35,14 @@
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
initialConnectionComplete.set(false);
|
||||
});
|
||||
|
||||
downloadedImageFiles.subscribe(() => {
|
||||
console.log("Auto downloaded files:", $downloadedImageFiles);
|
||||
});
|
||||
|
||||
function sendMessage() {
|
||||
if (!$peer) {
|
||||
console.error("Peer not initialized");
|
||||
@@ -72,14 +56,12 @@
|
||||
}
|
||||
|
||||
if ($inputFile != null && $inputFile[0] !== undefined) {
|
||||
// fileSize + fileNameSize + fileNameLen + id + textLen + header
|
||||
// fileSize + fileNameSize + fileTypeLen + id + textLen + header
|
||||
let messageLen =
|
||||
8 +
|
||||
$inputFile[0].name.length +
|
||||
2 +
|
||||
8 +
|
||||
$inputMessage.length +
|
||||
1;
|
||||
1 + 8 + ($inputFile[0].name.length + 1) + ($inputFile[0].type.length + 1) + 8;
|
||||
if ($inputMessage.length > 0) {
|
||||
messageLen += $inputMessage.length + 1;
|
||||
}
|
||||
let messageBuf = new WebBuffer(new ArrayBuffer(messageLen));
|
||||
|
||||
let fileId = new WebBuffer(
|
||||
@@ -97,15 +79,14 @@
|
||||
|
||||
messageBuf.writeInt8(MessageType.FILE_OFFER);
|
||||
messageBuf.writeBigInt64LE(BigInt($inputFile[0].size));
|
||||
messageBuf.writeInt16LE($inputFile[0].name.length);
|
||||
messageBuf.writeString($inputFile[0].name);
|
||||
messageBuf.writeString($inputFile[0].type);
|
||||
messageBuf.writeBigInt64LE(fileId);
|
||||
if ($inputMessage.length > 0) {
|
||||
messageBuf.writeString($inputMessage);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Sending file offer",
|
||||
new Uint8Array(messageBuf.buffer),
|
||||
);
|
||||
console.log("Sending file offer", new Uint8Array(messageBuf.buffer));
|
||||
|
||||
$messages = [
|
||||
...$messages,
|
||||
@@ -114,8 +95,8 @@
|
||||
type: MessageType.FILE_OFFER,
|
||||
data: {
|
||||
fileSize: BigInt($inputFile[0].size),
|
||||
fileNameSize: $inputFile[0].name.length,
|
||||
fileName: $inputFile[0].name,
|
||||
fileType: $inputFile[0].type,
|
||||
id: fileId,
|
||||
text: $inputMessage === "" ? null : $inputMessage,
|
||||
},
|
||||
@@ -155,12 +136,17 @@
|
||||
$peer.send(messageBuf.buffer, WebRTCPacketType.MESSAGE);
|
||||
}
|
||||
|
||||
function downloadFile(id: bigint) {
|
||||
function downloadFile(id: bigint, messageIdx: number, saveToDisk: boolean = true) {
|
||||
if (!$peer) {
|
||||
console.error("Peer not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
if ($messages[messageIdx].type !== MessageType.FILE_OFFER) {
|
||||
console.error("Message is not a file offer");
|
||||
return;
|
||||
}
|
||||
|
||||
let file = $receivedOffers.get(id);
|
||||
if (!file) {
|
||||
console.error("Unknown file id:", id);
|
||||
@@ -175,7 +161,17 @@
|
||||
fileRequestBuf.writeBigInt64LE(id);
|
||||
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);
|
||||
}
|
||||
@@ -184,6 +180,7 @@
|
||||
keyExchangeDone.subscribe((value) => {
|
||||
console.log("Key exchange done:", value, $keyExchangeDone);
|
||||
if (value) {
|
||||
initialConnectionComplete.set(true);
|
||||
// provide a grace period for the user to see that the connection is established
|
||||
setTimeout(() => {
|
||||
canCloseLoadingOverlay.set(true);
|
||||
@@ -221,25 +218,23 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<p>
|
||||
<!-- <p>
|
||||
{$room?.id}
|
||||
({$room?.participants}) - {$room?.connectionState} - {$ws.status}
|
||||
- Initial connection {$initialConnectionComplete
|
||||
? "complete"
|
||||
: "incomplete"}
|
||||
</p>
|
||||
({$room?.participants}) - {RoomConnectionState[$room?.connectionState]} - {WebsocketConnectionState[
|
||||
$ws.status
|
||||
]} - {$isRTCConnected} - {$dataChannelReady} - {$keyExchangeDone}
|
||||
- Initial connection {$initialConnectionComplete ? "complete" : "incomplete"}
|
||||
</p> -->
|
||||
|
||||
<!-- 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}
|
||||
<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
|
||||
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}
|
||||
<div
|
||||
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}
|
||||
<p>Waiting for peer to connect...</p>
|
||||
{:else if !$dataChannelReady && !$initialConnectionComplete}
|
||||
@@ -247,20 +242,11 @@
|
||||
{:else if !$keyExchangeDone}
|
||||
<p>Establishing a secure connection with the peer...</p>
|
||||
{:else if $room.connectionState === RoomConnectionState.RECONNECTING}
|
||||
<p>
|
||||
Disconnect from peer, attempting to reconnecting...
|
||||
</p>
|
||||
<p>Disconnect from peer, attempting to reconnecting...</p>
|
||||
{:else if $room.participants !== 2 || $dataChannelReady === false}
|
||||
<p>
|
||||
Peer has disconnected, waiting for other peer to
|
||||
<span>reconnect...</span>
|
||||
</p>
|
||||
<p>Peer has disconnected, waiting for other peer to reconnect...</p>
|
||||
{:else}
|
||||
<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>
|
||||
<p>Successfully established a secure connection to peer!</p>
|
||||
{/if}
|
||||
<div class="mt-2">
|
||||
{#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"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
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"
|
||||
/>
|
||||
stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
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>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#each $messages as msg}
|
||||
{#each $messages as msg, index}
|
||||
<div class="flex flex-row gap-2">
|
||||
<p class="whitespace-nowrap">
|
||||
{#if msg.initiator}
|
||||
@@ -317,15 +298,58 @@
|
||||
{#if msg.type === MessageType.TEXT}
|
||||
<p>{msg.data}</p>
|
||||
{: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 the file was auto downloaded, OR we downloaded the file manually OR we are the sender of the image (regardless of auto download setting) -->
|
||||
{#if msg.data.fileType.startsWith("image/")}
|
||||
{#if $settingsStore.autoDownloadImages || $downloadedImageFiles.has(msg.data.id) || msg.initiator}
|
||||
{#if msg.initiator}
|
||||
<!-- just preview the image -->
|
||||
<img
|
||||
class="w-auto h-auto rounded"
|
||||
src={URL.createObjectURL(
|
||||
$advertisedOffers.get(msg.data.id)!,
|
||||
)}
|
||||
alt={msg.data.fileName} />
|
||||
{:else}
|
||||
<!-- wait for the image to download in the auto download queue -->
|
||||
{#if $downloadedImageFiles.has(msg.data.id)}
|
||||
<img
|
||||
class="max-w-3/5 h-auto rounded"
|
||||
src={URL.createObjectURL(
|
||||
$downloadedImageFiles.get(msg.data.id)!,
|
||||
)}
|
||||
alt={msg.data.fileName} />
|
||||
{:else}
|
||||
<div
|
||||
class="bg-neutral-800 animate-pulse rounded w-40 h-56 text-paragraph-muted flex justify-center items-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
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="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}
|
||||
{: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"
|
||||
>
|
||||
class="flex flex-col p-2 relative w-8/12 bg-primary/50 rounded">
|
||||
<h3 class="font-semibold">
|
||||
{msg.data.fileName}
|
||||
</h3>
|
||||
@@ -334,11 +358,42 @@
|
||||
</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)}
|
||||
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"
|
||||
>
|
||||
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"
|
||||
@@ -350,12 +405,13 @@
|
||||
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
|
||||
>
|
||||
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>
|
||||
{:else}
|
||||
<p>Unknown message type: {msg.type}</p>
|
||||
@@ -367,17 +423,14 @@
|
||||
type="file"
|
||||
bind:files={$inputFile}
|
||||
bind:this={inputFileElement}
|
||||
class="absolute opacity-0 -top-[9999px] -left-[9999px]"
|
||||
/>
|
||||
<div class="flex gap-2 w-full flex-row">
|
||||
class="absolute opacity-0 -top-[9999px] -left-[9999px]" />
|
||||
<div class="flex gap-2 w-full flex-row mb-2">
|
||||
<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}
|
||||
<div class="flex flex-row gap-2 p-2">
|
||||
<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">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -390,17 +443,11 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1"
|
||||
><path
|
||||
d="M14 3v4a1 1 0 0 0 1 1h4"
|
||||
/><path
|
||||
d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2"
|
||||
/></g
|
||||
></svg
|
||||
>
|
||||
><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path
|
||||
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>
|
||||
<p
|
||||
class="text-sm whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
>
|
||||
<p class="text-sm whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{$inputFile[0].name}
|
||||
</p>
|
||||
|
||||
@@ -408,8 +455,7 @@
|
||||
onclick={() => {
|
||||
$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
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
@@ -421,9 +467,7 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M18 6L6 18M6 6l12 12"
|
||||
/></svg
|
||||
>
|
||||
d="M18 6L6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -433,12 +477,9 @@
|
||||
<textarea
|
||||
bind:value={$inputMessage}
|
||||
cols="1"
|
||||
use:autogrow={$inputMessage}
|
||||
use:autogrow
|
||||
onkeydown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!e.getModifierState("Shift")
|
||||
) {
|
||||
if (e.key === "Enter" && !e.getModifierState("Shift")) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
@@ -446,8 +487,7 @@
|
||||
disabled={!$isRTCConnected ||
|
||||
!$dataChannelReady ||
|
||||
!$keyExchangeDone ||
|
||||
$room.connectionState ===
|
||||
RoomConnectionState.RECONNECTING}
|
||||
$room.connectionState === RoomConnectionState.RECONNECTING}
|
||||
placeholder="Type your message..."
|
||||
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"
|
||||
@@ -458,11 +498,9 @@
|
||||
disabled={!$isRTCConnected ||
|
||||
!$dataChannelReady ||
|
||||
!$keyExchangeDone ||
|
||||
$room.connectionState ===
|
||||
RoomConnectionState.RECONNECTING}
|
||||
$room.connectionState === RoomConnectionState.RECONNECTING}
|
||||
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
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
@@ -474,19 +512,15 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
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"
|
||||
/></svg
|
||||
>
|
||||
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>
|
||||
</button>
|
||||
<button
|
||||
onclick={sendMessage}
|
||||
disabled={!$isRTCConnected ||
|
||||
!$dataChannelReady ||
|
||||
!$keyExchangeDone ||
|
||||
$room.connectionState ===
|
||||
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"
|
||||
>
|
||||
$room.connectionState === 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">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
@@ -498,9 +532,7 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
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"
|
||||
/></svg
|
||||
>
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
119
src/components/SettingsOverlay.svelte
Normal file
119
src/components/SettingsOverlay.svelte
Normal 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}
|
||||
88
src/components/ToggleSwitch.svelte
Normal file
88
src/components/ToggleSwitch.svelte
Normal 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>
|
||||
@@ -150,20 +150,25 @@ export class WebBuffer {
|
||||
this.dataView.setBigInt64(offset, value, true);
|
||||
}
|
||||
|
||||
// if no length is specified, reads until the end of the buffer
|
||||
readString(length?: number, offset?: number): string {
|
||||
if (length === undefined) {
|
||||
length = this.length - this.count;
|
||||
}
|
||||
|
||||
readString(offset?: number): string {
|
||||
if (offset === undefined) {
|
||||
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 readTextBuf = this.data.slice(offset, offset + length);
|
||||
let value = textDeccoder.decode(readTextBuf);
|
||||
let value = textDeccoder.decode(new Uint8Array(stringBytes));
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -171,12 +176,17 @@ export class WebBuffer {
|
||||
writeString(value: string, offset?: number) {
|
||||
if (offset === undefined) {
|
||||
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 textBuf = textEncoder.encode(value);
|
||||
|
||||
console.log("Writing string:", value, textBuf);
|
||||
|
||||
this.data.set(textBuf, offset);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { 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 {
|
||||
private peer: RTCPeerConnection | null = null;
|
||||
private dataChannel: RTCDataChannel | null = null;
|
||||
private isInitiator: boolean;
|
||||
private roomId: string;
|
||||
private callbacks: WebRTCPeerCallbacks;
|
||||
private callbacks: Map<WebRTCCallbackType, WebRTCCallback['cb'][]> = new Map();
|
||||
private credential: Credential;
|
||||
private clientState: ClientState | undefined;
|
||||
private cipherSuite: CiphersuiteImpl | undefined;
|
||||
@@ -27,12 +70,46 @@ export class WebRTCPeer {
|
||||
constructor(roomId: string, isInitiator: boolean, callbacks: WebRTCPeerCallbacks) {
|
||||
this.roomId = roomId;
|
||||
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));
|
||||
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) {
|
||||
ws.send({
|
||||
type: WebSocketWebRtcMessageType.ICE_CANDIDATE,
|
||||
@@ -68,14 +145,14 @@ export class WebRTCPeer {
|
||||
this.peer.oniceconnectionstatechange = () => {
|
||||
console.log('ICE connection state changed to:', this.peer?.iceConnectionState);
|
||||
if (this.peer?.iceConnectionState === 'connected' || this.peer?.iceConnectionState === 'completed') {
|
||||
this.callbacks.onConnected();
|
||||
this.emitCallback(WebRTCCallbackType.CONNECTED);
|
||||
} else if (this.peer?.iceConnectionState === 'failed') {
|
||||
this.callbacks.onError('ICE connection failed');
|
||||
this.emitCallback(WebRTCCallbackType.ERROR, 'ICE connection failed');
|
||||
}
|
||||
};
|
||||
|
||||
this.peer.onnegotiationneeded = () => {
|
||||
this.callbacks.onNegotiationNeeded();
|
||||
this.emitCallback(WebRTCCallbackType.NEGOTIATION_NEEDED);
|
||||
};
|
||||
|
||||
// 2. Create data channel
|
||||
@@ -96,7 +173,7 @@ export class WebRTCPeer {
|
||||
channel.binaryType = "arraybuffer";
|
||||
|
||||
channel.onopen = async () => {
|
||||
this.callbacks.onDataChannelStateChange(true);
|
||||
this.emitCallback(WebRTCCallbackType.DATA_CHANNEL_STATE_CHANGE, true);
|
||||
|
||||
try {
|
||||
if (this.isInitiator) {
|
||||
@@ -114,7 +191,7 @@ export class WebRTCPeer {
|
||||
}
|
||||
} catch (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;
|
||||
}
|
||||
|
||||
console.log("parsed data", data, encrypted, type);
|
||||
console.log("parsed data", data, encrypted, WebRTCPacketType[type]);
|
||||
|
||||
if (type === WebRTCPacketType.GROUP_OPEN) {
|
||||
await this.generateKeyPair();
|
||||
@@ -181,7 +258,7 @@ export class WebRTCPeer {
|
||||
|
||||
this.send(encodedWelcomeBuf, WebRTCPacketType.WELCOME);
|
||||
this.encyptionReady = true;
|
||||
this.callbacks.onKeyExchangeDone();
|
||||
this.emitCallback(WebRTCCallbackType.KEY_EXCHANGE_DONE);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -205,7 +282,7 @@ export class WebRTCPeer {
|
||||
|
||||
console.log("Joined group", this.clientState);
|
||||
this.encyptionReady = true;
|
||||
this.callbacks.onKeyExchangeDone();
|
||||
this.emitCallback(WebRTCCallbackType.KEY_EXCHANGE_DONE);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -235,17 +312,17 @@ export class WebRTCPeer {
|
||||
data: data.buffer,
|
||||
};
|
||||
|
||||
this.callbacks.onMessage(message, this);
|
||||
this.emitCallback(WebRTCCallbackType.MESSAGE, message, this);
|
||||
};
|
||||
|
||||
channel.onclose = () => {
|
||||
this.callbacks.onDataChannelStateChange(false);
|
||||
this.emitCallback(WebRTCCallbackType.DATA_CHANNEL_STATE_CHANGE, false);
|
||||
|
||||
};
|
||||
|
||||
channel.onerror = (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);
|
||||
} catch (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) {
|
||||
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));
|
||||
} catch (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);
|
||||
} catch (e) {
|
||||
console.error("Error generating key package:", e);
|
||||
this.callbacks.onError(e);
|
||||
this.emitCallback(WebRTCCallbackType.ERROR, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ import { WebRTCPeer } from "$lib/webrtc";
|
||||
import { WebRTCPacketType } from "$types/webrtc";
|
||||
import { room } from "$stores/roomStore";
|
||||
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 { type WebSocketMessage } from "$types/websocket";
|
||||
import { WebBuffer } from "./buffer";
|
||||
import { goto } from "$app/navigation";
|
||||
import { settingsStore } from "$stores/settingsStore";
|
||||
|
||||
export const error: Writable<string | null> = writable(null);
|
||||
export let peer: Writable<WebRTCPeer | null> = writable(null);
|
||||
@@ -46,7 +47,6 @@ const callbacks = {
|
||||
console.log("Connected to peer");
|
||||
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) => {
|
||||
console.log("WebRTC Received message:", message);
|
||||
if (message.type !== WebRTCPacketType.MESSAGE) {
|
||||
@@ -74,23 +74,41 @@ const callbacks = {
|
||||
break;
|
||||
case MessageType.FILE_OFFER:
|
||||
let fileSize = messageData.readBigInt64LE();
|
||||
let fileNameSize = messageData.readInt16LE();
|
||||
let fileName = messageData.readString(fileNameSize);
|
||||
let fileName = messageData.readString();
|
||||
let fileType = messageData.readString();
|
||||
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), {
|
||||
initiator: false,
|
||||
type: messageType,
|
||||
data: {
|
||||
fileSize,
|
||||
fileNameSize,
|
||||
fileName,
|
||||
fileType,
|
||||
id,
|
||||
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;
|
||||
case MessageType.FILE_REQUEST:
|
||||
// the id that coresponds to our file offer
|
||||
@@ -171,18 +189,34 @@ const callbacks = {
|
||||
break;
|
||||
case MessageType.FILE:
|
||||
let requestId = messageData.readBigInt64LE();
|
||||
let receivedOffserId = get(fileRequestIds).get(requestId);
|
||||
if (!receivedOffserId) {
|
||||
let receivedOffer = get(fileRequestIds).get(requestId);
|
||||
if (!receivedOffer) {
|
||||
console.error("Received file message for unknown file id:", requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
let file = get(receivedOffers).get(receivedOffserId);
|
||||
let file = get(receivedOffers).get(receivedOffer.offerId);
|
||||
if (!file) {
|
||||
console.error("Unknown file id:", requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
let fileData = new Uint8Array(messageData.read());
|
||||
|
||||
console.log("recieved chunk of auto downloaded file");
|
||||
if (get(receivedOffers).get(receivedOffer.offerId)!.type.startsWith("image/")) {
|
||||
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)!));
|
||||
}
|
||||
}
|
||||
|
||||
if (receivedOffer.saveToDisk) {
|
||||
if (downloadStream === undefined) {
|
||||
window.addEventListener("pagehide", onPageHide);
|
||||
window.addEventListener("beforeunload", beforeUnload);
|
||||
@@ -192,7 +226,8 @@ const callbacks = {
|
||||
downloadWriter = downloadStream!.getWriter();
|
||||
}
|
||||
|
||||
await downloadWriter!.write(new Uint8Array(messageData.read()));
|
||||
await downloadWriter!.write(new Uint8Array(fileData));
|
||||
}
|
||||
|
||||
let fileAckBuf = new WebBuffer(new ArrayBuffer(1 + 8));
|
||||
fileAckBuf.writeInt8(MessageType.FILE_ACK);
|
||||
@@ -208,6 +243,18 @@ const callbacks = {
|
||||
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("beforeunload", beforeUnload);
|
||||
|
||||
@@ -264,7 +311,7 @@ export async function handleMessage(event: MessageEvent) {
|
||||
return;
|
||||
case WebSocketRoomMessageType.PARTICIPANT_JOINED:
|
||||
console.log("new client joined room");
|
||||
room.update((room) => ({ ...room, participants: room.participants + 1 }));
|
||||
room.update((room) => ({ ...room, participants: message.participants }));
|
||||
return;
|
||||
case WebSocketResponseType.ROOM_JOINED:
|
||||
// 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");
|
||||
return;
|
||||
case WebSocketRoomMessageType.PARTICIPANT_LEFT:
|
||||
room.update((room) => ({ ...room, participants: room.participants - 1 }));
|
||||
room.update((room) => ({ ...room, participants: message.participants }));
|
||||
console.log("Participant left room");
|
||||
return;
|
||||
case WebSocketErrorType.ERROR:
|
||||
@@ -288,7 +335,7 @@ export async function handleMessage(event: MessageEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
room.update(r => ({ ...r, RTCConnectionReady: true }));
|
||||
room.update(r => ({ ...r, RTCConnectionReady: true, participants: message.data.participants }));
|
||||
|
||||
console.log("Creating peer");
|
||||
peer.set(new WebRTCPeer(
|
||||
|
||||
@@ -4,9 +4,35 @@
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { WebsocketConnectionState, ws } from "$stores/websocketStore";
|
||||
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(() => {
|
||||
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(() => {
|
||||
@@ -42,16 +68,44 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/streamsaver@2.0.3/StreamSaver.min.js"></script>
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
id="app"
|
||||
aria-hidden={$settingsOverlayOpen}
|
||||
class="transition-[filter] duration-300 ease-[cubic-bezier(0.45,_0,_0.55,_1)]">
|
||||
<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 href="/" class="!text-white !no-underline"
|
||||
>Noctis<span class="text-accent">.</span>
|
||||
</a>
|
||||
</div>
|
||||
<nav>
|
||||
<a href="https://github.com/juls0730/noctis" target="_blank" rel="noopener noreferrer">
|
||||
<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>
|
||||
</header>
|
||||
@@ -59,3 +113,6 @@
|
||||
<main>
|
||||
{@render children?.()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<SettingsOverlay bind:open={$settingsOverlayOpen} />
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
challenge: {
|
||||
target: challengeResult.target,
|
||||
nonce: challengeResult.nonce,
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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">
|
||||
{#if $ws.status !== WebsocketConnectionState.CONNECTED}
|
||||
<span class="flex items-center">
|
||||
<LoadingSpinner /> Connecting to server...
|
||||
<span class="mr-3"><LoadingSpinner /></span> Connecting to server...
|
||||
</span>
|
||||
{:else if $roomLoading}
|
||||
<span class="flex items-center">
|
||||
<LoadingSpinner /> Creating Room...
|
||||
<span class="mr-3"><LoadingSpinner /></span> Creating Room...
|
||||
</span>
|
||||
{:else}
|
||||
<svg
|
||||
@@ -80,6 +80,7 @@
|
||||
Enter a custom room name
|
||||
</label>
|
||||
<input
|
||||
tabindex={!$showRoomNameInput ? -1 : 0}
|
||||
type="text"
|
||||
id="roomNameInput"
|
||||
bind:value={$roomName}
|
||||
@@ -223,7 +224,7 @@
|
||||
</div>
|
||||
</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">
|
||||
<p>
|
||||
© {new Date().getFullYear()} Noctis - MIT License
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { room } from "$stores/roomStore";
|
||||
import { WebsocketConnectionState, ws } from "$stores/websocketStore";
|
||||
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 RtcMessage from "$components/RTCMessage.svelte";
|
||||
import { page } from "$app/state";
|
||||
import LoadingSpinner from "$components/LoadingSpinner.svelte";
|
||||
import { hashStringSHA256, solveChallenge } from "$lib/powUtil";
|
||||
import { doChallenge } from "$lib/challenge";
|
||||
import { messages } from "$stores/messageStore";
|
||||
const { roomId } = page.params;
|
||||
|
||||
let isHost = $derived($room.host === true);
|
||||
@@ -25,6 +26,11 @@
|
||||
roomLink = `${window.location.origin}/${roomId}`;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
messages.set([]);
|
||||
$peer?.close();
|
||||
});
|
||||
|
||||
function handleCopyLink() {
|
||||
navigator.clipboard.writeText(roomLink).then(() => {
|
||||
copyButtonText = "Copied!";
|
||||
@@ -52,7 +58,7 @@
|
||||
challenge: {
|
||||
target: challengeResult.target,
|
||||
nonce: challengeResult.nonce,
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,13 +69,6 @@
|
||||
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) => {
|
||||
if (newWs.status === WebsocketConnectionState.CONNECTED) {
|
||||
if (!awaitingJoinConfirmation) {
|
||||
@@ -99,7 +98,7 @@
|
||||
challenge: {
|
||||
target: challengeResult.target,
|
||||
nonce: challengeResult.nonce,
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -144,7 +143,7 @@
|
||||
{#if $ws.status !== WebsocketConnectionState.CONNECTED || roomExists === undefined}
|
||||
<h2 class="text-3xl font-bold text-white mb-2">
|
||||
<span class="flex items-center">
|
||||
<LoadingSpinner size="24" /> Connecting to server...
|
||||
<span class="mr-3"><LoadingSpinner size="24" /></span> Connecting to server...
|
||||
</span>
|
||||
</h2>
|
||||
<p class="!text-paragraph">
|
||||
@@ -172,6 +171,12 @@
|
||||
</button>
|
||||
</div>
|
||||
{/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}
|
||||
<RtcMessage {room} />
|
||||
{/if}
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { Message } from "$types/message";
|
||||
|
||||
export let messages: Writable<Message[]> = writable([]);
|
||||
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
|
||||
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());
|
||||
11
src/stores/settingsStore.ts
Normal file
11
src/stores/settingsStore.ts
Normal 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
|
||||
});
|
||||
@@ -38,12 +38,13 @@ export interface FileOfferMessage extends BaseMessage {
|
||||
// 64 bit file size. chunked at 1024 bytes
|
||||
fileSize: bigint;
|
||||
|
||||
// 16 bit file name size
|
||||
fileNameSize: number;
|
||||
fileName: string;
|
||||
fileType: string;
|
||||
|
||||
// 64bit randomly generated id to identify the file so that multiple files with the same name can be uploaded
|
||||
id: bigint;
|
||||
text: string | null;
|
||||
downloading?: 'preview' | 'downloading' | 'downloaded';
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user