auto download images

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

View File

@@ -6,23 +6,14 @@
</script>
<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>

View File

@@ -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);
messageBuf.writeString($inputMessage);
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,45 +298,120 @@
{#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">
{#if msg.data.text !== null}
<p>
{msg.data.text}
</p>
{/if}
<div
class="flex flex-col p-2 relative w-8/12 bg-primary/50 rounded"
>
<h3 class="font-semibold">
{msg.data.fileName}
</h3>
<p class="text-sm text-paragraph-muted">
{msg.data.fileSize} bytes
</p>
<!-- as the initiator, we cant send ourselves a file -->
{#if !msg.initiator}
<button
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"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 11l5 5l5-5m-5-7v12"
/></svg
>
</button>
<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}
</div>
{:else}
{#if msg.data.text !== null}
<p>
{msg.data.text}
</p>
{/if}
<div
class="flex flex-col p-2 relative w-8/12 bg-primary/50 rounded">
<h3 class="font-semibold">
{msg.data.fileName}
</h3>
<p class="text-sm text-paragraph-muted">
{msg.data.fileSize} bytes
</p>
<!-- as the initiator, we cant send ourselves a file -->
{#if !msg.initiator}
<div class="flex flex-row gap-2 absolute right-2 bottom-2">
{#if msg.data.fileType.startsWith("image/")}
<button
disabled={msg.data.downloading !== undefined}
onclick={() =>
downloadFile(msg.data.id, index, false)}
class="p-1 border border-[#2c3444]/80 text-paragraph hover:bg-[#1D1C1F]/60 transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed">
{#if msg.data.downloading === "preview"}
<LoadingSpinner size="16" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
><path
d="M14 3v4a1 1 0 0 0 1 1h4" /><path
d="M12 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v4.5" /><path
d="M14 17.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0m4.5 2L21 22" /></g
></svg>
{/if}
</button>
{/if}
<button
disabled={msg.data.downloading !== undefined}
onclick={() => downloadFile(msg.data.id, index)}
class="p-1 border border-[#2c3444]/80 text-paragraph hover:bg-[#1D1C1F]/60 transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed">
{#if msg.data.downloading === "downloading"}
<LoadingSpinner size="16" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 11l5 5l5-5m-5-7v12" /></svg>
{/if}
</button>
</div>
{/if}
</div>
{/if}
</div>
{: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>

View File

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

View File

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

View File

@@ -150,20 +150,25 @@ export class WebBuffer {
this.dataView.setBigInt64(offset, value, true);
}
// 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);
}

View File

@@ -5,12 +5,55 @@ import { browser } from '$app/environment';
import { createApplicationMessage, createCommit, createGroup, decodeMlsMessage, defaultCapabilities, defaultLifetime, emptyPskIndex, encodeMlsMessage, generateKeyPackage, getCiphersuiteFromName, getCiphersuiteImpl, joinGroup, processPrivateMessage, type CiphersuiteImpl, type ClientState, type Credential, type KeyPackage, type PrivateKeyPackage, type Proposal } from 'ts-mls';
import { 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);
}
}

View File

@@ -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,28 +189,45 @@ 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;
}
if (downloadStream === undefined) {
window.addEventListener("pagehide", onPageHide);
window.addEventListener("beforeunload", beforeUnload);
let fileData = new Uint8Array(messageData.read());
// @ts-ignore
downloadStream = window.streamSaver.createWriteStream(file.name, { size: Number(file.size) });
downloadWriter = downloadStream!.getWriter();
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)!));
}
}
await downloadWriter!.write(new Uint8Array(messageData.read()));
if (receivedOffer.saveToDisk) {
if (downloadStream === undefined) {
window.addEventListener("pagehide", onPageHide);
window.addEventListener("beforeunload", beforeUnload);
// @ts-ignore
downloadStream = window.streamSaver.createWriteStream(file.name, { size: Number(file.size) });
downloadWriter = downloadStream!.getWriter();
}
await downloadWriter!.write(new Uint8Array(fileData));
}
let fileAckBuf = new WebBuffer(new ArrayBuffer(1 + 8));
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(

View File

@@ -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,20 +68,51 @@
<script src="https://cdn.jsdelivr.net/npm/streamsaver@2.0.3/StreamSaver.min.js"></script>
</svelte:head>
<header class="p-5">
<div class="flex justify-between items-center max-w-7xl px-5 mx-auto">
<div class="text-2xl font-bold text-white">
<a href="/" class="!text-white !no-underline">Noctis<span class="text-accent">.</span>
</a>
<div
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>
</div>
<nav class="flex gap-2 items-center">
<a
href="https://github.com/juls0730/noctis"
target="_blank"
rel="noopener noreferrer">
GitHub
</a>
<button
onclick={() => settingsOverlayOpen.set(!$settingsOverlayOpen)}
class="p-1 rounded-md hover:bg-surface/80 transition-colors cursor-pointer group">
<svg
class="group-hover:text-accent group-hover:rotate-45 transition-[colors,rotate] duration-300 ease-in-out"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
><path
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 0 0-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 0 0-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 0 0-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 0 0-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 0 0 1.066-2.573c-.94-1.543.826-3.31 2.37-2.37c1 .608 2.296.07 2.572-1.065" /><path
d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0-6 0" /></g
></svg>
</button>
</nav>
</div>
<nav>
<a href="https://github.com/juls0730/noctis" target="_blank" rel="noopener noreferrer">
GitHub
</a>
</nav>
</div>
</header>
</header>
<main>
{@render children?.()}
</main>
<main>
{@render children?.()}
</main>
</div>
<SettingsOverlay bind:open={$settingsOverlayOpen} />

View File

@@ -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>
&copy; {new Date().getFullYear()} Noctis - MIT License

View File

@@ -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,
}
},
});
}
}
@@ -113,7 +112,7 @@
</h2>
<p class="!text-paragraph">
click <a href="/">here</a>
to go back to the homepage
to go back to the homepage
</p>
{/if}
@@ -144,18 +143,18 @@
{#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">
click <a href="/">here</a>
to go back to the homepage
to go back to the homepage
</p>
{:else if roomExists === false}
<h2 class="text-3xl font-bold text-white mb-2">That room does not exist.</h2>
<p class="!text-paragraph">
click <a href="/">here</a>
to go back to the homepage
to go back to the homepage
</p>
{:else}
<h2 class="text-3xl font-bold text-white mb-2">You're invited to chat.</h2>
@@ -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}

View File

@@ -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());

View File

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

View File

@@ -38,12 +38,13 @@ export interface FileOfferMessage extends BaseMessage {
// 64 bit file size. chunked at 1024 bytes
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';
};
}