finish chat so far

This commit is contained in:
marlon-erler
2024-07-09 17:23:29 +02:00
parent dae32fa6e7
commit 1f482dd35d
12 changed files with 657 additions and 548 deletions
+61 -47
View File
@@ -1,6 +1,6 @@
import * as React from "bloatless-react";
import { UDN, isConnected, senderName } from "../Model/model";
import { UDN, chatIds, chats, isConnected, senderName } from "../Model/model";
import { decryptString, encryptString } from "../cryptUtility";
import { Message } from "udn-frontend";
@@ -20,9 +20,7 @@ export class Chat {
id: string;
isSubscribed = new React.State(false);
currentChannel = new React.State("");
primaryChannelInput: React.State<string>;
primaryChannel = new React.State("");
secondaryChannels: React.ListState<string>;
encryptionKey: React.State<string>;
@@ -30,17 +28,19 @@ export class Chat {
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.primaryChannelInput = React.restoreState(
this.primaryChannel = React.restoreState(
storageKeys.primaryChannel(id),
""
);
@@ -52,22 +52,24 @@ export class Chat {
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.currentChannel,
this.primaryChannel,
this.composingMessage,
this.isSubscribed,
isConnected,
],
() =>
this.currentChannel.value == "" ||
this.primaryChannel.value == "" ||
this.composingMessage.value == "" ||
this.isSubscribed.value == false ||
isConnected.value == false
@@ -77,35 +79,45 @@ export class Chat {
() => this.newSecondaryChannelName.value == ""
);
this.cannotSetChannel = React.createProxyState(
[this.primaryChannelInput, this.currentChannel],
[this.primaryChannelInput, this.primaryChannel],
() =>
this.primaryChannelInput.value == "" ||
this.primaryChannelInput.value == this.currentChannel.value
this.primaryChannelInput.value == this.primaryChannel.value
);
this.cannotUndoChannel = React.createProxyState(
[this.primaryChannelInput, this.primaryChannel],
() =>
this.primaryChannelInput.value == this.primaryChannel.value
);
}
// handlers
onmessage(data: Message): void {
if (data.messageChannel && data.messageChannel != this.currentChannel.value)
onmessage = async (data: Message): Promise<void> => {
if (data.messageChannel && data.messageChannel != this.primaryChannel.value)
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, channel, isoDate });
}
this.handleMessage({
sender,
body: await decryptString(body, this.encryptionKey.value),
channel,
isoDate,
});
};
handleSubscription(isSubscribed: boolean): void {
handleSubscription = (isSubscribed: boolean): void => {
this.isSubscribed.value = isSubscribed;
}
};
handleMessage(chatMessage: ChatMessage): void {
handleMessage = (chatMessage: ChatMessage): void => {
this.messages.add(chatMessage);
}
};
// messages
async sendMessage(): Promise<void> {
sendMessage = async (): Promise<void> => {
if (this.cannotSendMessage.value == true) return;
// get channels
@@ -113,17 +125,18 @@ export class Chat {
...this.secondaryChannels.value.values(),
].map((channel) => channel);
const allChannelNames: string[] = [
this.currentChannel.value,
this.primaryChannel.value,
...secondaryChannelNames,
];
// encrypt
const encrypted =
(await encryptString(
this.composingMessage.value,
this.encryptionKey.value
)) || this.composingMessage.value;
if (encrypted == undefined) return;
this.encryptionKey.value == ""
? this.composingMessage.value
: (await encryptString(
this.composingMessage.value,
this.encryptionKey.value
)) || this.composingMessage.value;
// create object
const joinedChannelName = allChannelNames.join("/");
@@ -140,52 +153,53 @@ export class Chat {
// clear
this.composingMessage.value = "";
}
};
clearMessages(): void {
clearMessages = (): void => {
this.messages.clear();
}
};
deleteMessage(message: ChatMessage): void {
deleteMessage = (message: ChatMessage): void => {
this.messages.remove(message);
}
};
async decryptReceivedMessage(message: ChatMessage): Promise<void> {
decryptReceivedMessage = async (message: ChatMessage): Promise<void> => {
message.body = await decryptString(message.body, this.encryptionKey.value);
this.messages.callSubscriptions();
}
};
// channel
setChannel(): void {
setChannel = (): void => {
if (this.cannotSetChannel.value == true) return;
this.currentChannel.value = this.primaryChannelInput.value;
UDN.subscribe(this.currentChannel.value);
}
this.primaryChannel.value = this.primaryChannelInput.value;
UDN.subscribe(this.primaryChannel.value);
};
addSecondaryChannel(): void {
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 {
removeSecondaryChannel = (channel: string): void => {
this.secondaryChannels.remove(channel);
}
};
}
// INIT
const chatIds = React.restoreListState<string>("chat-ids");
export const chats = new React.ListState<Chat>();
chatIds.value.forEach((id) => chats.add(new Chat(id)));
// METHODS
export function createChatWithName(name: string): void {
const newChat = new Chat();
newChat.currentChannel.value = name;
chatIds.add(newChat.id);
chats.add(newChat);
newChat.primaryChannel.value = name;
chatIds.add(newChat.id);
UDN.subscribe(name);
}
export function removeChat(chat: Chat): void {
+17 -3
View File
@@ -1,6 +1,6 @@
import * as React from "bloatless-react";
import { Chat, chats, createChatWithName } from "./chatModel";
import { Chat, createChatWithName } from "./chatModel";
import UDNFrontend from "udn-frontend";
@@ -59,6 +59,10 @@ export function resetAddressInput(): void {
// listeners
UDN.onconnect = () => {
isConnected.value = true;
chats.value.forEach((chat) => {
UDN.subscribe(chat.primaryChannel.value);
});
};
UDN.onmessage = (data) => {
@@ -114,9 +118,13 @@ UDN.onmailboxdelete = () => {
};
// MISC
export const isEncryptionUnavailable = window.crypto.subtle == undefined;
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("");
@@ -127,7 +135,7 @@ export const cannotCreateChat = React.createProxyState(
export function createChat() {
if (cannotCreateChat.value == true) return;
createChatWithName(newChatName.value);
newChatName.value = "";
}
@@ -137,6 +145,12 @@ export function closeChatView() {
document.getElementById("settings-tab")?.scrollIntoView();
}
export function selectChat(chat: Chat) {
selectedChat.value = chat;
}
chatIds.value.forEach((id) => chats.add(new Chat(id)));
// INIT
if (serverAddressInput.value != "" && didRequestConnection.value == true) {
connect();
+13 -6
View File
@@ -2,6 +2,7 @@ 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 { translation } from "../translations";
@@ -16,9 +17,13 @@ export function MessageTab() {
);
const chat = selectedChat.value;
const isShowingOptions = new React.State(false);
function clearMessageHistory() {
chat.clearMessages();
function showOptions() {
isShowingOptions.value = true;
}
function hideOptions() {
isShowingOptions.value = false;
}
return (
@@ -29,19 +34,21 @@ export function MessageTab() {
<span class="icon">arrow_back</span>
</button>
<span subscribe:innerText={chat.currentChannel}></span>
<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(chat)}
<footer>{MessageComposer(chat)}</footer>
{ChatOptionModal(chat, isShowingOptions)}
</article>
);
});
@@ -1,15 +1,28 @@
import * as React from "bloatless-react";
import { isConnected, isEncryptionAvailable } from "../Model/model";
import { ChatListSection } from "../Views/chatListSection";
import { ConnectionSection } from "../Views/connectionSection";
import { isConnected } from "../Model/model";
import { translation } from "../translations";
export function SettingsTab() {
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>
{ConnectionSection()}
{ChatListSection()}
</div>
+25 -17
View File
@@ -1,29 +1,34 @@
import * as React from "bloatless-react";
import { Chat, chats, removeChat } from "../Model/chatModel";
import { cannotCreateChat, createChat, newChatName } from "../Model/model";
import { Chat, removeChat } from "../Model/chatModel";
import {
cannotCreateChat,
chats,
createChat,
newChatName,
selectChat,
selectedChat,
} from "../Model/model";
import { translation } from "../translations";
const chatConverter: React.ListItemConverter<Chat> = (chat) => {
function remove() {
removeChat(chat);
function select() {
selectChat(chat);
}
const isSelected = React.createProxyState(
[selectedChat],
() => selectedChat.value == chat
);
return (
<div class="tile padding-0">
<div class="flex-row width-100 justify-apart align-center">
<span class="padding-h ellipsis" subscribe:innerText={chat.currentChannel}></span>
<button
class="danger"
aria-label={translation.removeChat}
on:click={remove}
>
<span class="icon">delete</span>
</button>
<button class="tile" on:click={select} toggle:selected={isSelected}>
<div>
<b class="ellipsis" subscribe:innerText={chat.primaryChannel}></b>
</div>
</div>
<span class="icon">arrow_forward</span>
</button>
);
};
@@ -56,7 +61,10 @@ export function ChatListSection() {
<hr></hr>
<div class="flex-column gap" children:append={[chats, chatConverter]}></div>
<div
class="flex-column gap"
children:append={[chats, chatConverter]}
></div>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
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;
}
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>
<label class="tile">
<span class="icon">key</span>
<div>
<span>{translation.encryptionKey}</span>
<input
placeholder={translation.encryptionKeyPlaceholder}
bind:value={chat.encryptionKey}
></input>
</div>
</label>
</main>
<button class="danger" on:click={closeModal}>
{translation.close}
<span class="icon">close</span>
</button>
</div>
</div>
);
}
+1 -1
View File
@@ -43,7 +43,7 @@ export function ConnectionSection() {
</button>
<button
class="width-100 flex-1 justify-center"
aria-label={translation.resetAddress}
aria-label={translation.undoChanges}
on:click={resetAddressInput}
toggle:disabled={cannotResetAddress}
>
+1 -1
View File
@@ -44,7 +44,7 @@ export function ThreadView(chat: Chat) {
</span>
</div>
<div class="flex-column padding-h padding-bottom">
<b class="break-word" subscribe:innerText={message.body}></b>
<b class="break-word">{message.body}</b>
<span class="secondary">
{new Date(message.isoDate).toLocaleString()}
</span>
+2 -2
View File
@@ -1,5 +1,5 @@
import { MessageTab } from "./Tabs/messageTab";
import { SettingsTab } from "./Tabs/settingsTab";
import { OverviewTab } from "./Tabs/overviewTab";
document.querySelector("main")!.append(SettingsTab(), MessageTab());
document.querySelector("main")!.append(OverviewTab(), MessageTab());
document.querySelector("main")!.classList.add("split");
+28 -112
View File
@@ -1,7 +1,9 @@
const englishTranslations = {
// general
setInput: "Set",
set: "Set",
back: "Back",
undoChanges: "Undo changes",
close: "Close",
// overview
overview: "Overview",
@@ -9,46 +11,46 @@ const englishTranslations = {
connection: "Connection",
chats: "Chats",
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",
resetAddress: "Reset address",
newChatPrimaryChannel: "Primary channel",
newChatNamePlaceholder: "my-channel",
addChat: "Add",
removeChat: "Remove chat",
// messages
noChatSelected: "No chat selected",
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",
newChatPrimaryChannel: "Primary channel",
newChatNamePlaceholder: "my-channel",
addChat: "Add",
removeChat: "Remove chat",
// messages
messages: "Messages",
showChatOptions: "show chat options",
configureChatTitle: "Configure Chat",
primaryChannel: "Primary channel",
primaryChannelPlaceholder: "my-channel",
secondaryChannel: "Secondary channel",
secondaryChannelPlaceholder: "Add secondary channel",
addSecondaryChannel: "Add secondary channel",
removeSecondaryChannel: "Remove secondary channel",
encryptionKey: "Encryption key",
encryptionKeyPlaceholder: "n10d2482dg283hg",
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",
@@ -57,92 +59,6 @@ const englishTranslations = {
const allTranslations: { [key: string]: typeof englishTranslations } = {
en: englishTranslations,
es: {
// general
setInput: "OK",
// settings
overview: "Configuración",
connection: "Conexión",
communication: "Comunicación",
encryption: "Cifrado",
serverAddress: "Dirección 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",
// messages
messages: "Mensajes",
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",
},
de: {
// general
setInput: "OK",
// settings
overview: "Einstellungen",
connection: "Verbindung",
communication: "Kommunikation",
encryption: "Verschlüsselung",
serverAddress: "Serveraddresse",
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",
// messages
messages: "Nachrichten",
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",
deleteMessage: "Nachricht löschen",
},
};
const language = navigator.language.substring(0, 2);