auto download images
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
if ($inputMessage.length > 0) {
|
||||||
messageBuf.writeString($inputMessage);
|
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,15 +298,58 @@
|
|||||||
{#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 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}
|
{#if msg.data.text !== null}
|
||||||
<p>
|
<p>
|
||||||
{msg.data.text}
|
{msg.data.text}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<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">
|
<h3 class="font-semibold">
|
||||||
{msg.data.fileName}
|
{msg.data.fileName}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -334,11 +358,42 @@
|
|||||||
</p>
|
</p>
|
||||||
<!-- as the initiator, we cant send ourselves a file -->
|
<!-- as the initiator, we cant send ourselves a file -->
|
||||||
{#if !msg.initiator}
|
{#if !msg.initiator}
|
||||||
|
<div class="flex flex-row gap-2 absolute right-2 bottom-2">
|
||||||
|
{#if msg.data.fileType.startsWith("image/")}
|
||||||
<button
|
<button
|
||||||
|
disabled={msg.data.downloading !== undefined}
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
downloadFile(msg.data.id)}
|
downloadFile(msg.data.id, index, false)}
|
||||||
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"
|
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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="16"
|
width="16"
|
||||||
@@ -350,12 +405,13 @@
|
|||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 11l5 5l5-5m-5-7v12"
|
d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 11l5 5l5-5m-5-7v12" /></svg>
|
||||||
/></svg
|
{/if}
|
||||||
>
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
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);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,18 +189,34 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (downloadStream === undefined) {
|
||||||
window.addEventListener("pagehide", onPageHide);
|
window.addEventListener("pagehide", onPageHide);
|
||||||
window.addEventListener("beforeunload", beforeUnload);
|
window.addEventListener("beforeunload", beforeUnload);
|
||||||
@@ -192,7 +226,8 @@ const callbacks = {
|
|||||||
downloadWriter = downloadStream!.getWriter();
|
downloadWriter = downloadStream!.getWriter();
|
||||||
}
|
}
|
||||||
|
|
||||||
await downloadWriter!.write(new Uint8Array(messageData.read()));
|
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(
|
||||||
|
|||||||
@@ -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,16 +68,44 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="app"
|
||||||
|
aria-hidden={$settingsOverlayOpen}
|
||||||
|
class="transition-[filter] duration-300 ease-[cubic-bezier(0.45,_0,_0.55,_1)]">
|
||||||
<header class="p-5">
|
<header class="p-5">
|
||||||
<div class="flex justify-between items-center max-w-7xl px-5 mx-auto">
|
<div class="flex justify-between items-center max-w-7xl px-5 mx-auto">
|
||||||
<div class="text-2xl font-bold text-white">
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<nav>
|
<nav class="flex gap-2 items-center">
|
||||||
<a href="https://github.com/juls0730/noctis" target="_blank" rel="noopener noreferrer">
|
<a
|
||||||
|
href="https://github.com/juls0730/noctis"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">
|
||||||
GitHub
|
GitHub
|
||||||
</a>
|
</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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -59,3 +113,6 @@
|
|||||||
<main>
|
<main>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsOverlay bind:open={$settingsOverlayOpen} />
|
||||||
|
|||||||
@@ -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>
|
||||||
© {new Date().getFullYear()} Noctis - MIT License
|
© {new Date().getFullYear()} Noctis - MIT License
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,7 +143,7 @@
|
|||||||
{#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">
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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());
|
||||||
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
|
// 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';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user