Merge pull request #1 from marlon-erler/2.0

2.0
This commit is contained in:
Marlon Erler
2024-07-09 19:17:57 +02:00
committed by GitHub
26 changed files with 1657 additions and 1321 deletions
BIN
View File
Binary file not shown.
-24
View File
@@ -1,24 +0,0 @@
/* src/styles.css */
#settings-tab > div {
display: flex;
flex-direction: column;
gap: 2rem;
}
#settings-tab > div > div {
width: var(--max-input-width);
min-width: 100%;
max-width: 100%;
flex: 1;
}
article:not([connected]) .connected-only {
display: none;
}
article:not([connected]) .disconnected-only {
display: flex;
}
article[connected] .connected-only {
display: flex;
}
article[connected] .disconnected-only {
display: none;
}
+625 -466
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -214,6 +214,7 @@ SIZES
--gap-between-tiles: 4px;
--gap-in-tab-links: 1rem;
--gap-in-controls: 1.2rem;
--large-gap: 2rem;
/* margin */
--label-top-padding: 0.25rem;
@@ -245,7 +246,7 @@ SIZES
--safe-area-top: env(safe-area-inset-top);
--safe-area-left: env(safe-area-inset-left);
--safe-area-right: env(safe-area-inset-right);
--safe-area-bottom: max(env(safe-area-inset-bottom), var(--control-padding));
--safe-area-bottom: env(safe-area-inset-bottom);
}
/*
@@ -421,6 +422,7 @@ main,
}
.modal > div,
.modal main,
.tile > div {
flex-direction: column;
}
@@ -448,6 +450,10 @@ label.inline,
gap: var(--gap-between-tiles) !important;
}
.large-gap {
gap: var(--large-gap) !important;
}
header,
button {
justify-content: space-between;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"dependencies": {
"bloatless-react": "^1.2.5",
"bloatless-react": "^1.3.1-beta1",
"esbuild": "latest",
"udn-frontend": "^1.0.7-beta1"
},
+215
View File
@@ -0,0 +1,215 @@
import * as React from "bloatless-react";
import { UDN, chatIds, chats, isConnected, senderName } from "../Model/model";
import { decryptString, encryptString } from "../cryptUtility";
import { Message } from "udn-frontend";
import { storageKeys } from "../utility";
// TYPES
// message
export interface ChatMessage {
channel: string;
sender: string;
body: string;
isoDate: string;
}
// chat
export class Chat {
id: string;
isSubscribed = new React.State(false);
primaryChannel = new React.State("");
secondaryChannels: React.ListState<string>;
encryptionKey: React.State<string>;
messages: React.ListState<ChatMessage>;
composingMessage: React.State<string>;
newSecondaryChannelName: React.State<string>;
primaryChannelInput: React.State<string>;
cannotSendMessage: React.State<boolean>;
cannotAddSecondaryChannel: React.State<boolean>;
cannotSetChannel: React.State<boolean>;
cannotUndoChannel: React.State<boolean>;
// init
constructor(id: string = React.UUID()) {
this.id = id;
// channels
this.primaryChannel = React.restoreState(
storageKeys.primaryChannel(id),
""
);
this.secondaryChannels = React.restoreListState(
storageKeys.secondaryChannels(id)
);
// messaging
this.encryptionKey = React.restoreState(storageKeys.encyptionKey(id), "");
this.messages = React.restoreListState(storageKeys.messages(id));
// inputs
this.composingMessage = React.restoreState(
storageKeys.composingMessage(id),
""
);
this.newSecondaryChannelName = new React.State("");
this.primaryChannelInput = new React.State(this.primaryChannel.value);
// guards
this.cannotSendMessage = React.createProxyState(
[
this.primaryChannel,
this.composingMessage,
this.isSubscribed,
isConnected,
senderName,
],
() =>
this.primaryChannel.value == "" ||
this.composingMessage.value == "" ||
senderName.value == "" ||
this.isSubscribed.value == false ||
isConnected.value == false
);
this.cannotAddSecondaryChannel = React.createProxyState(
[this.newSecondaryChannelName],
() => this.newSecondaryChannelName.value == ""
);
this.cannotSetChannel = React.createProxyState(
[this.primaryChannelInput, this.primaryChannel],
() =>
this.primaryChannelInput.value == "" ||
this.primaryChannelInput.value == this.primaryChannel.value
);
this.cannotUndoChannel = React.createProxyState(
[this.primaryChannelInput, this.primaryChannel],
() => this.primaryChannelInput.value == this.primaryChannel.value
);
}
// general
deleteSelf = () => {
Object.values(storageKeys).forEach((storageKey) => {
localStorage.removeItem(storageKey(this.id));
});
chats.remove(this);
chatIds.remove(this.id);
};
// handlers
onmessage = async (data: Message): Promise<void> => {
if (!data.messageChannel) return;
const channels = data.messageChannel.split("/");
if (channels.indexOf(this.primaryChannel.value) == -1) return;
if (data.subscribed != undefined) this.handleSubscription(data.subscribed);
if (!data.messageBody) return;
const { sender, body, channel, isoDate } = JSON.parse(data.messageBody);
this.handleMessage({
sender,
body: await decryptString(body, this.encryptionKey.value),
channel,
isoDate,
});
};
handleSubscription = (isSubscribed: boolean): void => {
this.isSubscribed.value = isSubscribed;
};
handleMessage = (chatMessage: ChatMessage): void => {
this.messages.add(chatMessage);
};
// messages
sendMessage = async (): Promise<void> => {
if (this.cannotSendMessage.value == true) return;
// get channels
const secondaryChannelNames: string[] = [
...this.secondaryChannels.value.values(),
];
const allChannelNames: string[] = [
this.primaryChannel.value,
...secondaryChannelNames,
];
// encrypt
const encrypted =
this.encryptionKey.value == ""
? this.composingMessage.value
: (await encryptString(
this.composingMessage.value,
this.encryptionKey.value
)) || this.composingMessage.value;
// create object
const joinedChannelName = allChannelNames.join("/");
const messageObject: ChatMessage = {
channel: joinedChannelName,
sender: senderName.value,
body: encrypted,
isoDate: new Date().toISOString(),
};
const messageString = JSON.stringify(messageObject);
// send
UDN.sendMessage(joinedChannelName, messageString);
// clear
this.composingMessage.value = "";
};
clearMessages = (): void => {
this.messages.clear();
};
deleteMessage = (message: ChatMessage): void => {
this.messages.remove(message);
};
decryptReceivedMessage = async (message: ChatMessage): Promise<void> => {
message.body = await decryptString(message.body, this.encryptionKey.value);
this.messages.callSubscriptions();
};
// channel
setChannel = (): void => {
if (this.cannotSetChannel.value == true) return;
this.primaryChannel.value = this.primaryChannelInput.value;
UDN.subscribe(this.primaryChannel.value);
};
undoChannelChange = (): void => {
this.primaryChannelInput.value = this.primaryChannel.value;
};
addSecondaryChannel = (): void => {
if (this.cannotAddSecondaryChannel.value == true) return;
this.secondaryChannels.add(this.newSecondaryChannelName.value);
this.newSecondaryChannelName.value = "";
};
removeSecondaryChannel = (channel: string): void => {
this.secondaryChannels.remove(channel);
};
}
// METHODS
export function createChatWithName(name: string): void {
const newChat = new Chat();
chats.add(newChat);
newChat.primaryChannel.value = name;
chatIds.add(newChat.id);
UDN.subscribe(name);
}
+163
View File
@@ -0,0 +1,163 @@
import * as React from "bloatless-react";
import { Chat, createChatWithName } from "./chatModel";
import UDNFrontend from "udn-frontend";
export const UDN = new UDNFrontend();
// CONNECTION
export const isConnected = new React.State(false);
export const serverAddressInput = React.restoreState("socket-address", "");
export const didRequestConnection = React.restoreState(
"did-request-connection",
false
);
export const currentAddress = React.restoreState("current-address", "");
export const cannotDisonnect = React.createProxyState(
[isConnected],
() => isConnected.value == false
);
export const cannotConnect = React.createProxyState(
[serverAddressInput, currentAddress, isConnected],
() =>
serverAddressInput.value == "" ||
(currentAddress.value == serverAddressInput.value &&
isConnected.value == true)
);
export const cannotResetAddress = React.createProxyState(
[serverAddressInput, currentAddress],
() => serverAddressInput.value == currentAddress.value
);
// methods
export function connect(): void {
if (cannotConnect.value == true) return;
currentAddress.value = serverAddressInput.value;
isConnected.value = false;
didRequestConnection.value = true;
UDN.connect(serverAddressInput.value);
}
export function disconnect(): void {
didRequestConnection.value = false;
if (cannotDisonnect.value == true) return;
UDN.disconnect();
}
export function resetAddressInput(): void {
serverAddressInput.value = currentAddress.value;
}
export function subscribeChannels() {
chats.value.forEach((chat) => {
UDN.subscribe(chat.primaryChannel.value);
});
}
// listeners
UDN.onconnect = () => {
isConnected.value = true;
subscribeChannels();
if (mailboxId.value != "") UDN.connectMailbox(mailboxId.value);
};
UDN.onmessage = (data) => {
chats.value.forEach((chat) => {
chat.onmessage(data);
});
};
UDN.ondisconnect = () => {
isConnected.value = false;
};
// MAILBOX
export const mailboxId = React.restoreState("mailbox-id", "");
export const isMailboxActive = new React.State(false);
export const cannotDeleteMailbox = React.createProxyState(
[mailboxId, isMailboxActive],
() => mailboxId.value == "" || isMailboxActive.value == false
);
export const cannotRequestMailbox = React.createProxyState(
[isConnected, isMailboxActive],
() => isConnected.value == false || isMailboxActive.value == true
);
export function requestMailbox(): void {
if (cannotRequestMailbox.value == true) return;
UDN.requestMailbox();
}
export function deleteMailbox(): void {
if (cannotDeleteMailbox.value == true) return;
UDN.deleteMailbox(mailboxId.value);
}
export function updateMailbox(): void {
deleteMailbox();
updateMailbox();
}
UDN.onmailboxcreate = (id) => {
mailboxId.value = id;
UDN.connectMailbox(id);
};
UDN.onmailboxconnect = () => {
isMailboxActive.value = true;
};
UDN.onmailboxdelete = () => {
isMailboxActive.value = false;
mailboxId.value = "";
subscribeChannels();
};
// MISC
export const isEncryptionAvailable = window.crypto.subtle != undefined;
export const senderName = React.restoreState("sender-name", "");
// CHAT
export const chats = new React.ListState<Chat>();
export const chatIds = React.restoreListState<string>("chat-ids");
export const selectedChat = new React.State<Chat | undefined>(undefined);
export const newChatName = new React.State("");
export const cannotCreateChat = React.createProxyState(
[newChatName],
() => newChatName.value == ""
);
export function createChat() {
if (cannotCreateChat.value == true) return;
createChatWithName(newChatName.value);
newChatName.value = "";
}
export function closeChatView() {
selectedChat.value = undefined;
document.getElementById("settings-tab")?.scrollIntoView();
}
export function selectChat(chat: Chat) {
selectedChat.value = chat;
document.getElementById("message-tab")?.scrollIntoView();
}
chatIds.value.forEach((id) => chats.add(new Chat(id)));
// INIT
if (serverAddressInput.value != "" && didRequestConnection.value == true) {
connect();
}
+40 -12
View File
@@ -1,26 +1,54 @@
import * as React from "bloatless-react";
import { closeChatView, selectedChat } from "../Model/model";
import { ChatOptionModal } from "../Views/chatOptionModal";
import { MessageComposer } from "../Views/messageComposer";
import { ThreadView } from "../Views/threadView";
import { clearMessageHistory } from "../model";
import { translation } from "../translations";
export function MessageTab() {
const messageTabContent = React.createProxyState([selectedChat], () => {
if (selectedChat.value == undefined)
return (
<article id="message-tab">
<header>
{translation.messages}
<div class="width-100 height-100 flex-column align-center justify-center">
<span class="secondary">{translation.noChatSelected}</span>
</div>
);
const chat = selectedChat.value;
const isShowingOptions = new React.State(false);
function showOptions() {
isShowingOptions.value = true;
}
return [
<header class="padding-0">
<span class="flex-row align-center">
<button aria-label={translation.back} on:click={closeChatView}>
<span class="icon">arrow_back</span>
</button>
<span subscribe:innerText={chat.primaryChannel}></span>
</span>
<span>
<button
aria-label={translation.clearHistory}
on:click={clearMessageHistory}
aria-label={translation.showChatOptions}
on:click={showOptions}
>
<span class="icon">delete_sweep</span>
<span class="icon">tune</span>
</button>
</span>
</header>
{ThreadView()}
<footer>{MessageComposer()}</footer>
</article>
);
</header>,
ThreadView(chat),
<footer>{MessageComposer(chat)}</footer>,
ChatOptionModal(chat, isShowingOptions),
];
});
return <article id="message-tab" children:set={messageTabContent}></article>;
}
+32
View File
@@ -0,0 +1,32 @@
import * as React from "bloatless-react";
import { isConnected, isEncryptionAvailable } from "../Model/model";
import { ChatListSection } from "../Views/chatListSection";
import { ConnectionSection } from "../Views/connectionSection";
import { PersonalSection } from "../Views/personalSection";
import { translation } from "../translations";
export function OverviewTab() {
return (
<article id="settings-tab" toggle:connected={isConnected}>
<header>{translation.overview}</header>
<div class="flex-column large-gap">
<div class="tile error flex-no" toggle:hidden={isEncryptionAvailable}>
<span class="icon">warning</span>
<div>
<b>{translation.encryptionUnavailableTitle}</b>
<span class="secondary">
{translation.encryptionUnavailableMessage}
</span>
</div>
</div>
{PersonalSection()}
{ConnectionSection()}
{ChatListSection()}
</div>
</article>
);
}
-20
View File
@@ -1,20 +0,0 @@
import * as React from "bloatless-react";
import { CommunicationSection } from "../Views/communicationSection";
import { ConnectionSection } from "../Views/connectionSection";
import { EncryptionSection } from "../Views/encryptionSection";
import { isConnected } from "../model";
import { translation } from "../translations";
export function SettingsTab() {
return (
<article id="settings-tab" toggle:connected={isConnected}>
<header>{translation.settings}</header>
<div>
{ConnectionSection()}
{CommunicationSection()}
{EncryptionSection()}
</div>
</article>
);
}
+70
View File
@@ -0,0 +1,70 @@
import * as React from "bloatless-react";
import {
cannotCreateChat,
chats,
createChat,
newChatName,
selectChat,
selectedChat,
} from "../Model/model";
import { Chat } from "../Model/chatModel";
import { translation } from "../translations";
const chatConverter: React.ListItemConverter<Chat> = (chat) => {
function select() {
selectChat(chat);
}
const isSelected = React.createProxyState(
[selectedChat],
() => selectedChat.value == chat
);
return (
<button class="tile" on:click={select} toggle:selected={isSelected}>
<div>
<b class="ellipsis" subscribe:innerText={chat.primaryChannel}></b>
</div>
<span class="icon">arrow_forward</span>
</button>
);
};
export function ChatListSection() {
return (
<div class="flex-column">
<h2>{translation.chats}</h2>
<label class="tile">
<span class="icon">forum</span>
<div>
<span>{translation.primaryChannel}</span>
<input
bind:value={newChatName}
placeholder={translation.primaryChannelPlaceholder}
on:enter={createChat}
></input>
</div>
</label>
<div class="flex-row justify-end">
<button
class="primary width-50"
toggle:disabled={cannotCreateChat}
on:click={createChat}
>
{translation.addChat}
<span class="icon">add</span>
</button>
</div>
<hr></hr>
<div
class="flex-column gap"
children:append={[chats, chatConverter]}
></div>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import * as React from "bloatless-react";
import { Chat } from "../Model/chatModel";
import { translation } from "../translations";
export function ChatOptionModal(chat: Chat, isPresented: React.State<boolean>) {
function closeModal() {
isPresented.value = false;
}
function deleteChat() {
chat.deleteSelf();
closeModal();
}
const secondaryChannelConverter: React.ListItemConverter<string> = (
secondaryChannel
) => {
function remove() {
chat.removeSecondaryChannel(secondaryChannel);
}
return (
<div class="tile width-input padding-0">
<div class="flex-row justify-apart align-center">
<b class="padding-h">{secondaryChannel}</b>
<button
class="danger"
aria-label={translation.removeSecondaryChannel}
on:click={remove}
>
<span class="icon">delete</span>
</button>
</div>
</div>
);
};
return (
<div class="modal" toggle:open={isPresented}>
<div>
<main>
<h2>{translation.configureChatTitle}</h2>
<div>
<label class="tile">
<span class="icon">forum</span>
<div>
<span>{translation.primaryChannel}</span>
<input
bind:value={chat.primaryChannelInput}
placeholder={translation.primaryChannelPlaceholder}
on:enter={chat.setChannel}
></input>
</div>
</label>
<div class="flex-row width-input">
<button
aria-label={translation.undoChanges}
class="flex justify-center"
on:click={chat.undoChannelChange}
toggle:disabled={chat.cannotUndoChannel}
>
<span class="icon">undo</span>
</button>
<button
aria-label={translation.set}
class="flex justify-center primary"
on:click={chat.setChannel}
toggle:disabled={chat.cannotSetChannel}
>
<span class="icon">check</span>
</button>
</div>
</div>
<hr></hr>
<div class="flex-row margin-bottom width-input">
<input
aria-label={translation.secondaryChannel}
placeholder={translation.secondaryChannelPlaceholder}
bind:value={chat.newSecondaryChannelName}
on:enter={chat.addSecondaryChannel}
></input>
<button
class="primary"
toggle:disabled={chat.cannotAddSecondaryChannel}
aria-label={translation.addSecondaryChannel}
on:click={chat.addSecondaryChannel}
>
<span class="icon">add</span>
</button>
</div>
<div
class="flex-column gap"
children:prepend={[
chat.secondaryChannels,
secondaryChannelConverter,
]}
></div>
<hr></hr>
<div>
<label class="tile">
<span class="icon">key</span>
<div>
<span>{translation.encryptionKey}</span>
<input
placeholder={translation.encryptionKeyPlaceholder}
bind:value={chat.encryptionKey}
></input>
</div>
</label>
</div>
<hr></hr>
<div class="flex-column gap">
<button class="danger" on:click={chat.clearMessages}>
{translation.clearChatMessages}
<span class="icon">delete_sweep</span>
</button>
<button class="danger" on:click={deleteChat}>
{translation.removeChat}
<span class="icon">delete</span>
</button>
</div>
</main>
<button on:click={closeModal}>
{translation.close}
<span class="icon">close</span>
</button>
</div>
</div>
);
}
-116
View File
@@ -1,116 +0,0 @@
import * as React from "bloatless-react";
import {
Channel,
addSecondaryChannel,
cannotAddSecondaryChannel,
cannotLeaveChannel,
cannotSetChannel,
leaveChannel,
newSecondaryChannelName,
primaryChannel,
removeSecondaryChannel,
secondaryChannels,
senderName,
setChannel,
} from "../model";
import { translation } from "../translations";
const secondaryChannelConverter: React.ListItemConverter<Channel> = (
channel
) => {
function remove() {
removeSecondaryChannel(channel);
}
return (
<div class="tile padding-0">
<div class="flex-row justify-apart align-center">
<b class="padding-h">{channel.channelName}</b>
<button
class="danger"
aria-label={translation.removeSecondaryChannel}
on:click={remove}
>
<span class="icon">delete</span>
</button>
</div>
</div>
);
};
export function CommunicationSection() {
return (
<div>
<h2>{translation.communication}</h2>
<label class="tile">
<span class="icon">account_circle</span>
<div>
<span>{translation.myName}</span>
<input
bind:value={senderName}
placeholder={translation.yourNamePlaceholder}
></input>
</div>
</label>
<hr></hr>
<label class="tile">
<span class="icon">forum</span>
<div>
<span>{translation.primaryChannel}</span>
<input
bind:value={primaryChannel}
on:enter={setChannel}
placeholder={translation.channelPlaceholder}
></input>
</div>
</label>
<div class="flex-row width-input justify-end">
<button
class="danger width-50"
on:click={leaveChannel}
toggle:disabled={cannotLeaveChannel}
>
{translation.leaveChannel}
</button>
<button
class="primary width-50"
on:click={setChannel}
toggle:disabled={cannotSetChannel}
>
{translation.setInput}
<span class="icon">check</span>
</button>
</div>
<hr></hr>
<div class="flex-row width-input margin-bottom">
<input
bind:value={newSecondaryChannelName}
on:enter={addSecondaryChannel}
placeholder={translation.newSecondaryChannelPlaceholder}
class="width-100 flex"
></input>
<button
class="primary"
on:click={addSecondaryChannel}
toggle:disabled={cannotAddSecondaryChannel}
aria-label={translation.addSecondaryChannel}
>
<span class="icon">add</span>
</button>
</div>
<div
class="flex-column gap"
subscribe:children={[secondaryChannels, secondaryChannelConverter]}
></div>
</div>
);
}
+79 -5
View File
@@ -1,16 +1,90 @@
import * as React from "bloatless-react";
import { ConnectionInputView } from "./connectionSettingsView";
import { ConnectionStatusView } from "./connectionStatusView";
import {
cannotConnect,
cannotDeleteMailbox,
cannotDisonnect,
cannotRequestMailbox,
cannotResetAddress,
connect,
deleteMailbox,
disconnect,
requestMailbox,
resetAddressInput,
serverAddressInput,
} from "../Model/model";
import { translation } from "../translations";
export function ConnectionSection() {
return (
<div>
<div class="flex-column">
<h2>{translation.connection}</h2>
{ConnectionStatusView()}
<label class="tile">
<span class="icon">cell_tower</span>
<div>
<span>{translation.serverAddress}</span>
<input
bind:value={serverAddressInput}
on:enter={connect}
placeholder={translation.serverAddressPlaceholder}
></input>
</div>
</label>
<div class="flex-row width-input justify-end">
<button
class="danger width-100 flex-1 justify-center"
aria-label={translation.disconnect}
on:click={disconnect}
toggle:disabled={cannotDisonnect}
>
<span class="icon">close</span>
</button>
<button
class="width-100 flex-1 justify-center"
aria-label={translation.undoChanges}
on:click={resetAddressInput}
toggle:disabled={cannotResetAddress}
>
<span class="icon">undo</span>
</button>
<button
class="primary width-100 flex-1 justify-center"
aria-label={translation.connectToServer}
on:click={connect}
toggle:disabled={cannotConnect}
>
<span class="icon">arrow_forward</span>
</button>
</div>
<hr></hr>
{ConnectionInputView()}
<div class="tile">
<span class="icon">inbox</span>
<div>
<b>{translation.mailbox}</b>
<span class="secondary">{translation.mailboxExplanation}</span>
</div>
</div>
<div class="flex-row width-input justify-end">
<button
class="danger width-100 flex-1"
on:click={deleteMailbox}
toggle:disabled={cannotDeleteMailbox}
>
{translation.deleteMailbox}
</button>
<button
class="primary width-100 flex-1"
on:click={requestMailbox}
toggle:disabled={cannotRequestMailbox}
>
{translation.requestMailbox}
<span class="icon">arrow_forward</span>
</button>
</div>
</div>
);
}
-77
View File
@@ -1,77 +0,0 @@
import * as React from "bloatless-react";
import {
cannotConnect,
cannotDeleteMailbox,
cannotDisonnect,
cannotRequestMailbox,
connect,
deleteMailbox,
disconnect,
requestMailbox,
serverAddress,
} from "../model";
import { translation } from "../translations";
export function ConnectionInputView() {
return (
<div class="flex-column">
<label class="tile">
<span class="icon">cell_tower</span>
<div>
<span>{translation.serverAddress}</span>
<input
bind:value={serverAddress}
on:enter={connect}
placeholder={translation.serverAddressPlaceholder}
></input>
</div>
</label>
<div class="flex-row width-input justify-end">
<button
class="danger width-50"
on:click={disconnect}
toggle:disabled={cannotDisonnect}
>
{translation.disconnect}
</button>
<button
class="primary width-50"
on:click={connect}
toggle:disabled={cannotConnect}
>
{translation.connectToServer}
<span class="icon">arrow_forward</span>
</button>
</div>
<hr></hr>
<div class="tile">
<span class="icon">inbox</span>
<div>
<b>{translation.mailbox}</b>
<span class="secondary">{translation.mailboxExplanation}</span>
</div>
</div>
<div class="flex-row width-input justify-end">
<button
class="danger width-50"
on:click={deleteMailbox}
toggle:disabled={cannotDeleteMailbox}
>
{translation.deleteMailbox}
</button>
<button
class="primary width-50"
on:click={requestMailbox}
toggle:disabled={cannotRequestMailbox}
>
{translation.requestMailbox}
<span class="icon">arrow_forward</span>
</button>
</div>
</div>
);
}
-19
View File
@@ -1,19 +0,0 @@
import * as React from "bloatless-react";
import { connectionMessage } from "../model";
import { translation } from "../translations";
export function ConnectionStatusView() {
return (
<div class="tile width-input">
<div>
<b>{translation.connectionStatus}</b>
<span
class="success connected-only"
subscribe:innerText={connectionMessage}
></span>
<span class="error disconnected-only">{translation.disconnected}</span>
</div>
</div>
);
}
-45
View File
@@ -1,45 +0,0 @@
import * as React from "bloatless-react";
import { encryptionKey, isEncryptionUnavailable } from "../model";
import { translation } from "../translations";
const shouldShowKey = new React.State(false);
const inputType = React.createProxyState([shouldShowKey], () =>
shouldShowKey.value ? "text" : "password"
);
export function EncryptionSection() {
return (
<div>
<h2>{translation.encryption}</h2>
<div
class="error tile margin-bottom"
toggle:hidden={!isEncryptionUnavailable}
>
<span class="icon">warning</span>
<div>
<b>{translation.encryptionUnavailableTitle}</b>
<p>{translation.encryptionUnavailableMessage}</p>
</div>
</div>
<label class="tile">
<span class="icon">key</span>
<div>
<span>{translation.encryptionKey}</span>
<input
toggle:disabled={isEncryptionUnavailable}
bind:value={encryptionKey}
set:type={inputType}
placeholder={translation.encryptionKeyPlaceholder}
></input>
</div>
</label>
<label class="inline">
<input type="checkbox" bind:checked={shouldShowKey}></input>
{translation.showEncryptionKey}
</label>
</div>
);
}
+7 -7
View File
@@ -1,23 +1,23 @@
import * as React from "bloatless-react";
import { cannotSendMessage, messageBody, sendMessage } from "../model";
import { Chat } from "../Model/chatModel";
import { translation } from "../translations";
export function MessageComposer() {
export function MessageComposer(chat: Chat) {
return (
<div class="flex-row width-100">
{" "}
<input
class="width-100 flex-1"
style="max-width: unset"
placeholder={translation.composerPlaceholder}
bind:value={messageBody}
on:enter={sendMessage}
bind:value={chat.composingMessage}
on:enter={chat.sendMessage}
></input>
<button
class="primary"
on:click={sendMessage}
toggle:disabled={cannotSendMessage}
on:click={chat.sendMessage}
toggle:disabled={chat.cannotSendMessage}
>
<span class="icon">send</span>
</button>
+21
View File
@@ -0,0 +1,21 @@
import * as React from "bloatless-react";
import { senderName } from "../Model/model";
import { translation } from "../translations";
export function PersonalSection() {
return (
<div class="flex-column">
<label class="tile">
<span class="icon">account_circle</span>
<div>
<span>{translation.yourName}</span>
<input
bind:value={senderName}
placeholder={translation.namePlaceholder}
></input>
</div>
</label>
</div>
);
}
+31 -17
View File
@@ -1,29 +1,28 @@
import * as React from "bloatless-react";
import {
Message,
decryptReceivedMessage,
deleteMessage,
messages,
} from "../model";
import { Chat, ChatMessage } from "../Model/chatModel";
import { translation } from "../translations";
const messageConverter: React.ListItemConverter<Message> = (message) => {
const messageBody = React.createProxyState([messages], () => message.body);
export function ThreadView(chat: Chat) {
const messageConverter: React.ListItemConverter<ChatMessage> = (message) => {
function copyMessage() {
navigator.clipboard.writeText(message.body);
}
function decrypt() {
decryptReceivedMessage(message);
chat.decryptReceivedMessage(message);
}
function remove() {
deleteMessage(message);
chat.deleteMessage(message);
}
const messageBody = React.createProxyState(
[chat.messages],
() => message.body
);
return (
<div class="tile width-100 flex-no padding-0">
<div class="flex-column">
@@ -32,10 +31,16 @@ const messageConverter: React.ListItemConverter<Message> = (message) => {
<b class="info">{message.sender}</b> - {message.channel}
</span>
<span class="flex-row">
<button aria-label={translation.copyMessage} on:click={copyMessage}>
<button
aria-label={translation.copyMessage}
on:click={copyMessage}
>
<span class="icon">content_copy</span>
</button>
<button aria-label={translation.decryptMessage} on:click={decrypt}>
<button
aria-label={translation.decryptMessage}
on:click={decrypt}
>
<span class="icon">key</span>
</button>
<button aria-label={translation.deleteMessage} on:click={remove}>
@@ -52,13 +57,22 @@ const messageConverter: React.ListItemConverter<Message> = (message) => {
</div>
</div>
);
};
};
export function ThreadView() {
return (
const listElement = (
<div
class="flex-column gap"
subscribe:children={[messages, messageConverter]}
children:append={[chat.messages, messageConverter]}
></div>
);
chat.messages.handleAddition(() => {
const scrollFromBottom =
listElement.scrollHeight -
(listElement.scrollTop + listElement.offsetHeight);
if (scrollFromBottom > 400) return;
listElement.scrollTop = listElement.scrollHeight;
});
setTimeout(() => (listElement.scrollTop = listElement.scrollHeight), 50);
return listElement;
}
+34
View File
@@ -1,6 +1,40 @@
const IV_SIZE = 12;
const ENCRYPTION_ALG = "AES-GCM";
// minimal
export async function encryptString(
plaintext: string,
passphrase: string
): Promise<string | false> {
if (!window.crypto.subtle) return false;
const iv = generateIV();
const key = await importKey(passphrase, "encrypt");
const encryptedArray = await encrypt(iv, key, plaintext);
const encryptionData = {
iv: uInt8ToArray(iv),
encryptedArray: uInt8ToArray(encryptedArray),
};
return btoa(JSON.stringify(encryptionData));
}
export async function decryptString(
cyphertext: string,
passphrase: string
): Promise<string> {
try {
const encrypionData = JSON.parse(atob(cyphertext));
const iv = arrayToUint8(encrypionData.iv);
const encryptedArray = arrayToUint8(encrypionData.encryptedArray);
const key = await importKey(passphrase, "decrypt");
return await decrypt(iv, key, encryptedArray);
} catch {
return cyphertext;
}
}
// process
export function encode(string: string): Uint8Array {
return new TextEncoder().encode(string);
+2 -19
View File
@@ -1,22 +1,5 @@
import "./styles.css";
import * as React from "bloatless-react";
import { MessageTab } from "./Tabs/messageTab";
import { SettingsTab } from "./Tabs/settingsTab";
import { translation } from "./translations";
import { OverviewTab } from "./Tabs/overviewTab";
document.body.prepend(
<menu class="mobile-only">
<a class="tab-link" href="#settings-tab" active>
<span class="icon">settings</span>
{translation.settings}
</a>
<a class="tab-link" href="#message-tab">
<span class="icon">forum</span>
{translation.messages}
</a>
</menu>
);
document.querySelector("main")!.append(SettingsTab(), MessageTab());
document.querySelector("main")!.append(OverviewTab(), MessageTab());
document.querySelector("main")!.classList.add("split");
-335
View File
@@ -1,335 +0,0 @@
import * as React from "bloatless-react";
import {
arrayToUint8,
decode,
decrypt,
encrypt,
generateIV,
importKey,
splitMergedString,
uInt8ToArray,
} from "./cryptUtility";
import UDNFrontend from "udn-frontend";
import { translation } from "./translations";
const UDN = new UDNFrontend();
// TYPE
export class Channel implements React.Identifiable {
id = React.UUID();
constructor(public channelName: string) {}
}
export interface Message extends React.Identifiable {
channel: string;
sender: string;
body: string;
isoDate: string;
}
export class Message implements React.Identifiable {
id = React.UUID();
constructor(
public channel: string,
public sender: string,
public body: string,
public isoDate: string
) {}
}
// STATE
// connection
export const currentAddress = new React.State("");
export const serverAddress = React.restoreState("socket-address", "");
export const isConnected = new React.State(false);
export const connectionMessage = React.createProxyState([serverAddress], () =>
translation.connectedTo(serverAddress.value)
);
export const cannotDisonnect = React.createProxyState(
[isConnected],
() => isConnected.value == false
);
export const cannotConnect = React.createProxyState(
[serverAddress, currentAddress, isConnected],
() =>
serverAddress.value == "" ||
(currentAddress.value == serverAddress.value && isConnected.value == true)
);
// mailbox
export const mailboxId = React.restoreState("mailbox-id", "");
export const isMailboxConnected = new React.State(false);
export const cannotRequestMailbox = React.createProxyState(
[isConnected, isMailboxConnected],
() => isConnected.value == false || isMailboxConnected.value == true
);
export const cannotDeleteMailbox = React.createProxyState(
[isConnected, mailboxId, isMailboxConnected],
() =>
isConnected.value == false ||
mailboxId.value == "" ||
isMailboxConnected.value == false
);
// encryption
export const encryptionKey = React.restoreState("encryption-key", "");
export const isEncryptionUnavailable = window.crypto.subtle == undefined;
// channel
export const currentPrimaryChannel = new React.State("");
export const primaryChannel = React.restoreState("primary-channel", "");
export const newSecondaryChannelName = new React.State("");
export const secondaryChannels =
React.restoreListState<Channel>("secondary-channels");
export const cannotSetChannel = React.createProxyState(
[primaryChannel, currentPrimaryChannel, isConnected],
() => primaryChannel.value == "" || isConnected.value == false
);
export const cannotLeaveChannel = React.createProxyState(
[currentPrimaryChannel, isConnected],
() => currentPrimaryChannel.value == "" || isConnected.value == false
);
export const cannotAddSecondaryChannel = React.createProxyState(
[newSecondaryChannelName],
() => newSecondaryChannelName.value == ""
);
// sending
export const senderName = React.restoreState("sender-name", "");
export const messageBody = React.restoreState("message", "");
export const cannotSendMessage = React.createProxyState(
[currentPrimaryChannel, messageBody, senderName, isConnected],
() =>
isConnected.value == false ||
currentPrimaryChannel.value == "" ||
messageBody.value == "" ||
senderName.value == ""
);
// receiving
export const messages = React.restoreListState<Message>("messages");
// METHODS
// connection
export function connect(): void {
if (cannotConnect.value == true) return;
currentAddress.value = serverAddress.value;
isConnected.value = false;
UDN.connect(serverAddress.value);
}
export function disconnect(): void {
if (cannotDisonnect.value == true) return;
UDN.disconnect();
}
// mailbox
export function requestMailbox(): void {
UDN.requestMailbox();
}
export function deleteMailbox(): void {
UDN.deleteMailbox(mailboxId.value);
}
export function updateMailbox(): void {
if (!isMailboxConnected || mailboxId.value == "") return;
deleteMailbox();
requestMailbox();
}
// messages
export async function sendMessage(): Promise<void> {
if (cannotSendMessage.value == true) return;
// get channels
const secondaryChannelNames: string[] = [
...secondaryChannels.value.values(),
].map((channel) => channel.channelName);
const allChannelNames: string[] = [
primaryChannel.value,
...secondaryChannelNames,
];
// encrypt
const encrypted = await encryptMessage();
if (encrypted == undefined) return;
// create object
const joinedChannelName = allChannelNames.join("/");
const messageObject = new Message(
joinedChannelName,
senderName.value,
encrypted,
new Date().toISOString()
);
const messageString = JSON.stringify(messageObject);
// send
UDN.sendMessage(joinedChannelName, messageString);
// clear
messageBody.value = "";
}
export function clearMessageHistory(): void {
messages.clear();
}
export function deleteMessage(message: Message): void {
messages.remove(message);
}
// channels
export function setChannel(shouldUpdateMailbox: boolean = true): void {
if (cannotSetChannel.value == true) return;
if (currentPrimaryChannel != undefined) {
UDN.unsubscribe(currentPrimaryChannel.value);
}
UDN.subscribe(primaryChannel.value);
if (shouldUpdateMailbox) {
updateMailbox();
}
}
export function leaveChannel(): void {
if (cannotLeaveChannel.value == true) return;
UDN.unsubscribe(currentPrimaryChannel.value);
}
export function addSecondaryChannel(): void {
if (cannotAddSecondaryChannel.value == true) return;
const channelObject = new Channel(newSecondaryChannelName.value);
secondaryChannels.add(channelObject);
newSecondaryChannelName.value = "";
}
export function removeSecondaryChannel(channel: Channel): void {
secondaryChannels.remove(channel);
}
// encryption
export async function decryptReceivedMessage(message: Message): Promise<void> {
message.body = await decryptMessage(message.body);
messages.callSubscriptions();
}
// LISTENERS
UDN.onconnect = () => {
isConnected.value = true;
setChannel(false);
if (mailboxId.value != "") {
UDN.connectMailbox(mailboxId.value);
}
};
UDN.onmailboxcreate = (id) => {
mailboxId.value = id;
UDN.connectMailbox(id);
};
UDN.onmailboxconnect = () => {
console.log("connected")
isMailboxConnected.value = true;
};
UDN.onmailboxdelete = () => {
isMailboxConnected.value = false;
mailboxId.value = "";
setChannel();
};
UDN.onmessage = (data) => {
if (data.subscribed != undefined && data.messageChannel != undefined) {
return handleSubscriptionConfirmation(data.messageChannel, data.subscribed);
} else if (data.messageBody) {
handleMessage(data.messageBody);
}
};
UDN.ondisconnect = () => {
isConnected.value = false;
isMailboxConnected.value = false;
};
// UTILITY
function handleSubscriptionConfirmation(
channel: string,
isSubscribed: boolean
): void {
if (isSubscribed == true) {
currentPrimaryChannel.value = channel;
} else {
currentPrimaryChannel.value = "";
}
}
async function handleMessage(body: string): Promise<void> {
try {
const object: Message = JSON.parse(body);
if (!object.channel || !object.sender || !object.body || !object.isoDate)
return;
const messageObject = new Message(
object.channel,
object.sender,
await decryptMessage(object.body),
object.isoDate
);
messages.add(messageObject);
} catch {}
}
async function encryptMessage(): Promise<string> {
if (encryptionKey.value == "") return messageBody.value;
if (!window.crypto.subtle) return messageBody.value;
const iv = generateIV();
const key = await importKey(encryptionKey.value, "encrypt");
const encryptedArray = await encrypt(iv, key, messageBody.value);
const encryptionData = {
iv: uInt8ToArray(iv),
encryptedArray: uInt8ToArray(encryptedArray),
};
return btoa(JSON.stringify(encryptionData));
}
async function decryptMessage(encryptionData: string): Promise<string> {
try {
const encrypionData = JSON.parse(atob(encryptionData));
const iv = arrayToUint8(encrypionData.iv);
const encryptedArray = arrayToUint8(encrypionData.encryptedArray);
const key = await importKey(encryptionKey.value, "decrypt");
return await decrypt(iv, key, encryptedArray);
} catch (error) {
console.error(error);
return encryptionData;
}
}
// INIT
if (serverAddress.value != "") {
connect();
}
-30
View File
@@ -1,30 +0,0 @@
#settings-tab > div {
display: flex;
flex-direction: column;
gap: 2rem;
}
#settings-tab > div > div {
width: var(--max-input-width);
min-width: 100%;
max-width: 100%;
flex: 1;
}
/* CONNECTION */
article:not([connected]) .connected-only {
display: none;
}
article:not([connected]) .disconnected-only {
display: flex;
}
article[connected] .connected-only {
display: flex;
}
article[connected] .disconnected-only {
display: none;
}
+119 -77
View File
@@ -1,44 +1,58 @@
const englishTranslations = {
// general
setInput: "Set",
connectionStatus: "Connection status",
connectedTo: (server: string) => `Connected to "${server}"`,
disconnected: "Disconnected",
set: "Set",
back: "Back",
undoChanges: "Undo changes",
close: "Close",
// overview
overview: "Overview",
// settings
settings: "Settings",
connection: "Connection",
communication: "Communication",
encryption: "Encryption",
chats: "Chats",
yourName: "Your Name",
namePlaceholder: "Jane Doe",
encryptionUnavailableTitle: "Encryption is not available",
encryptionUnavailableMessage:
"Obtain this app via HTTPS or continue without encryption",
serverAddress: "Server Address",
serverAddressPlaceholder: "wss://192.168.0.69:3000",
connectToServer: "Connect",
disconnect: "Disonnect",
primaryChannel: "Primary channel",
leaveChannel: "Leave",
channelPlaceholder: "my-channel",
addSecondaryChannel: "Add secondary channel",
removeSecondaryChannel: "Remove secondary channel",
newSecondaryChannelPlaceholder: "Add secondary channel",
encryptionKey: "Encryption key",
encryptionKeyPlaceholder: "1jg028ej40d",
showEncryptionKey: "Show encryption key",
myName: "My Name",
yourNamePlaceholder: "Jane Doe",
mailbox: "Mailbox",
requestMailbox: "Enable",
deleteMailbox: "Disable",
mailboxExplanation:
"When you're disconnected, messages will be kept on the server temporarily",
primaryChannel: "Primary channel",
primaryChannelPlaceholder: "my-channel",
addChat: "Add",
// messages
messages: "Messages",
showChatOptions: "show chat options",
configureChatTitle: "Configure Chat",
secondaryChannel: "Secondary channel",
secondaryChannelPlaceholder: "Add secondary channel",
addSecondaryChannel: "Add secondary channel",
removeSecondaryChannel: "Remove secondary channel",
encryptionKey: "Encryption key",
encryptionKeyPlaceholder: "n10d2482dg283hg",
removeChat: "Remove chat",
clearChatMessages: "Clear chat messages",
noChatSelected: "No chat selected",
composerPlaceholder: "Type a message...",
sendMessage: "Send",
clearHistory: "Clear history",
encryptionUnavailableTitle: "Encryption is not available",
encryptionUnavailableMessage:
"Obtain this app via HTTPS or continue without encryption",
decryptMessage: "Decrypt message",
copyMessage: "Copy message",
@@ -49,91 +63,119 @@ const allTranslations: { [key: string]: typeof englishTranslations } = {
en: englishTranslations,
es: {
// general
setInput: "OK",
connectionStatus: "Estado de Conexión",
connectedTo: (server: string) => `Conectado a "${server}"`,
disconnected: "Desconectado",
set: "Guardar",
back: "Atrás",
undoChanges: "Deshacer",
close: "Cerrar",
// settings
settings: "Configuración",
connection: "Conexión",
communication: "Comunicación",
encryption: "Cifrado",
serverAddress: "Dirección del servidor",
// overview
overview: "Resumen",
connection: "Conexion",
chats: "Chats",
yourName: "Tu nombre",
namePlaceholder: "Juan Pérez",
encryptionUnavailableTitle: "Cifrado no disponible",
encryptionUnavailableMessage:
"Obtén esta aplicación a traves de HTTPS o continúa sin cifrado",
serverAddress: "Direccion del servidor",
serverAddressPlaceholder: "wss://192.168.0.69:3000",
connectToServer: "Conectar",
disconnect: "Desconectar",
primaryChannel: "Canal principal",
leaveChannel: "Salir",
channelPlaceholder: "mi-canal",
addSecondaryChannel: "Añadir canal segundario",
removeSecondaryChannel: "Eliminar canal segundario",
newSecondaryChannelPlaceholder: "Añadir canal segundario",
encryptionKey: "Clave de cifrada",
encryptionKeyPlaceholder: "1jg028ej40d",
showEncryptionKey: "Mostrar clave de cifrado",
myName: "Mi nombre",
yourNamePlaceholder: "Juan Pérez",
mailbox: "Buzón",
requestMailbox: "Activar",
deleteMailbox: "Desactivar",
mailboxExplanation:
"Si estás sin conexión, los mensajes se guardarán temporalmente en el servidor",
primaryChannel: "Canal principal",
primaryChannelPlaceholder: "mi-canal",
addChat: "Añadir",
// messages
messages: "Mensajes",
showChatOptions: "Mostrar opciones del chat",
configureChatTitle: "Configurar chat",
secondaryChannel: "Canal segundario",
secondaryChannelPlaceholder: "Añadir canal segundario",
addSecondaryChannel: "Añadir canal segundario",
removeSecondaryChannel: "Eliminar canal segundario",
encryptionKey: "Clave de cifrado",
encryptionKeyPlaceholder: "n10d2482dg283hg",
removeChat: "Eliminar chat",
clearChatMessages: "Eliminar todos mensajes",
noChatSelected: "Selecciona un chat",
composerPlaceholder: "Escribe un mensaje...",
sendMessage: "Enviar",
clearHistory: "Borrar historial",
encryptionUnavailableTitle: "Cifrado no disponible",
encryptionUnavailableMessage:
"Obtén esta página a través de HTTPS para cifrar o continúa sin cifrado",
decryptMessage: "Descifrar mensaje",
copyMessage: "Copiar mensaje",
deleteMessage: "Eliminar mensaje",
deleteMessage: "Eliminar este mensaje",
},
de: {
// general
setInput: "OK",
connectionStatus: "Verbindungsstatus",
connectedTo: (server: string) => `Verbunden mit "${server}"`,
disconnected: "Verbindung getrennt",
set: "OK",
back: "Zurück",
undoChanges: "Änderungen verwerfen",
close: "Schließen",
// overview
overview: "Übersicht",
// settings
settings: "Einstellungen",
connection: "Verbindung",
communication: "Kommunikation",
encryption: "Verschlüsselung",
serverAddress: "Serveraddresse",
chats: "Chats",
yourName: "Dein Name",
namePlaceholder: "Max Mustermann",
encryptionUnavailableTitle: "Verschlüsselung nicht möglich",
encryptionUnavailableMessage:
"Lade diese app über HTTPS, um Nachrichten zu verschlüsseln",
serverAddress: "Serveradresse",
serverAddressPlaceholder: "wss://192.168.0.69:3000",
connectToServer: "Verbinden",
disconnect: "Trennen",
primaryChannel: "Hauptkanal",
leaveChannel: "Verlassen",
channelPlaceholder: "mein-kanal",
addSecondaryChannel: "Zweitkanal hinzufügen",
removeSecondaryChannel: "Zweitkanal entfernen",
newSecondaryChannelPlaceholder: "Zweitkanal hinzufügen",
encryptionKey: "Schlüssel",
encryptionKeyPlaceholder: "1jg028ej40d",
showEncryptionKey: "Schlüssel anzeigen",
myName: "Mein Name",
yourNamePlaceholder: "Max Mustermann",
mailbox: "Briefkasten",
requestMailbox: "Aktivieren",
deleteMailbox: "Deaktivieren",
mailboxExplanation:
"Nachrichten werden temporär auf dem Server gespeichert, wenn deine Verbindung getrennt ist",
"Wenn du offline bist, werden Nachrichten auf dem Server gelagert",
primaryChannel: "Hauptkanal",
primaryChannelPlaceholder: "mein-kanal",
addChat: "Hinzufügen",
// messages
messages: "Nachrichten",
showChatOptions: "Chatoptionen einblenden",
configureChatTitle: "Chat konfigurieren",
secondaryChannel: "Zweitkanal",
secondaryChannelPlaceholder: "Zweitkanal hinzufügen",
addSecondaryChannel: "Zweitkanal hinzufügen",
removeSecondaryChannel: "Zweitkanal entfernen",
encryptionKey: "Schlüssel",
encryptionKeyPlaceholder: "n10d2482dg283hg",
removeChat: "Chat löschen",
clearChatMessages: "Nachrichtenverlauf leeren",
noChatSelected: "Kein Chat ausgewählt",
composerPlaceholder: "Neue Nachricht...",
sendMessage: "Senden",
clearHistory: "Nachrichtenverlauf leeren",
encryptionUnavailableTitle: "Verschlüsselung nicht möglich",
encryptionUnavailableMessage:
"Um Nachrichten zu verschlüsseln, lade diese Seite über HTTPS.",
decryptMessage: "Nachricht entschlüsseln",
copyMessage: "Nachricht kopieren",
+21
View File
@@ -0,0 +1,21 @@
export const storageKeys = {
primaryChannel(id: string): string {
return id + "primary-channel";
},
secondaryChannels(id: string): string {
return id + "secondary-channels";
},
encyptionKey(id: string): string {
return id + "encryption-key";
},
messages(id: string): string {
return id + "messages";
},
composingMessage(id: string): string {
return id + "composing-message";
},
};