delete src

This commit is contained in:
marlon-erler
2024-07-19 17:20:22 +02:00
parent 1f3753db78
commit 78ebb2a4f5
30 changed files with 0 additions and 3828 deletions
-494
View File
@@ -1,494 +0,0 @@
import * as React from "bloatless-react";
import {
UDN,
chatIds,
chats,
isConnected,
outboxItemCount,
selectedChat,
senderName,
updateMailbox,
usedObjectCategories,
usedObjectStatuses,
} from "../Model/model";
import { decryptString, encryptString } from "../cryptUtility";
import { Message } from "udn-frontend";
import { storageKeys } from "../utility";
import { translation } from "../translations";
import { viewTypes } from "../Views/Objects/objectPane";
// TYPES
// message
export interface ChatMessage {
channel: string;
sender: string;
body: string;
isoDate: string;
messageObjectString?: string;
}
// object
export interface MessageObject {
id: string;
title: string;
contentVersions: { [key: string]: MessageObjectContent };
}
export interface MessageObjectContent {
isoDateVersionCreated: string;
id: string;
noteContent?: string;
priority?: number;
categoryName?: string;
status?: string;
date?: string;
time?: string;
}
export interface MessageObjectWithIndex extends MessageObject {
index: React.State<number>;
}
export class MessageObjectWithIndex implements MessageObjectWithIndex {
constructor(messageObject: MessageObject, index: number = 0) {
this.id = messageObject.id;
this.title = messageObject.title;
this.contentVersions = messageObject.contentVersions;
this.index = new React.State(index);
}
}
// chat
export class Chat {
id: string;
viewType: React.State<keyof typeof viewTypes>;
isSubscribed = new React.State(false);
primaryChannel = new React.State("");
hasUnreadMessages: React.State<boolean>;
secondaryChannels: React.ListState<string>;
encryptionKey: React.State<string>;
messages: React.ListState<ChatMessage>;
objects: React.MapState<MessageObject>;
outbox: React.ListState<ChatMessage>;
objectOutbox: React.MapState<MessageObject>;
isOutBoxEmpty: React.State<boolean>;
composingMessage: React.State<string>;
primaryChannelInput: React.State<string>;
newSecondaryChannelName: React.State<string>;
cannotSendMessage: React.State<boolean>;
cannotResendMessage: React.State<boolean>;
cannotAddSecondaryChannel: React.State<boolean>;
cannotSetChannel: React.State<boolean>;
cannotUndoChannel: React.State<boolean>;
cannotClearMessages: React.State<boolean>;
cannotClearObjects: React.State<boolean>;
// init
constructor(id: string = React.UUID()) {
this.id = id;
this.viewType = React.restoreState<keyof typeof viewTypes>(
storageKeys.viewType(id),
"all"
);
// channels
this.primaryChannel = React.restoreState(
storageKeys.primaryChannel(id),
""
);
this.secondaryChannels = React.restoreListState(
storageKeys.secondaryChannels(id)
);
// messaging
this.hasUnreadMessages = React.restoreState(
storageKeys.hasUnread(id),
false
);
this.encryptionKey = React.restoreState(storageKeys.encyptionKey(id), "");
this.messages = React.restoreListState(storageKeys.messages(id));
this.objects = React.restoreMapState(storageKeys.objects(id));
this.outbox = React.restoreListState(storageKeys.outbox(id));
this.objectOutbox = React.restoreMapState(storageKeys.itemOutbox(id));
this.isOutBoxEmpty = React.createProxyState(
[this.outbox],
() => this.outbox.value.size == 0
);
// outbox
React.bulkSubscribe([this.outbox, this.objectOutbox], () =>
this.updateOutboxCount()
);
this.updateOutboxCount();
// 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, senderName],
() =>
this.primaryChannel.value == "" ||
this.composingMessage.value == "" ||
senderName.value == ""
);
this.cannotResendMessage = React.createProxyState(
[this.primaryChannel, senderName],
() => this.primaryChannel.value == "" || senderName.value == ""
);
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
);
this.cannotClearMessages = React.createProxyState(
[this.messages],
() => this.messages.value.size == 0
);
this.cannotClearObjects = React.createProxyState(
[this.objects],
() => this.objects.value.size == 0
);
}
// 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 message: ChatMessage = JSON.parse(data.messageBody);
const { sender, body, channel, isoDate, messageObjectString } = message;
if (messageObjectString) {
const decryptedMessageObjectString = await decryptString(
messageObjectString,
this.encryptionKey.value
);
const messageObject = JSON.parse(decryptedMessageObjectString);
if (messageObject.id) return this.handleMessageObject(messageObject);
}
this.handleMessage({
sender,
body: await decryptString(body, this.encryptionKey.value),
channel,
isoDate,
});
};
handleSubscription = (isSubscribed: boolean): void => {
this.isSubscribed.value = isSubscribed;
if (isSubscribed == true) {
this.sendMessagesInOutbox();
}
};
handleMessage = (chatMessage: ChatMessage): void => {
this.messages.add(chatMessage);
if (selectedChat.value != this) this.hasUnreadMessages.value = true;
};
handleMessageObject = (messageObject: MessageObject): void => {
this.addObject(messageObject, true);
};
// sending
sendMessagesInOutbox = () => {
this.outbox.value.forEach(async (message) => {
const isSent = await this.sendMessage(message);
if (isSent == true) this.outbox.remove(message);
});
this.objectOutbox.value.forEach(async (messageObject) => {
const chatMessage = await this.createChatMessage("", messageObject);
const isSent = await this.sendMessage(chatMessage);
if (isSent == true) this.objectOutbox.remove(messageObject.id);
});
};
updateOutboxCount() {
outboxItemCount.value =
this.outbox.value.size + this.objectOutbox.value.size;
}
// messages
createChatMessage = async (
messageText: string,
messageObject?: MessageObject
): Promise<ChatMessage> => {
// get channels
const secondaryChannelNames: string[] = [
...this.secondaryChannels.value.values(),
];
const allChannelNames: string[] = [
this.primaryChannel.value,
...secondaryChannelNames,
];
// create object
const joinedChannelName = allChannelNames.join("/");
const chatMessage: ChatMessage = {
channel: joinedChannelName,
sender: senderName.value,
body: messageText,
isoDate: new Date().toISOString(),
};
if (messageObject)
chatMessage.messageObjectString = await encryptString(
JSON.stringify(messageObject),
this.encryptionKey.value
);
return chatMessage;
};
sendMessageFromComposer = async (): Promise<void> => {
if (this.cannotSendMessage.value == true) return;
await this.sendMessageFromText(this.composingMessage.value);
this.composingMessage.value = "";
};
sendMessageFromText = async (text: string): Promise<void> => {
const chatMessage = await this.createChatMessage(text);
this.outbox.add(chatMessage);
this.sendMessagesInOutbox();
};
resendMessage = (chatMessage: ChatMessage) => {
if (this.cannotResendMessage.value == true) return;
this.sendMessageFromText(chatMessage.body);
};
clearMessages = (): void => {
this.messages.clear();
};
deleteMessage = (chatMessage: ChatMessage): void => {
this.messages.remove(chatMessage);
};
deleteOutboxMessage = (chatMessage: ChatMessage): void => {
this.outbox.remove(chatMessage);
};
decryptReceivedMessage = async (chatMessage: ChatMessage): Promise<void> => {
chatMessage.body = await decryptString(
chatMessage.body,
this.encryptionKey.value
);
this.messages.callSubscriptions();
};
sendMessage = async (chatMessage: ChatMessage): Promise<boolean> => {
if (isConnected.value == false || this.isSubscribed.value == false)
return false;
const encryptedBody =
this.encryptionKey.value == ""
? chatMessage.body
: await encryptString(chatMessage.body, this.encryptionKey.value);
chatMessage.body = encryptedBody;
const messageString = JSON.stringify(chatMessage);
UDN.sendMessage(chatMessage.channel, messageString);
return true;
};
// objects
createObjectFromTitle = (title: string): MessageObject => {
const firstObjectContent = this.createObjectContent();
const messageObject: MessageObject = {
id: React.UUID(),
title,
contentVersions: {},
};
this.addObjectContent(messageObject, firstObjectContent);
return messageObject;
};
createObjectContent = (): MessageObjectContent => {
return {
id: React.UUID(),
isoDateVersionCreated: new Date().toISOString(),
};
};
addObjectContent = (
messageObject: MessageObject,
content: MessageObjectContent
): void => {
messageObject.contentVersions[content.id] = content;
};
updateObjectContent = (
messageObject: MessageObject,
content: MessageObjectContent
): void => {
const latest = this.getMostRecentContent(messageObject);
Object.entries(content).forEach((entry) => {
const [key, value] = entry;
latest[key] = value;
});
this.addObjectContent(messageObject, latest);
};
addObject = (
messageObject: MessageObject,
ignoreIfUnchanged: boolean = false
): void => {
const existingObject = this.objects.value.get(messageObject.id);
if (existingObject) {
let isIdentical = existingObject.title == messageObject.title;
existingObject.title = messageObject.title;
Object.values(messageObject.contentVersions).forEach((content) => {
if (existingObject.contentVersions[content.id]) return;
this.addObjectContent(existingObject, content);
isIdentical = false;
});
if (isIdentical && ignoreIfUnchanged) return;
this.objects.set(existingObject.id, existingObject);
} else {
this.objects.set(messageObject.id, messageObject);
}
const latest = this.getMostRecentContent(messageObject);
if (latest.categoryName) usedObjectCategories.add(latest.categoryName);
if (latest.status) usedObjectStatuses.add(latest.status);
};
addObjectAndSend = (messageObject: MessageObject): void => {
this.addObject(messageObject);
this.sendObject(messageObject);
};
sendObject = (messageObject: MessageObject): void => {
this.objectOutbox.set(messageObject.id, messageObject);
this.sendMessagesInOutbox();
};
deleteObject = (messageObject: MessageObject): void => {
this.objects.remove(messageObject.id);
this.objectOutbox.remove(messageObject.id);
};
resendObjects = (): void => {
this.objects.value.forEach((messageObject) => {
this.sendObject(messageObject);
});
};
clearObjects = (): void => {
this.objects.clear();
};
getSortedContents = (
messageObject: MessageObject
): MessageObjectContent[] => {
const contents = Object.values(messageObject.contentVersions);
contents.sort((a, b) =>
a.isoDateVersionCreated > b.isoDateVersionCreated ? -1 : 1
);
return contents;
};
getMostRecentContentId = (messageObject: MessageObject): string => {
const contents = Object.values(messageObject.contentVersions);
return contents[contents.length - 1].id;
};
getMostRecentContent = (
messageObject: MessageObject
): MessageObjectContent => {
const id = this.getMostRecentContentId(messageObject);
return this.getObjectContentFromId(messageObject, id);
};
getObjectContentFromId = (
messageObject: MessageObject,
contentId: string
): MessageObjectContent => {
return messageObject.contentVersions[contentId];
};
getObjectTitle = (messageObject: MessageObject) => {
return messageObject.title || translation.untitledObject;
};
// channel
setChannel = (): void => {
if (this.cannotSetChannel.value == true) return;
this.primaryChannel.value = this.primaryChannelInput.value;
UDN.subscribe(this.primaryChannel.value);
updateMailbox();
};
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;
newChat.primaryChannelInput.value = name;
chatIds.add(newChat.id);
UDN.subscribe(name);
updateMailbox();
}
-283
View File
@@ -1,283 +0,0 @@
import * as React from "bloatless-react";
import { Chat, createChatWithName } from "./chatModel";
import UDNFrontend from "udn-frontend";
import { translation } from "../translations";
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 previousAddresses =
React.restoreListState<string>("previous-addresses");
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;
serverAddressInput.value = serverAddressInput.value.toLowerCase();
currentAddress.value = serverAddressInput.value;
isConnected.value = false;
didRequestConnection.value = true;
UDN.connect(serverAddressInput.value);
isMailboxActive.value = false;
}
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;
const lowerCasedAddress = currentAddress.value.toLowerCase();
if (!previousAddresses.value.has(lowerCasedAddress)) {
previousAddresses.add(lowerCasedAddress);
}
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, isConnected],
() =>
mailboxId.value == "" ||
isMailboxActive.value == false ||
isConnected.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 {
if (isMailboxActive.value == false) return;
deleteMailbox();
setTimeout(() => requestMailbox(), 10);
}
UDN.onmailboxcreate = (id) => {
mailboxId.value = id;
UDN.connectMailbox(id);
};
UDN.onmailboxconnect = (id) => {
isMailboxActive.value = true;
};
UDN.onmailboxdelete = () => {
isMailboxActive.value = false;
mailboxId.value = "";
subscribeChannels();
};
// OUTBOX
export const outboxItemCount = new React.State(0);
export const outboxText = React.createProxyState([outboxItemCount], () =>
translation.outboxText(outboxItemCount.value)
);
export const outboxTextStyle = React.createProxyState([outboxItemCount], () =>
outboxItemCount.value == 0 ? "success" : "warning"
);
// MISC
export const isPresentingSettingsModal = new React.State(false);
export const isEncryptionAvailable = window.crypto.subtle != undefined;
export const senderName = React.restoreState("sender-name", "");
export const pageZoom = React.restoreState("page-zoom", 100);
export const objectFilterInput = React.restoreState("object-filter", "");
export const firstDayOfWeek = React.restoreState("first-day-of-week", 0);
export const dayInCalendar = React.restoreState("calendar-day", new Date().toISOString().split("T")[0])
pageZoom.subscribe(() => {
document.body.style.zoom = `${pageZoom.value}%`;
document.body.style.webkitTextSizeAdjust = `${pageZoom.value}%`;
});
export const usedObjectCategories =
React.restoreListState<string>("object-categories");
export const usedObjectStatuses =
React.restoreListState<string>("object-statuses");
export const previousObjectSearches =
React.restoreListState<string>("object-searches");
export function toggleSettings() {
isPresentingSettingsModal.value = !isPresentingSettingsModal.value;
}
export function repairApp() {
mapSetToLowercase(previousAddresses);
removeSetDuplicates(previousAddresses);
removeSetDuplicates(usedObjectCategories);
removeSetDuplicates(usedObjectStatuses);
removeSetDuplicates(previousObjectSearches);
}
function mapSetToLowercase(listState: React.ListState<string>) {
const lowerCased = [...listState.value].map((x) => x.toLowerCase());
listState.clear();
listState.add(...lowerCased);
}
function removeSetDuplicates(listState: React.ListState<any>) {
const values = [...listState.value];
listState.clear();
listState.add(...values);
}
export function clearAddresses() {
previousAddresses.clear();
}
export function clearCategories() {
usedObjectCategories.clear();
}
export function clearStatuses() {
usedObjectStatuses.clear();
}
export function clearObjectSearches() {
previousObjectSearches.clear();
}
const zoomStep = 10;
export function zoomOut() {
pageZoom.value -= zoomStep;
}
export function zoomIn() {
pageZoom.value += zoomStep;
}
// 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 isShowingObjects = React.restoreState("showing-objects", false);
export const isShowingChatInSplit = React.restoreState(
"showing-chat-split",
false
);
export const isChatOpen = React.createProxyState(
[selectedChat],
() => selectedChat.value != undefined
);
export const isChatVisible = React.createProxyState(
[isShowingObjects, isShowingChatInSplit],
() => isShowingObjects.value == false || isShowingChatInSplit.value == true
);
isShowingObjects.subscribeSilent(() => {
setTimeout(() => scrollToChat("instant"), 1);
});
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;
chat.hasUnreadMessages.value = false;
scrollToChat();
}
export function scrollToChat(behavior: ScrollBehavior = "smooth") {
document.getElementById("message-tab")?.scrollIntoView({ behavior });
}
export function toggleObjects() {
isShowingObjects.value = !isShowingObjects.value;
}
export function toggleChat() {
isShowingChatInSplit.value = !isShowingChatInSplit.value;
}
chatIds.value.forEach((id) => chats.add(new Chat(id)));
// INIT
if (serverAddressInput.value != "" && didRequestConnection.value == true) {
connect();
}
-86
View File
@@ -1,86 +0,0 @@
import * as React from "bloatless-react";
import {
closeChatView,
isChatOpen,
isChatVisible,
isShowingObjects,
selectedChat,
toggleChat,
toggleObjects,
} from "../Model/model";
import { ChatOptionModal } from "../Views/Chat/chatOptionModal";
import { MessageComposer } from "../Views/Chat/messageComposer";
import { ObjectPane } from "../Views/Objects/objectPane";
import { ThreadView } from "../Views/Chat/threadView";
import { translation } from "../translations";
export function MessageTab() {
const messageTabContent = React.createProxyState([selectedChat], () => {
if (selectedChat.value == undefined)
return (
<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.showObjects}
on:click={toggleObjects}
toggle:selected={isShowingObjects}
>
<span class="icon">deployed_code</span>
</button>
<button
aria-label={translation.showObjects} // TODO
on:click={toggleChat}
toggle:selected={isChatVisible}
>
<span class="icon">forum</span>
</button>
<button
aria-label={translation.showChatOptions}
on:click={showOptions}
>
<span class="icon">tune</span>
</button>
</span>
</header>,
ObjectPane(chat),
ThreadView(chat),
<footer>{MessageComposer(chat)}</footer>,
ChatOptionModal(chat, isShowingOptions),
];
});
return (
<article
id="message-tab"
children:set={messageTabContent}
toggle:showingobjects={isShowingObjects}
toggle:chatvisible={isChatVisible}
toggle:chatopen={isChatOpen}
></article>
);
}
-37
View File
@@ -1,37 +0,0 @@
import * as React from "bloatless-react";
import { isConnected, isEncryptionAvailable, toggleSettings, zoomIn, zoomOut } from "../Model/model";
import { ChatListSection } from "../Views/Overview/chatListSection";
import { ConnectionSection } from "../Views/Overview/connectionSection";
import { PersonalSection } from "../Views/Overview/personalSection";
import { translation } from "../translations";
export function OverviewTab() {
return (
<article id="settings-tab" toggle:connected={isConnected}>
<header>
{translation.overview}
<button aria-label={translation.showSettings} on:click={toggleSettings}>
<span class="icon">settings</span>
</button>
</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>
);
}
-175
View File
@@ -1,175 +0,0 @@
import * as React from "bloatless-react";
import { Chat } from "../../Model/chatModel";
import { closeChatView } from "../../Model/model";
import { translation } from "../../translations";
export function ChatOptionModal(chat: Chat, isPresented: React.State<boolean>) {
function closeModal() {
isPresented.value = false;
}
function deleteChat() {
chat.deleteSelf();
closeModal();
closeChatView();
}
const shouldShowKey = new React.State(false);
const inputType = React.createProxyState([shouldShowKey], () =>
shouldShowKey.value == true ? "text" : "password"
);
const secondaryChannelConverter: React.StateItemConverter<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}
set:type={inputType}
></input>
</div>
</label>
<label class="inline margin-0">
<input type="checkbox" bind:checked={shouldShowKey}></input>
{translation.showKey}
</label>
</div>
<hr></hr>
<div class="flex-column gap width-input">
<button on:click={chat.resendObjects}>
{translation.resendObjects}
<span class="icon">replay</span>
</button>
</div>
<hr></hr>
<div class="flex-column gap width-input">
<button
class="danger"
on:click={chat.clearObjects}
toggle:disabled={chat.cannotClearObjects}
>
{translation.clearObjects}
<span class="icon">deployed_code</span>
</button>
<button
class="danger"
on:click={chat.clearMessages}
toggle:disabled={chat.cannotClearMessages}
>
{translation.clearChatMessages}
<span class="icon">chat_error</span>
</button>
<button class="danger" on:click={deleteChat}>
{translation.removeChat}
<span class="icon">delete_forever</span>
</button>
</div>
</main>
<button on:click={closeModal}>
{translation.close}
<span class="icon">close</span>
</button>
</div>
</div>
);
}
-26
View File
@@ -1,26 +0,0 @@
import * as React from "bloatless-react";
import { Chat } from "../../Model/chatModel";
import { translation } from "../../translations";
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={chat.composingMessage}
on:enter={chat.sendMessageFromComposer}
></input>
<button
class="primary"
on:click={chat.sendMessageFromComposer}
toggle:disabled={chat.cannotSendMessage}
>
<span class="icon">send</span>
</button>
</div>
);
}
-146
View File
@@ -1,146 +0,0 @@
import * as React from "bloatless-react";
import { Chat, ChatMessage } from "../../Model/chatModel";
import { MessageComposer } from "./messageComposer";
import { isShowingObjects } from "../../Model/model";
import { translation } from "../../translations";
export function ThreadView(chat: Chat) {
const messageConverter: React.StateItemConverter<ChatMessage> = (message) => {
function resendMessage() {
chat.resendMessage(message);
}
function copyMessage() {
navigator.clipboard.writeText(message.body);
}
function decrypt() {
chat.decryptReceivedMessage(message);
}
function remove() {
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">
<div class="flex-row justify-apart align-center secondary">
<span class="padding-h ellipsis">
<b class="info">{message.sender}</b>
</span>
<span class="flex-row">
<button
aria-label={translation.resendMessage}
on:click={resendMessage}
toggle:disabled={chat.cannotResendMessage}
>
<span class="icon">replay</span>
</button>
<button
aria-label={translation.copyMessage}
on:click={copyMessage}
>
<span class="icon">content_copy</span>
</button>
<button
aria-label={translation.decryptMessage}
on:click={decrypt}
>
<span class="icon">key</span>
</button>
<button aria-label={translation.deleteMessage} on:click={remove}>
<span class="icon">delete</span>
</button>
</span>
</div>
<div class="flex-column padding-h padding-bottom">
<b class="break-word" subscribe:innerText={messageBody}></b>
<span class="secondary">
<b>{new Date(message.isoDate).toLocaleString()}</b>
<br></br>
{message.channel}
</span>
</div>
</div>
</div>
);
};
const outboxMessageConverter: React.StateItemConverter<ChatMessage> = (
message
) => {
function remove() {
chat.deleteOutboxMessage(message);
}
return (
<div class="tile width-100 flex-no padding-0">
<div class="flex-column">
<div class="flex-row justify-apart align-center">
<b class="error padding-h">{translation.messageInOutbox}</b>
<span class="flex-row secondary">
<button aria-label={translation.deleteMessage} on:click={remove}>
<span class="icon">delete</span>
</button>
</span>
</div>
<div class="flex-column padding-h padding-bottom">
<b class="break-word">{message.body}</b>
<span class="secondary">
<b>{new Date(message.isoDate).toLocaleString()}</b>
<br></br>
{message.channel}
</span>
</div>
</div>
</div>
);
};
const messageList = (
<div
class="flex-column gap"
children:append={[chat.messages, messageConverter]}
></div>
);
const outboxList = (
<div
class="flex-column gap"
children:append={[chat.outbox, outboxMessageConverter]}
></div>
);
const listWrapper = (
<div class="thread-view flex-column gap">
{messageList}
{outboxList}
</div>
);
function scrollToBottom() {
listWrapper.scrollTop = listWrapper.scrollHeight;
}
function scrollToBottomIfAppropriate() {
const scrollFromBottom =
listWrapper.scrollHeight -
(listWrapper.scrollTop + listWrapper.offsetHeight);
if (scrollFromBottom > 400) return;
scrollToBottom();
}
chat.messages.handleAddition(scrollToBottomIfAppropriate);
chat.outbox.handleAddition(scrollToBottomIfAppropriate);
isShowingObjects.subscribe(() => scrollToBottom());
setTimeout(() => scrollToBottom(), 50);
return listWrapper;
}
-33
View File
@@ -1,33 +0,0 @@
import * as React from "bloatless-react";
import { Chat, MessageObject, MessageObjectWithIndex } from "../../Model/chatModel";
import { ObjectGridView } from "./objectGridView";
import { translation } from "../../translations";
export function AllObjectsView(
chat: Chat,
messageObjects: React.MapState<MessageObjectWithIndex>,
selectedObject: React.State<MessageObject | undefined>,
isShowingObjectModal: React.State<boolean>
) {
const objectCount = React.createProxyState(
[chat.objects],
() => chat.objects.value.size
);
return (
<div class="width-100 height-100 scroll-v padding flex-column gap">
<b class="secondary">
{translation.objectsInTotal}{" "}
<span subscribe:innerText={objectCount}></span>
</b>
{ObjectGridView(
chat,
messageObjects,
selectedObject,
isShowingObjectModal
)}
</div>
);
}
-133
View File
@@ -1,133 +0,0 @@
import * as React from "bloatless-react";
import {
Chat,
MessageObject,
MessageObjectWithIndex,
} from "../../Model/chatModel";
import { dayInCalendar, firstDayOfWeek } from "../../Model/model";
import { DayView } from "./dayView";
import { MonthGridCell } from "./monthGridCell";
import { translation } from "../../translations";
export function CalendarView(
chat: Chat,
messageObjects: React.MapState<MessageObjectWithIndex>,
selectedObject: React.State<MessageObject | undefined>,
isShowingObjectModal: React.State<boolean>
) {
let selectedDate = new Date(dayInCalendar.value);
const selectedMonth = new React.State(selectedDate.getUTCMonth() + 1);
const selectedYear = new React.State(selectedDate.getUTCFullYear());
React.bulkSubscribe([selectedMonth, selectedYear], () =>
updateSelectedDate()
);
dayInCalendar.subscribeSilent(() => {
selectedDate = new Date(dayInCalendar.value);
});
updateSelectedDate();
function updateSelectedDate() {
selectedDate.setUTCMonth(selectedMonth.value - 1);
selectedDate.setUTCFullYear(selectedYear.value);
dayInCalendar.value = selectedDate.toISOString().split("T")[0];
}
function previousMonth() {
if (selectedMonth.value <= 1) {
selectedMonth.value = 12;
selectedYear.value -= 1;
return;
}
selectedMonth.value -= 1;
}
function nextMonth() {
if (selectedMonth.value >= 12) {
selectedMonth.value = 1;
selectedYear.value += 1;
return;
}
selectedMonth.value += 1;
}
let monthGridCells = new React.State<HTMLElement[]>([]);
React.bulkSubscribe([dayInCalendar, firstDayOfWeek], updateMonthGrid);
updateMonthGrid();
function updateMonthGrid() {
const newSelectedDate = new Date(dayInCalendar.value);
const currentDate = new Date();
monthGridCells.value = [];
// initialize to first day of month
currentDate.setUTCDate(1);
currentDate.setUTCMonth(newSelectedDate.getMonth());
// labels
let currentLabelIndex = firstDayOfWeek.value;
for (let i = 0; i < 7; i++) {
monthGridCells.value.push(
<b class="secondary ellipsis width-100">
{translation.weekdays[currentLabelIndex]}
</b>
);
currentLabelIndex++;
if (currentLabelIndex >= 7) currentLabelIndex = 0;
}
// create blank dates starting sunday
const monthOffset = currentDate.getDay() - firstDayOfWeek.value;
for (let i = 0; i < monthOffset; i++) {
monthGridCells.value.push(<div></div>);
}
// add day views
while (currentDate.getUTCMonth() == newSelectedDate.getUTCMonth()) {
monthGridCells.value.push(
MonthGridCell(chat, messageObjects, currentDate)
);
currentDate.setDate(currentDate.getDate() + 1);
}
monthGridCells.callSubscriptions();
}
return (
<div class="calendar-wrapper">
<div class="month-grid padding scroll-v">
<div class="flex-row justify-center gap">
<button
on:click={previousMonth}
aria-label={translation.previousMonth}
>
<span class="icon">arrow_back</span>
</button>
<input
style="width: 90px"
type="number"
bind:value={selectedMonth}
></input>
<input
style="width: 110px"
type="number"
bind:value={selectedYear}
></input>
<button on:click={nextMonth} aria-label={translation.nextMonth}>
<span class="icon">arrow_forward</span>
</button>
</div>
<hr></hr>
<div
class="grid gap"
style="grid-template-columns: repeat(7, 1fr)"
children:set={monthGridCells}
></div>
</div>
{DayView(chat, messageObjects, selectedObject, isShowingObjectModal)}
</div>
);
}
-51
View File
@@ -1,51 +0,0 @@
import * as React from "bloatless-react";
import { Chat, MessageObject, MessageObjectWithIndex } from "../../Model/chatModel";
import { ObjectEntryView } from "./objectEntryView";
import { dayInCalendar } from "../../Model/model";
export function DayView(
chat: Chat,
messageObjects:
| React.ListState<MessageObjectWithIndex>
| React.MapState<MessageObjectWithIndex>,
selectedObject: React.State<MessageObject | undefined>,
isShowingObjectModal: React.State<boolean>
) {
const objectsForDayView = new React.ListState<MessageObjectWithIndex>();
function processObject(messageObject: MessageObjectWithIndex) {
const latest = chat.getMostRecentContent(messageObject);
if (!latest.date || latest.date != dayInCalendar.value) return;
objectsForDayView.add(messageObject);
messageObjects.handleRemoval(messageObject, () => {
objectsForDayView.remove(messageObject);
})
}
messageObjects.handleAddition(processObject);
dayInCalendar.subscribeSilent(() => {
objectsForDayView.clear();
messageObjects.value.forEach(processObject);
})
const objectConverter: React.StateItemConverter<MessageObjectWithIndex> = (
messageObject
) => {
return ObjectEntryView(
chat,
messageObject,
selectedObject,
isShowingObjectModal
);
};
return (
<div
class="day-view padding flex-column gap scroll-v"
children:append={[objectsForDayView, objectConverter]}
></div>
);
}
-141
View File
@@ -1,141 +0,0 @@
import * as React from "bloatless-react";
import { Chat, MessageObject, MessageObjectWithIndex } from "../../Model/chatModel";
import { ObjectEntryView } from "./objectEntryView";
import { RenameView } from "../renameView";
interface KanbanBoard {
category: string;
items: React.ListState<KanbanBoardItem>;
}
interface KanbanBoardItem {
priority: number;
messageObject: MessageObjectWithIndex;
}
export function KanbanView(
chat: Chat,
messageObjects: React.MapState<MessageObjectWithIndex>,
selectedObject: React.State<MessageObject | undefined>,
isShowingObjectModal: React.State<boolean>
) {
const boards = new React.MapState<KanbanBoard>();
const sortedBoardNames = React.createProxyState([boards], () =>
[...boards.value.values()]
.map((board) => board.category)
.sort((a, b) => a.localeCompare(b))
);
messageObjects.handleAddition((messageObject) => {
const latest = chat.getMostRecentContent(messageObject);
if (!latest.categoryName) return;
const categoryName = latest.categoryName!;
if (!boards.value.has(categoryName)) {
const listState = new React.ListState<KanbanBoardItem>();
listState.subscribeSilent(() => {
if (listState.value.size != 0) return;
boards.remove(categoryName);
});
boards.set(categoryName, {
category: categoryName,
items: listState,
});
}
const boardItem: KanbanBoardItem = {
priority: latest.priority ?? 0,
messageObject,
};
boards.value.get(categoryName)?.items.add(boardItem);
messageObjects.handleRemoval(messageObject, () => {
boards.value.get(categoryName)?.items.remove(boardItem);
});
});
const boardToBoardView: React.StateItemConverter<KanbanBoard> = (board) => {
const view = KanbanBoardView(
chat,
board,
selectedObject,
isShowingObjectModal
);
sortedBoardNames.subscribe((sortedBoardNames) => {
view.style.order = sortedBoardNames.indexOf(board.category);
});
return view;
};
return (
<div
class="flex-row large-gap width-100 height-100 scroll-v scroll-h padding"
children:append={[boards, boardToBoardView]}
></div>
);
}
function KanbanBoardView(
chat: Chat,
kanbanBoard: KanbanBoard,
selectedObject: React.State<MessageObject | undefined>,
isShowingObjectModal: React.State<boolean>
) {
const editingCategory = new React.State(kanbanBoard.category);
function renameBoard() {
kanbanBoard.items.value.forEach((kanbanBoardItem) => {
const { messageObject } = kanbanBoardItem;
const latest = chat.getMostRecentContent(messageObject);
latest.categoryName = editingCategory.value;
chat.addObjectAndSend(messageObject);
});
}
function dragOver(event: DragEvent) {
event.preventDefault();
}
function drop(event: DragEvent) {
event.preventDefault();
var id = event.dataTransfer?.getData("text");
if (!id) return;
const messageObject = chat.objects.value.get(id);
if (!messageObject) return;
const newContent = chat.createObjectContent();
newContent.categoryName = kanbanBoard.category;
chat.updateObjectContent(messageObject, newContent);
chat.addObjectAndSend(messageObject);
}
const itemToViewEntry: React.StateItemConverter<KanbanBoardItem> = (
kanbanBoardItem
) => {
return ObjectEntryView(
chat,
kanbanBoardItem.messageObject,
selectedObject,
isShowingObjectModal
);
};
return (
<div
class="flex-column flex-no object-entry-wide"
on:dragover={dragOver}
on:drop={drop}
>
{RenameView(editingCategory, kanbanBoard.category, renameBoard)}
<hr></hr>
<div
class="flex-column gap padding-bottom"
children:append={[kanbanBoard.items, itemToViewEntry]}
></div>
</div>
);
}
-94
View File
@@ -1,94 +0,0 @@
import * as React from "bloatless-react";
import { Chat, MessageObject, MessageObjectWithIndex } from "../../Model/chatModel";
import { dayInCalendar } from "../../Model/model";
import { getRawObjectIndex } from "../../utility";
export function MonthGridCell(
chat: Chat,
messageObjects:
| React.ListState<MessageObjectWithIndex>
| React.MapState<MessageObjectWithIndex>,
dateObject: Date
) {
const objectsForDayView = new React.ListState<MessageObject>();
const dateString = dateObject.toISOString().split("T")[0];
function processObject(messageObject: MessageObjectWithIndex) {
const latest = chat.getMostRecentContent(messageObject);
if (!latest.date || latest.date != dateString) return;
objectsForDayView.add(messageObject);
messageObjects.handleRemoval(messageObject, () => {
objectsForDayView.remove(messageObject);
});
}
function select() {
dayInCalendar.value = dateString;
}
const isSelected = React.createProxyState(
[dayInCalendar],
() => dayInCalendar.value == dateString
);
const today = new Date();
const isToday =
today.getDate() == dateObject.getUTCDate() &&
today.getMonth() == dateObject.getUTCMonth() &&
today.getFullYear() == dateObject.getUTCFullYear();
messageObjects.handleAddition(processObject);
const objectConverter: React.StateItemConverter<MessageObjectWithIndex> = (
messageObject
) => {
const view = (
<span class="secondary ellipsis">
{chat.getObjectTitle(messageObject)}
</span>
);
messageObject.index.subscribe(newIndex => {
view.style.order = newIndex;
})
return view;
};
function dragOver(event: DragEvent) {
event.preventDefault();
}
function drop(event: DragEvent) {
event.preventDefault();
var id = event.dataTransfer?.getData("text");
if (!id) return;
const messageObject = chat.objects.value.get(id);
if (!messageObject) return;
const newContent = chat.createObjectContent();
newContent.date = dateString;
chat.updateObjectContent(messageObject, newContent);
chat.addObjectAndSend(messageObject);
}
return (
<button
on:click={select}
on:dragover={dragOver}
on:drop={drop}
toggle:selected={isSelected}
toggle:today={isToday}
class="day-miniature tile flex-column align-start"
style="aspect-ratio: 1/1; overflow: hidden"
>
<h3 class="margin-0">{dateObject.getUTCDate()}</h3>
<div
class="flex-column gap"
children:append={[objectsForDayView, objectConverter]}
></div>
</button>
);
}
-27
View File
@@ -1,27 +0,0 @@
import * as React from "bloatless-react";
import { Chat, MessageObject, MessageObjectWithIndex } from "../../Model/chatModel";
import { ObjectGridView } from "./objectGridView";
export function NoteObjectsView(
chat: Chat,
messageObjects: React.MapState<MessageObjectWithIndex>,
selectedObject: React.State<MessageObject | undefined>,
isShowingObjectModal: React.State<boolean>
) {
const notes = new React.ListState<MessageObjectWithIndex>();
messageObjects.handleAddition((messageObject) => {
const latest = chat.getMostRecentContent(messageObject);
if (!latest.noteContent) return;
notes.add(messageObject);
messageObjects.handleRemoval(messageObject, () =>
notes.remove(messageObject)
);
});
return <div class="width-100 height-100 scroll-v padding">
{ObjectGridView(chat, notes, selectedObject, isShowingObjectModal)}
</div>;
}
-256
View File
@@ -1,256 +0,0 @@
import * as React from "bloatless-react";
import { Chat, MessageObject } from "../../Model/chatModel";
import { usedObjectCategories, usedObjectStatuses } from "../../Model/model";
import { icons } from "../../icons";
import { stringToOptionTag } from "../../utility";
import { translation } from "../../translations";
export function ObjectDetailModal(
chat: Chat,
messageObject: MessageObject,
isPresented: React.State<boolean>
) {
// state
const editingTitle = new React.State(messageObject.title);
const selectedMessageObjectId = new React.State(
chat.getMostRecentContentId(messageObject)
);
const selectedMessageObject = React.createProxyState(
[selectedMessageObjectId],
() =>
chat.getObjectContentFromId(messageObject, selectedMessageObjectId.value)
);
// properties
const didEditContent = new React.State(false);
const editingNoteContent = React.createProxyState(
[selectedMessageObject],
() => selectedMessageObject.value.noteContent ?? ""
);
const editingCategory = React.createProxyState(
[selectedMessageObject],
() => selectedMessageObject.value.categoryName ?? ""
);
const editingStatus = React.createProxyState(
[selectedMessageObject],
() => selectedMessageObject.value.status ?? ""
);
const editingDate = React.createProxyState(
[selectedMessageObject],
() => selectedMessageObject.value.date ?? ""
);
const editingTime = React.createProxyState(
[selectedMessageObject],
() => selectedMessageObject.value.time ?? ""
);
const editingPriority = React.createProxyState(
[selectedMessageObject],
() => selectedMessageObject.value.priority ?? 0
);
React.bulkSubscribe(
[
editingNoteContent,
editingCategory,
editingStatus,
editingDate,
editingTime,
editingPriority,
],
() => (didEditContent.value = true)
);
// methods
function handleKeyDown(e: KeyboardEvent) {
if (!e.metaKey && !e.ctrlKey) return;
switch (e.key) {
case "s":
e.preventDefault();
saveAndClose();
break;
}
}
function closeModal() {
isPresented.value = false;
}
function saveAndClose() {
messageObject.title = editingTitle.value;
if (didEditContent.value == true) {
chat.addObjectContent(messageObject, {
isoDateVersionCreated: new Date().toISOString(),
id: React.UUID(),
noteContent: editingNoteContent.value,
priority: editingPriority.value,
categoryName: editingCategory.value,
status: editingStatus.value,
date: editingDate.value,
time: editingTime.value,
});
}
chat.addObjectAndSend(messageObject);
closeModal();
}
function deleteAndClose() {
chat.deleteObject(messageObject);
closeModal();
}
const input = (
<input
bind:value={editingTitle}
placeholder={translation.objectTitlePlaceholder}
></input>
);
isPresented.subscribe(() => setTimeout(() => input.focus(), 100));
return (
<div class="modal" toggle:open={isPresented} on:keydown={handleKeyDown}>
<div>
<main>
<h2>{chat.getObjectTitle(messageObject)}</h2>
<span class="secondary">{messageObject.id}</span>
<hr></hr>
<div class="flex-column gap">
<label class="tile">
<span class="icon">{icons.objectTitle}</span>
<div>
<span>{translation.objectTitle}</span>
{input}
</div>
</label>
<hr></hr>
<label class="tile">
<span class="icon">{icons.objectHistory}</span>
<div>
<span>{translation.objectVersion}</span>
<select bind:value={selectedMessageObjectId}>
{...chat
.getSortedContents(messageObject)
.map((content) => (
<option value={content.id}>
{new Date(
content.isoDateVersionCreated
).toLocaleString()}
</option>
))}
</select>
<span class="icon">arrow_drop_down</span>
</div>
</label>
<hr></hr>
<label class="tile">
<span class="icon">{icons.noteContent}</span>
<div>
<span>{translation.note}</span>
<textarea
rows="10"
class="height-auto"
bind:value={editingNoteContent}
placeholder={translation.noteContentPlaceholder}
></textarea>
</div>
</label>
<hr></hr>
<label class="tile">
<span class="icon">{icons.categoryName}</span>
<div>
<span>{translation.category}</span>
<input
bind:value={editingCategory}
placeholder={translation.categoryPlaceholder}
list="object-categories"
></input>
</div>
</label>
<datalist
hidden
id="object-categories"
children:append={[usedObjectCategories, stringToOptionTag]}
></datalist>
<label class="tile">
<span class="icon">{icons.priority}</span>
<div>
<span>{translation.priority}</span>
<input
type="number"
bind:value={editingPriority}
placeholder={translation.priorityPlaceholder}
></input>
</div>
</label>
<label class="tile">
<span class="icon">{icons.status}</span>
<div>
<span>{translation.status}</span>
<input
bind:value={editingStatus}
placeholder={translation.statusPlaceholder}
list="object-statuses"
></input>
</div>
</label>
<datalist
hidden
id="object-statuses"
children:append={[usedObjectStatuses, stringToOptionTag]}
></datalist>
<hr></hr>
<label class="tile">
<span class="icon">{icons.date}</span>
<div>
<span>{translation.date}</span>
<input type="date" bind:value={editingDate}></input>
</div>
</label>
<label class="tile">
<span class="icon">{icons.time}</span>
<div>
<span>{translation.time}</span>
<input type="time" bind:value={editingTime}></input>
</div>
</label>
<hr></hr>
<button class="danger width-input" on:click={deleteAndClose}>
{translation.deleteObject}
<span class="icon">delete</span>
</button>
</div>
</main>
<div class="flex-row">
<button class="flex-1 width-100 danger" on:click={closeModal}>
{translation.discard}
</button>
<button class="flex-1 width-100 primary" on:click={saveAndClose}>
{translation.save}
<span class="icon">save</span>
</button>
</div>
</div>
</div>
);
}
-81
View File
@@ -1,81 +0,0 @@
import * as React from "bloatless-react";
import {
Chat,
MessageObject,
MessageObjectContent,
MessageObjectWithIndex,
} from "../../Model/chatModel";
import { icons } from "../../icons";
export function ObjectEntryView(
chat: Chat,
messageObject: MessageObjectWithIndex,
selectedObject: React.State<MessageObject | undefined>,
isShowingObjectModal: React.State<boolean>
) {
const latest = chat.getMostRecentContent(messageObject);
function select() {
selectedObject.value = messageObject;
isShowingObjectModal.value = true;
}
function dragStart(event: DragEvent) {
event.dataTransfer?.setData("text", messageObject.id);
}
const fields = {
[icons.noteContent]: "---",
[icons.priority]: "---",
[icons.categoryName]: "---",
[icons.status]: "---",
[icons.date]: "---",
[icons.time]: "---",
};
Object.entries(latest).forEach((entry) => {
let [key, value] = entry as [keyof MessageObjectContent, string];
if (!value) return;
const icon = icons[key];
if (!icon) return;
if (!fields[icon]) return;
fields[icon] = value;
});
const fieldElements = Object.entries(fields).map((field) => {
const [icon, value] = field;
return (
<span class="flex-row control-gap flex align-center padding-right ellipsis">
<span class="icon">{icon}</span>
<span class="ellipsis">{value}</span>
</span>
);
});
const view = (
<button
class="tile flex-no"
on:click={select}
draggable="true"
on:dragstart={dragStart}
>
<div>
<b class="ellipsis">{chat.getObjectTitle(messageObject)}</b>
<hr></hr>
<span
class="grid height-100 flex secondary"
style="grid-template-columns: 1fr 1fr; grid-template-rows: repeat(3, 1.7rem)"
>
{...fieldElements}
</span>
</div>
</button>
);
messageObject.index.subscribe(newIndex => {
view.style.order = newIndex;
})
return view;
}
-33
View File
@@ -1,33 +0,0 @@
import * as React from "bloatless-react";
import { Chat, MessageObject, MessageObjectWithIndex } from "../../Model/chatModel";
import { ObjectEntryView } from "./objectEntryView";
export function ObjectGridView(
chat: Chat,
messageObjects:
| React.ListState<MessageObjectWithIndex>
| React.MapState<MessageObjectWithIndex>,
selectedObject: React.State<MessageObject | undefined>,
isShowingObjectModal: React.State<boolean>
) {
const objectConverter: React.StateItemConverter<MessageObjectWithIndex> = (
messageObject
) => {
return ObjectEntryView(
chat,
messageObject,
selectedObject,
isShowingObjectModal
);
};
return (
<div
class="grid gap"
style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));"
children:prepend={[messageObjects, objectConverter]}
></div>
);
}
-296
View File
@@ -1,296 +0,0 @@
import * as React from "bloatless-react";
import {
Chat,
MessageObject,
MessageObjectWithIndex,
} from "../../Model/chatModel";
import { getRawObjectIndex, stringToOptionTag } from "../../utility";
import { objectFilterInput, previousObjectSearches } from "../../Model/model";
import { AllObjectsView } from "./allObjectsView";
import { CalendarView } from "./calendarView";
import { KanbanView } from "./kanbanView";
import { NoteObjectsView } from "./noteObjectsView";
import { ObjectDetailModal } from "./objectDetailModal";
import { ObjectGridView } from "./objectGridView";
import { StatusView } from "./statusView";
import { icons } from "../../icons";
import { translation } from "../../translations";
export const viewTypes = {
all: [translation.viewAll, "grid_view"],
notes: [translation.viewNotes, icons.noteContent],
calendar: [translation.viewCalendar, icons.date],
kanban: [translation.viewKanban, "view_kanban"],
status: [translation.viewStatus, icons.status],
};
export function ObjectPane(chat: Chat) {
// selection and modal
const isShowingObjectModal = new React.State(false);
const isShowingFilterModel = new React.State(false);
const selectedObject = new React.State<MessageObject | undefined>(undefined);
// methods
function createObject() {
const newObject = chat.createObjectFromTitle("");
chat.addObjectAndSend(newObject);
selectedObject.value = newObject;
isShowingObjectModal.value = true;
}
function showFilters() {
isShowingFilterModel.value = true;
}
function closeFilters() {
isShowingFilterModel.value = false;
}
// filter
const appliedFilter = new React.State("");
const resultCount = new React.State(0);
const resultText = React.createProxyState([appliedFilter, resultCount], () =>
translation.searchTitleText(appliedFilter.value, resultCount.value)
);
const visibleMessageObjects = new React.MapState<MessageObjectWithIndex>();
const isFilterEmpty = React.createProxyState(
[appliedFilter],
() => appliedFilter.value == ""
);
function resetFilter() {
objectFilterInput.value = "";
applyFilter();
}
function applyFilter() {
appliedFilter.value = objectFilterInput.value;
if (!previousObjectSearches.value.has(appliedFilter.value))
previousObjectSearches.add(appliedFilter.value);
const allObjects = [...chat.objects.value.values()];
allObjects.forEach((messageObject, i) => {
const doesMatch = checkIfMatchesFilter(messageObject);
if (doesMatch) {
visibleMessageObjects.set(messageObject.id, new MessageObjectWithIndex(messageObject));
} else {
visibleMessageObjects.remove(messageObject.id);
}
});
resultCount.value = visibleMessageObjects.value.size;
}
chat.objects.handleAddition((messageObject) => {
if (checkIfMatchesFilter(messageObject) == false) return;
visibleMessageObjects.set(messageObject.id, new MessageObjectWithIndex(messageObject));
chat.objects.handleRemoval(messageObject, () => {
visibleMessageObjects.remove(messageObject.id);
});
});
function checkIfMatchesFilter(messageObject: MessageObject) {
if (isFilterEmpty.value == true) return true;
const stringsInObject: string[] = [];
const objectTitle = messageObject.title || translation.untitledObject;
stringsInObject.push(objectTitle);
const latest = chat.getMostRecentContent(messageObject);
if (latest) {
Object.values(latest).forEach((value) => {
if (typeof value != "string") return;
stringsInObject.push(value);
});
}
const wordsOfObject = stringsInObject
.map((string) => string.toLowerCase().split(" "))
.flat();
const wordsOfSearchTerm = appliedFilter.value.toLowerCase().split(" ");
for (const word of wordsOfSearchTerm) {
if (word[0] == "-") {
// exclusion
const wordContent = word.substring(1);
if (wordsOfObject.includes(wordContent)) return false;
} else {
return wordsOfObject.includes(word);
}
}
return true;
}
applyFilter();
// sorting
visibleMessageObjects.subscribe((visibleMessageObjects) => {
let indices = [...visibleMessageObjects.values()]
.map((messageObject) => {
const latest = chat.getMostRecentContent(messageObject);
return getRawObjectIndex(latest);
})
.sort();
visibleMessageObjects.forEach((messageObject) => {
const latest = chat.getMostRecentContent(messageObject);
messageObject.index.value = indices.indexOf(getRawObjectIndex(latest));
});
});
// view
const objectModal = React.createProxyState(
[chat.objects, selectedObject],
() => {
if (selectedObject.value == undefined) return <div></div>;
return ObjectDetailModal(
chat,
selectedObject.value,
isShowingObjectModal
);
}
);
const mainView = React.createProxyState([chat.viewType], () => {
function getViewFunction() {
switch (chat.viewType.value) {
case "notes":
return NoteObjectsView;
case "kanban":
return KanbanView;
case "status":
return StatusView;
case "calendar":
return CalendarView;
default:
return AllObjectsView;
}
}
return getViewFunction()(
chat,
visibleMessageObjects,
selectedObject,
isShowingObjectModal
);
});
return (
<div class="chat-object-view flex-column scroll-no padding-0">
<div class="flex-row align-center border-bottom">
<button
class="primary height-100"
on:click={createObject}
aria-label={translation.createObject}
>
<span class="icon">add</span>
</button>
<div class="padding-sm flex flex-row gap justify-center scroll-h width-100">
{...Object.keys(viewTypes).map((key: any) =>
ViewTypeToggle(key, chat.viewType)
)}
</div>
<button
class="height-100"
aria-label={translation.filterObjects}
on:click={showFilters}
>
<span class="icon">filter_list</span>
</button>
</div>
<div
class="object-content width-100 height-100 flex scroll-no"
children:set={mainView}
></div>
<div class="modal" toggle:open={isShowingFilterModel}>
<div style="max-width: 2084px">
<main class="gap">
<h2>{translation.filterObjects}</h2>
<div class="flex-column">
<label class="tile">
<span class="icon">search</span>
<div>
<span>{translation.searchTitle}</span>
<input
bind:value={objectFilterInput}
on:enter={applyFilter}
placeholder={translation.searchPlaceholder}
list="object-searches"
></input>
<datalist
hidden
id="object-searches"
children:append={[
previousObjectSearches,
stringToOptionTag,
]}
></datalist>
</div>
</label>
</div>
<div class="flex-row width-input">
<button
class="width-50 flex"
on:click={resetFilter}
toggle:disabled={isFilterEmpty}
>
{translation.reset}
</button>
<button class="width-50 flex primary" on:click={applyFilter}>
{translation.search}
<span class="icon">arrow_forward</span>
</button>
</div>
<hr></hr>
<b class="secondary" subscribe:innerText={resultText}></b>
{ObjectGridView(
chat,
visibleMessageObjects,
selectedObject,
isShowingObjectModal
)}
</main>
<button on:click={closeFilters}>
{translation.close}
<span class="icon">close</span>
</button>
</div>
</div>
<div children:set={objectModal}></div>
</div>
);
}
function ViewTypeToggle(
key: keyof typeof viewTypes,
selection: React.State<keyof typeof viewTypes>
) {
const [label, icon] = viewTypes[key];
function select() {
selection.value = key;
}
const isSelected = React.createProxyState(
[selection],
() => selection.value == key
);
return (
<button aria-label={label} on:click={select} toggle:selected={isSelected}>
<span class="icon">{icon}</span>
</button>
);
}
-9
View File
@@ -1,9 +0,0 @@
import * as React from "bloatless-react";
import { translation } from "../../translations";
export function PlaceholderView() {
return <div class="flex-column width-100 height-100 align-center justify-center secondary">
{translation.noObjects}
</div>;
}
-258
View File
@@ -1,258 +0,0 @@
import * as React from "bloatless-react";
import { Chat, MessageObject, MessageObjectWithIndex } from "../../Model/chatModel";
import { ObjectEntryView } from "./objectEntryView";
import { RenameView } from "../renameView";
interface CategoryRow {
category: string;
statusColumns: React.MapState<StatusColumn>;
}
interface StatusColumn {
status: string;
items: React.ListState<StatusCellItem>;
}
interface StatusCellItem {
priority: number;
status: string;
messageObject: MessageObjectWithIndex;
}
// data only
interface StatusData {
title: string;
items: MessageObject[];
}
export function StatusView(
chat: Chat,
messageObjects: React.MapState<MessageObjectWithIndex>,
selectedObject: React.State<MessageObject | undefined>,
isShowingObjectModal: React.State<boolean>
) {
const rows = new React.MapState<CategoryRow>();
const statuses = new React.MapState<StatusData>();
const sortedStatuses = React.createProxyState([statuses], () =>
[...statuses.value.values()]
.map((statusData) => statusData.title)
.sort((a, b) => a.localeCompare(b))
);
const sortedCategoryNames = React.createProxyState([rows], () =>
[...rows.value.values()]
.map((row) => row.category)
.sort((a, b) => a.localeCompare(b))
);
messageObjects.subscribe(() => {
rows.clear();
statuses.clear();
// add items
messageObjects.value.forEach((messageObject) => {
const latest = chat.getMostRecentContent(messageObject);
if (!latest.categoryName || !latest.status) return;
const category = latest.categoryName;
const status = latest.status;
if (!statuses.value.has(status)) {
statuses.set(status, { title: status, items: [] });
}
statuses.value.get(status)!.items.push(messageObject);
if (!rows.value.has(category)) {
rows.set(category, {
category: category,
statusColumns: new React.MapState(),
});
}
const row = rows.value.get(category)!;
if (!row.statusColumns.value.has(status)) {
row.statusColumns.set(status, {
status: status,
items: new React.ListState(),
});
}
const column = row.statusColumns.value.get(status)!;
column.items.add({
priority: latest.priority ?? 0,
status,
messageObject,
});
});
// add status placeholders
statuses.value.forEach((statusItemList) => {
rows.value.forEach((row) => {
if (row.statusColumns.value.has(statusItemList.title)) return;
row.statusColumns.set(statusItemList.title, {
status: statusItemList.title,
items: new React.ListState(),
});
});
});
});
const statusDataToHeaderView: React.StateItemConverter<StatusData> = (
statusData
) => {
const view = StatusHeaderView(chat, statusData);
sortedStatuses.subscribe(
() => (view.style.order = sortedStatuses.value.indexOf(statusData.title))
);
return view;
};
const categoryRowToView: React.StateItemConverter<CategoryRow> = (row) => {
const view = KanbanRowView(
chat,
row,
sortedStatuses,
selectedObject,
isShowingObjectModal
);
sortedCategoryNames.subscribe(
() => (view.style.order = sortedCategoryNames.value.indexOf(row.category))
);
return view;
};
return (
<div class="flex-column large-gap width-100 height-100 scroll-v scroll-h padding">
<div class="flex-row large-gap">
<div class="flex-column object-entry-wide"></div>
<div
class="flex-row large-gap"
children:append={[statuses, statusDataToHeaderView]}
></div>
</div>
<div
class="flex-column large-gap"
children:append={[rows, categoryRowToView]}
></div>
</div>
);
}
function StatusHeaderView(chat: Chat, status: StatusData) {
const editingStatus = new React.State(status.title);
function rename() {
status.items.forEach((messageObject) => {
const latest = chat.getMostRecentContent(messageObject);
latest.status = editingStatus.value;
chat.addObject(messageObject);
});
}
return (
<div class="object-entry-wide">
{RenameView(editingStatus, status.title, rename)}
<hr></hr>
</div>
);
}
function KanbanRowView(
chat: Chat,
kanbanRow: CategoryRow,
sortedStatuses: React.State<string[]>,
selectedObject: React.State<MessageObject | undefined>,
isShowingObjectModal: React.State<boolean>
) {
const editingCategory = new React.State(kanbanRow.category);
function renameCategory() {
kanbanRow.statusColumns.value.forEach((statusColumns) => {
statusColumns.items.value.forEach((statusCellItem) => {
const { messageObject } = statusCellItem;
const latest = chat.getMostRecentContent(messageObject);
latest.categoryName = editingCategory.value;
chat.addObjectAndSend(messageObject);
});
});
}
const statusColumnToEntryView: React.StateItemConverter<StatusColumn> = (
statusColumn
) => {
const view = StatusColumnView(
chat,
statusColumn.items,
kanbanRow.category,
statusColumn.status,
selectedObject,
isShowingObjectModal
);
sortedStatuses.subscribe(
() =>
(view.style.order = sortedStatuses.value.indexOf(statusColumn.status))
);
return view;
};
return (
<div class="flex-row flex-no large-gap">
<div class="flex-row align-start">
{RenameView(editingCategory, kanbanRow.category, renameCategory)}
</div>
<div
class="flex-row large-gap"
children:append={[kanbanRow.statusColumns, statusColumnToEntryView]}
></div>
</div>
);
}
function StatusColumnView(
chat: Chat,
items: React.ListState<StatusCellItem>,
category: string,
status: string,
selectedObject: React.State<MessageObject | undefined>,
isShowingObjectModal: React.State<boolean>
) {
function dragOver(event: DragEvent) {
event.preventDefault();
}
function drop(event: DragEvent) {
event.preventDefault();
var id = event.dataTransfer?.getData("text");
if (!id) return;
const messageObject = chat.objects.value.get(id);
if (!messageObject) return;
const newContent = chat.createObjectContent();
newContent.categoryName = category;
newContent.status = status;
chat.updateObjectContent(messageObject, newContent);
chat.addObjectAndSend(messageObject);
}
const cellItemToEntryView: React.StateItemConverter<StatusCellItem> = (
cellItem
) =>
ObjectEntryView(
chat,
cellItem.messageObject,
selectedObject,
isShowingObjectModal
);
return (
<div
class="flex-column gap object-entry-wide"
on:dragover={dragOver}
on:drop={drop}
children:append={[items, cellItemToEntryView]}
></div>
);
}
-75
View File
@@ -1,75 +0,0 @@
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.StateItemConverter<Chat> = (chat) => {
function select() {
selectChat(chat);
}
const isSelected = React.createProxyState(
[selectedChat],
() => selectedChat.value == chat
);
return (
<button
class="tile"
on:click={select}
toggle:selected={isSelected}
toggle:unread={chat.hasUnreadMessages}
>
<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>
);
}
-131
View File
@@ -1,131 +0,0 @@
import * as React from "bloatless-react";
import {
cannotConnect,
cannotDeleteMailbox,
cannotDisonnect,
cannotRequestMailbox,
cannotResetAddress,
connect,
deleteMailbox,
disconnect,
outboxText,
outboxTextStyle,
previousAddresses,
requestMailbox,
resetAddressInput,
serverAddressInput,
} from "../../Model/model";
import { stringToOptionTag } from "../../utility";
import { translation } from "../../translations";
export function ConnectionSection() {
const stringToPreviouAddressOption: React.StateItemConverter<string> = (
value
) => {
const isSelected = value == serverAddressInput.value;
return <option toggle:selected={isSelected}>{value}</option>;
};
return (
<div class="flex-column">
<h2>{translation.connection}</h2>
<label class="tile">
<span class="icon">cell_tower</span>
<div>
<span>{translation.serverAddress}</span>
<input
bind:value={serverAddressInput}
on:enter={connect}
placeholder={translation.serverAddressPlaceholder}
list="previous-addresses"
></input>
<datalist
hidden
id="previous-addresses"
children:append={[previousAddresses, stringToOptionTag]}
></datalist>
</div>
</label>
<label class="tile">
<span class="icon">history</span>
<div>
<span>{translation.previousConnections}</span>
<select
bind:value={serverAddressInput}
children:append={[previousAddresses, stringToPreviouAddressOption]}
></select>
<span class="icon">arrow_drop_down</span>
</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>
<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>
<hr></hr>
<div class="tile">
<span class="icon">outbox</span>
<div>
<b>{translation.outbox}</b>
<span
set:class={outboxTextStyle}
subscribe:innerText={outboxText}
></span>
</div>
</div>
</div>
);
}
-21
View File
@@ -1,21 +0,0 @@
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>
);
}
-28
View File
@@ -1,28 +0,0 @@
import * as React from "bloatless-react";
import { translation } from "../translations";
export function RenameView(
editingName: React.State<string>,
initialName: string,
rename: () => void
) {
const cannotRename = React.createProxyState(
[editingName],
() => editingName.value == initialName || editingName.value == ""
);
return (
<div class="flex-row align-center justify-apart object-entry-wide">
<input bind:value={editingName} on:enter={rename}></input>
<button
class="primary"
aria-label={translation.rename}
on:click={rename}
toggle:disabled={cannotRename}
>
<span class="icon">edit</span>
</button>
</div>
);
}
-97
View File
@@ -1,97 +0,0 @@
import * as React from "bloatless-react";
import {
clearAddresses,
clearCategories,
clearObjectSearches,
clearStatuses,
firstDayOfWeek,
isPresentingSettingsModal,
repairApp,
toggleSettings,
zoomIn,
zoomOut,
} from "../Model/model";
import { icons } from "../icons";
import { translation } from "../translations";
export function SettingsModal() {
const stringToWeekdayOption: React.StateItemConverter<number> = (value) => {
const isSelected = value == firstDayOfWeek.value;
return <option toggle:selected={isSelected} value={value}>{translation.weekdays[value]}</option>;
};
return (
<div class="modal" toggle:open={isPresentingSettingsModal}>
<div>
<main>
<h2>{translation.settings}</h2>
<label class="tile">
<span class="icon">calendar_month</span>
<div>
<span>{translation.firstDayOfWeek}</span>
<select bind:value={firstDayOfWeek}>
{...translation.weekdays.map((weekday, i) =>
stringToWeekdayOption(i)
)}
</select>
<span class="icon">arrow_drop_down</span>
</div>
</label>
<hr></hr>
<div class="flex-row width-input gap">
<button class="width-100 flex justify-start" on:click={zoomOut}>
<span class="icon">zoom_out</span>
{translation.zoomOut}
</button>
<button class="width-100 flex justify-start" on:click={zoomIn}>
<span class="icon">zoom_in</span>
{translation.zoomIn}
</button>
</div>
<hr></hr>
<div class="flex-column width-input">
<button class="tile width-100 flex-1" on:click={repairApp}>
<span class="icon">handyman</span>
{translation.repairApp}
</button>
</div>
<hr></hr>
<div class="flex-column width-input gap">
<button class="tile width-100 flex-1" on:click={clearAddresses}>
<span class="icon">cell_tower</span>
{translation.clearAddresses}
</button>
<button class="tile width-100 flex-1" on:click={clearCategories}>
<span class="icon">{icons.categoryName}</span>
{translation.clearCategories}
</button>
<button class="tile width-100 flex-1" on:click={clearStatuses}>
<span class="icon">{icons.status}</span>
{translation.clearStatuses}
</button>
<button
class="tile width-100 flex-1"
on:click={clearObjectSearches}
>
<span class="icon">search</span>
{translation.clearObjectFilters}
</button>
</div>
</main>
<button class="width-100" on:click={toggleSettings}>
{translation.close}
<span class="icon">close</span>
</button>
</div>
</div>
);
}
-115
View File
@@ -1,115 +0,0 @@
const IV_SIZE = 12;
const ENCRYPTION_ALG = "AES-GCM";
// minimal
export async function encryptString(
plaintext: string,
passphrase: string
): Promise<string> {
if (!window.crypto.subtle) return plaintext;
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);
}
export function decode(array: Uint8Array): string {
return new TextDecoder("utf-8").decode(array);
}
export async function encrypt(
iv: Uint8Array,
key: CryptoKey,
message: string
): Promise<Uint8Array> {
const arrayBuffer = await window.crypto.subtle.encrypt(
{ name: ENCRYPTION_ALG, iv },
key,
encode(message)
);
return new Uint8Array(arrayBuffer);
}
export async function decrypt(
iv: Uint8Array,
key: CryptoKey,
cyphertext: Uint8Array
): Promise<string> {
const arrayBuffer = await crypto.subtle.decrypt(
{ name: ENCRYPTION_ALG, iv },
key,
cyphertext
);
return arrayBufferToString(arrayBuffer);
}
export async function hash(encoded: Uint8Array): Promise<ArrayBuffer> {
return await crypto.subtle.digest("SHA-256", encoded);
}
// iv
export function generateIV(): Uint8Array {
return crypto.getRandomValues(new Uint8Array(IV_SIZE));
}
export function splitMergedString(mergedString: string): [Uint8Array, string] {
const iv = mergedString.slice(0, IV_SIZE);
const encryptedString = mergedString.slice(IV_SIZE);
return [JSON.parse(iv), encryptedString];
}
// key
export async function importKey(
passphrase: string,
purpose: "encrypt" | "decrypt"
): Promise<CryptoKey> {
return await crypto.subtle.importKey(
"raw",
await hash(encode(passphrase)),
{ name: ENCRYPTION_ALG },
false,
[purpose]
);
}
// conversion
export function arrayBufferToString(arrayBuffer: ArrayBuffer): string {
const uInt8Array = new Uint8Array(arrayBuffer);
return decode(uInt8Array);
}
export function uInt8ToArray(uInt8Array: Uint8Array): number[] {
return Array.from(uInt8Array);
}
export function arrayToUint8(array: number[]): Uint8Array {
return new Uint8Array(array);
}
-11
View File
@@ -1,11 +0,0 @@
export const icons = {
objectTitle: "label",
objectHistory: "history",
noteContent: "sticky_note_2",
categoryName: "category",
status: "trending_up",
priority: "priority_high",
date: "calendar_month",
time: "schedule",
}
-10
View File
@@ -1,10 +0,0 @@
import "./styles.css";
import { MessageTab } from "./Tabs/messageTab";
import { OverviewTab } from "./Tabs/overviewTab";
import { SettingsModal } from "./Views/settingsModal";
document
.querySelector("main")!
.append(OverviewTab(), MessageTab(), SettingsModal());
document.querySelector("main")!.classList.add("split");
-173
View File
@@ -1,173 +0,0 @@
[unread] {
border-left: 10px solid var(--text-warning);
}
/* OBJECTS */
:root {
--split-thread-height: 8rem;
--split-thread-width: 20rem;
}
.object-entry-wide {
width: 320px;
min-width: 320px;
}
#message-tab {
flex-direction: column;
}
/* standard */
.chat-object-view {
display: none;
}
/* objects */
[showingobjects] header {
border-bottom: 1px solid var(--border);
}
[showingobjects] footer {
display: none;
}
[showingobjects] .chat-object-view {
display: flex;
top: var(--header-height);
}
[showingobjects] .thread-view {
display: none;
}
/* objects split */
[showingobjects][chatvisible] .chat-object-view {
right: var(--split-thread-width);
border-right: 1px solid var(--border);
}
[showingobjects][chatvisible] .thread-view {
top: var(--header-height);
padding-top: var(--content-padding);
}
[showingobjects][chatvisible] :is(.thread-view, footer) {
display: flex;
right: 0px;
left: unset;
width: var(--split-thread-width);
}
/* hide settings tab */
main:has([showingobjects][chatopen]) {
display: flex;
}
main:has([showingobjects][chatopen]) #settings-tab {
display: none;
}
@media (max-width: 640px) {
[showingobjects][chatvisible][chatopen] .chat-object-view {
width: 100%;
bottom: calc(var(--split-thread-height) + var(--control-height));
border-right: none;
border-bottom: 1px solid var(--border);
}
[showingobjects][chatvisible][chatopen] .thread-view {
top: unset;
bottom: calc(var(--control-height) + var(--safe-area-bottom));
width: 100%;
height: var(--split-thread-height);
padding: 0px;
}
[showingobjects][chatvisible][chatopen] footer {
width: 100%;
}
}
/* calendar */
:root {
--day-view-width: 400px;
}
.calendar-wrapper {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.calendar-wrapper .month-grid {
border-bottom: 1px solid var(--border);
}
.calendar-wrapper > * {
width: 100%;
height: 100%;
flex: 1;
}
.day-miniature[today] h3 {
color: var(--primary);
}
.day-miniature:has(> div:not(:empty)):not([selected]) {
border: var(--gap-between-tiles) solid var(--text-warning);
}
@media (min-width: 750px) {
.calendar-wrapper:not([chatvisible] *) {
flex-direction: row;
}
.calendar-wrapper:not([chatvisible] *) .month-grid {
border-bottom: none;
border-right: 1px solid var(--border);
}
.calendar-wrapper:not([chatvisible] *) .day-view {
max-width: var(--day-view-width);
}
}
@media (min-width: 1200px) {
.calendar-wrapper {
flex-direction: row;
}
.calendar-wrapper .month-grid {
border-bottom: none;
border-right: 1px solid var(--border);
}
.calendar-wrapper .day-view {
max-width: var(--day-view-width);
}
}
@media (max-width: 600px) or ((min-width: 750px) and (max-width: 1100px)) {
.day-miniature {
padding: 0px !important;
align-items: center !important;
justify-content: center;
}
.day-miniature h3 {
font-size: 1rem;
text-align: center;
}
.day-miniature > div {
display: none;
}
}
-437
View File
@@ -1,437 +0,0 @@
const englishTranslations = {
// general
set: "Set",
save: "Save",
search: "Search",
reset: "Reset",
back: "Back",
undoChanges: "Undo changes",
close: "Close",
discard: "Discard",
rename: "Rename",
firstDayOfWeek: "First day of week",
weekdays: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
previousMonth: "previous month",
nextMonth: "next month",
// settings
settings: "Settings",
showSettings: "Show settings",
zoomOut: "Zoom Out",
zoomIn: "Zoom In",
repairApp: "Repair App",
clearAddresses: "Clear previous connections",
clearCategories: "Clear suggestions for object categories",
clearStatuses: "Clear suggestions for object statuses",
clearObjectFilters: "Clear object filters",
// overview
overview: "Overview",
connection: "Connection",
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",
previousConnections: "Previous connections",
connectToServer: "Connect",
disconnect: "Disconnect",
mailbox: "Mailbox",
requestMailbox: "Enable",
deleteMailbox: "Disable",
mailboxExplanation:
"When you're disconnected, messages will be kept on the server temporarily",
outbox: "Outbox",
outboxText: (count: number) => {
switch (count) {
case 0:
return "All items sent";
case 1:
return "1 item in outbox";
default:
return `${count} items in outbox`;
}
},
primaryChannel: "Primary channel",
primaryChannelPlaceholder: "my-channel",
addChat: "Add",
// chat
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",
showKey: "Show key",
clearObjects: "Delete all objects",
clearChatMessages: "Clear chat messages",
removeChat: "Remove chat",
messageInOutbox: "Pending",
noChatSelected: "No chat selected",
composerPlaceholder: "Type a message...",
sendMessage: "Send",
// messages
resendMessage: "Resend message",
decryptMessage: "Decrypt message",
copyMessage: "Copy message",
deleteMessage: "Delete message",
// objects
objectsInTotal: "Objects in total: ",
showObjects: "show objects",
createObject: "create object",
untitledObject: "Untitled Object",
filterObjects: "Filter Objects",
searchTitle: "Search",
searchPlaceholder: "Note 2000-12-31",
searchTitleText: (term: string, resultCount: number) =>
term == ""
? `Objects in total: ${resultCount}`
: `Matches for "${term}": ${resultCount}`,
viewAll: "All",
viewNotes: "Notes",
viewKanban: "Kanban",
viewStatus: "Status",
viewCalendar: "Calendar",
noObjects: "No objects",
objectTitle: "Object title",
objectTitlePlaceholder: "My object",
objectVersion: "Object version",
note: "Note",
noteContentPlaceholder: "Take a note...",
category: "Category",
categoryPlaceholder: "Define a category",
priority: "Priority",
priorityPlaceholder: "2",
status: "Status",
statusPlaceholder: "Pending",
date: "Date",
time: "Time",
resendObjects: "Resend all objects",
deleteObject: "Delete object",
};
const allTranslations: { [key: string]: typeof englishTranslations } = {
en: englishTranslations,
es: {
// general
set: "Guardar",
save: "Guardar",
search: "Buscar",
reset: "Restablecer",
back: "Atrás",
undoChanges: "Deshacer",
close: "Cerrar",
discard: "Descartar",
rename: "Renombrar",
firstDayOfWeek: "Primer día de la semana",
weekdays: ["Dom", "Lun", "Mar", "Mié", "Jue", "Vie", "Sáb"],
previousMonth: "Último mes",
nextMonth: "Próximo mes",
// settings
settings: "Configuración",
showSettings: "Mostrar configuración",
zoomOut: "Alejar",
zoomIn: "Acercar",
repairApp: "Reparar Aplicación",
clearAddresses: "Borrar conexiones previas",
clearCategories: "Borrar sugerencias para categorías de objetos",
clearStatuses: "Borrar sugerencias para estados de objetos",
clearObjectFilters: "Borrar filtros de objetos",
// overview
overview: "Resumen",
connection: "Conexión",
chats: "Chats",
yourName: "Tu nombre",
namePlaceholder: "Juan Pérez",
encryptionUnavailableTitle: "Cifrado no disponible",
encryptionUnavailableMessage:
"Obtén esta aplicación a través de HTTPS o continúa sin cifrado",
serverAddress: "Dirección del servidor",
serverAddressPlaceholder: "wss://192.168.0.69:3000",
previousConnections: "Conexiones anteriores",
connectToServer: "Conectar",
disconnect: "Desconectar",
mailbox: "Buzón",
requestMailbox: "Activar",
deleteMailbox: "Desactivar",
mailboxExplanation:
"Si estás sin conexión, los mensajes se guardarán temporalmente en el servidor",
outbox: "Bandeja de salida",
outboxText: (count: number) => {
switch (count) {
case 0:
return "Todos los elementos enviados";
case 1:
return "1 elemento en bandeja de salida";
default:
return `${count} elementos en bandeja de salida`;
}
},
primaryChannel: "Canal principal",
primaryChannelPlaceholder: "mi-canal",
addChat: "Añadir",
// chat
showChatOptions: "Mostrar opciones del chat",
configureChatTitle: "Configurar chat",
secondaryChannel: "Canal secundario",
secondaryChannelPlaceholder: "Añadir canal secundario",
addSecondaryChannel: "Añadir canal secundario",
removeSecondaryChannel: "Eliminar canal secundario",
encryptionKey: "Clave de cifrado",
encryptionKeyPlaceholder: "n10d2482dg283hg",
showKey: "Mostrar clave",
clearObjects: "Eliminar todos los objetos",
clearChatMessages: "Eliminar todos los mensajes",
removeChat: "Eliminar chat",
messageInOutbox: "Pendiente",
noChatSelected: "Selecciona un chat",
composerPlaceholder: "Escribe un mensaje...",
sendMessage: "Enviar",
// messages
resendMessage: "Reenviar mensaje",
decryptMessage: "Descifrar mensaje",
copyMessage: "Copiar mensaje",
deleteMessage: "Eliminar este mensaje",
// objects
objectsInTotal: "Objetos en total: ",
showObjects: "mostrar objetos",
createObject: "añadir nuevo objeto",
untitledObject: "Sin título",
filterObjects: "Filtrar objetos",
searchTitle: "Buscar",
searchPlaceholder: "Nota 2000-12-31",
searchTitleText: (term: string, resultCount: number) =>
term == ""
? `Objetos en total: ${resultCount}`
: `Resultados por "${term}": ${resultCount}`,
viewAll: "Todos",
viewNotes: "Notas",
viewKanban: "Kanban",
viewStatus: "Estado",
viewCalendar: "Calendario",
noObjects: "Sin objetos",
objectTitle: "Título",
objectTitlePlaceholder: "Mi objeto",
objectVersion: "Version del objeto",
note: "Nota",
noteContentPlaceholder: "Toma nota...",
category: "Categoría",
categoryPlaceholder: "Define una categoría",
priority: "Prioridad",
priorityPlaceholder: "2",
status: "Estado",
statusPlaceholder: "Pendiente",
date: "Fecha",
time: "Hora",
resendObjects: "Reenviar todos los objetos",
deleteObject: "Eliminar objeto",
},
de: {
// general
set: "OK",
save: "Sichern",
search: "Suchen",
reset: "Zurücksetzen",
back: "Zurück",
undoChanges: "Änderungen verwerfen",
close: "Schließen",
discard: "Verwerfen",
rename: "Umbenennen",
firstDayOfWeek: "Erster Wochentag",
weekdays: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"],
previousMonth: "Vorheriger Monat",
nextMonth: "Nächster Monat",
// settings
settings: "Einstellungen",
showSettings: "Einstellungen anzeigen",
zoomOut: "Verkleinern",
zoomIn: "Vergrößern",
repairApp: "App reparieren",
clearAddresses: "Vorherige Verbindungen löschen",
clearCategories: "Vorschläge für Objektkategorien löschen",
clearStatuses: "Vorschläge für Objektstaten löschen",
clearObjectFilters: "Objektfilter löschen",
// overview
overview: "Übersicht",
connection: "Verbindung",
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",
previousConnections: "Vorherige Verbindungen",
connectToServer: "Verbinden",
disconnect: "Trennen",
mailbox: "Briefkasten",
requestMailbox: "Aktivieren",
deleteMailbox: "Deaktivieren",
mailboxExplanation:
"Wenn du offline bist, werden Nachrichten auf dem Server gelagert",
outbox: "Ausgang",
outboxText: (count: number) => {
switch (count) {
case 0:
return "Alle Nachrichten gesendet";
case 1:
return "Eine Nachricht ausstehend";
default:
return `${count} Nachrichten ausstehend`;
}
},
primaryChannel: "Hauptkanal",
primaryChannelPlaceholder: "mein-kanal",
addChat: "Hinzufügen",
// chat
showChatOptions: "Chatoptionen einblenden",
configureChatTitle: "Chat konfigurieren",
secondaryChannel: "Zweitkanal",
secondaryChannelPlaceholder: "Zweitkanal hinzufügen",
addSecondaryChannel: "Zweitkanal hinzufügen",
removeSecondaryChannel: "Zweitkanal entfernen",
encryptionKey: "Schlüssel",
encryptionKeyPlaceholder: "n10d2482dg283hg",
showKey: "Schlüssel anzeigen",
clearObjects: "Alle Objekte löschen",
clearChatMessages: "Nachrichtenverlauf leeren",
removeChat: "Chat löschen",
messageInOutbox: "Ausstehend",
noChatSelected: "Kein Chat ausgewählt",
composerPlaceholder: "Neue Nachricht...",
sendMessage: "Senden",
// messages
resendMessage: "Erneut senden",
decryptMessage: "Nachricht entschlüsseln",
copyMessage: "Nachricht kopieren",
deleteMessage: "Nachricht löschen",
// objects
objectsInTotal: "Objekte insgesamt: ",
showObjects: "Objekte anzeigen",
createObject: "Neues Objekt erstellen",
untitledObject: "Unbenannt",
filterObjects: "Objekte filtern",
searchTitle: "Suchen",
searchPlaceholder: "Notiz 2000-12-31",
searchTitleText: (term: string, resultCount: number) =>
term == ""
? `Objekte insgesamt: ${resultCount}`
: `Ergebnisse für "${term}": ${resultCount}`,
viewAll: "Alle",
viewNotes: "Notizen",
viewKanban: "Kanban",
viewStatus: "Status",
viewCalendar: "Kalender",
noObjects: "Keine Objekte",
objectTitle: "Titel",
objectTitlePlaceholder: "Mein Objekt",
objectVersion: "Version des Objekts",
note: "Notiz",
noteContentPlaceholder: "Notiz eingeben...",
category: "Kategorie",
categoryPlaceholder: "Kategorie eingeben...",
priority: "Priorität",
priorityPlaceholder: "2",
status: "Status",
statusPlaceholder: "Ausstehend",
date: "Datum",
time: "Uhrzeit",
resendObjects: "Objekte erneut senden",
deleteObject: "Objekt löschen",
},
};
const language = navigator.language.substring(0, 2);
export const translation: typeof englishTranslations =
allTranslations[language] ?? allTranslations.en;
-71
View File
@@ -1,71 +0,0 @@
import * as React from "bloatless-react";
import { MessageObjectContent } from "./Model/chatModel";
export const storageKeys = {
viewType(id: string): string {
return id + "view-type";
},
hasUnread(id: string): string {
return id + "has-unread-messages";
},
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";
},
objects(id: string): string {
return id + "items";
},
outbox(id: string): string {
return id + "outbox";
},
itemOutbox(id: string): string {
return id + "item-outbox";
},
composingMessage(id: string): string {
return id + "composing-message";
},
};
export const stringToOptionTag: React.StateItemConverter<string> = (value) => {
return <option>{value}</option>;
};
export function getRawObjectIndex(latest: MessageObjectContent): string {
// date
const dateString = latest.date || "0000-00-00";
const [year, month, date] = dateString.split("-")
// time
const timeString = latest.time || "00:00";
const [hour, minute] = timeString.split(":");
const hourInMinutes = parseInt(hour) * 60;
const minutesTotal = parseInt(minute) + hourInMinutes;
const paddedMinutes = minutesTotal.toString().padStart(4, "0")
// priority
const priority = latest.priority ?? 0;
const priorityInverse = 100 - priority;
const paddedPriority = priorityInverse.toString().padStart(3, "0");
// final
return `${year}${month}${date}${paddedMinutes}${paddedPriority}`;
}