notebrook-notes/frontend/src/views/main.ts

592 lines
25 KiB
TypeScript
Raw Normal View History

2024-08-23 14:45:28 +00:00
import { API } from "../api";
import { ChunkProcessor } from "../chunk-processor";
import { ChannelDialog } from "../dialogs/channel-dialog";
import { CreateChannelDialog } from "../dialogs/create-channel";
import { MessageDialog } from "../dialogs/message";
import { RecordAudioDialog } from "../dialogs/record-audio";
import { SearchDialog } from "../dialogs/search";
import { SettingsDialog } from "../dialogs/settings";
import { TakePhotoDialog } from "../dialogs/take-photo";
import { MessageUpdated } from "../events/message-events";
2024-08-23 14:45:28 +00:00
import { Channel } from "../model/channel";
import { IMessage, Message } from "../model/message";
import { UnsentMessage } from "../model/unsent-message";
import { playSent, playSound, playWater } from "../sound";
import { showToast } from "../speech";
import { state } from "../state";
import { Button, List, ListItem, TextInput, UINode } from "../ui";
import { Dropdown } from "../ui/dropdown";
import { FileInput } from "../ui/file-input";
import { MultilineInput } from "../ui/multiline-input";
import { connectToWebsocket } from "../websockets";
import { AuthorizeView } from "./authorize";
import { View } from "./view";
export class MainView extends View {
private settingsButton!: Button;
private channelSwitcher!: Dropdown;
private channelInfoButton!: Button;
private searchButton!: Button;
private fileInput!: FileInput;
private messageInput!: MultilineInput;
private imageInput!: Button;
private voiceMessageInput!: Button;
private messageList!: List;
private updateInterval!: number;
private messageElementMap: Map<number, UINode> = new Map();
2024-08-30 14:15:15 +00:00
private hotkeyMap: Map<string, () => void> = new Map();
2024-08-23 14:45:28 +00:00
public onActivate(): void {
if (!state.currentChannel) {
if (state.defaultChannelId) {
this.switchChannel(state.defaultChannelId.toString());
} else {
if (state.channelList.channels.length > 0) this.switchChannel(state.channelList.channels[0].id.toString());
}
}
this.renderInitialMessageList();
this.checkAuthorization();
this.syncChannels();
this.updateChannelList();
this.syncMessages();
this.updateInterval = setInterval(() => {
this.updateVisibleMessageShownTimestamps();
2024-08-24 16:18:09 +00:00
}, 10000);
2024-08-23 14:45:28 +00:00
setTimeout(() => this.attemptToSendUnsentMessages(), 2000);
state.events.registerHandler<MessageUpdated>("message-updated", (message) => {
const { data } = message;
if (!data) return;
const channel = state.currentChannel;
if (!channel) return;
const existing = channel.getMessage(parseInt(data!.id));
if (!existing) {
return;
} else {
existing.content = data.content;
state.save();
const renderedMessage = this.messageElementMap.get(existing.id);
if (renderedMessage) {
2024-08-24 16:18:09 +00:00
(renderedMessage as ListItem).setText(`${existing.content}; ${this.convertIsoTimeStringToFriendly(existing.createdAt)}`);
}
}
});
2024-08-23 14:45:28 +00:00
2024-08-30 14:15:15 +00:00
document.addEventListener("keydown", (e) => this.handleHotkey(e));
}
2024-08-23 14:45:28 +00:00
public onDeactivate(): void {
clearInterval(this.updateInterval);
2024-08-30 14:15:15 +00:00
// unregister hotkey
document.removeEventListener("keydown", (e) => this.handleHotkey(e));
2024-08-23 14:45:28 +00:00
}
public onCreate(): void {
this.settingsButton = new Button("Settings");
this.settingsButton.setPosition(0, 0, 10, 10);
this.settingsButton.onClick(() => this.openSettingsDialog());
2024-08-23 14:45:28 +00:00
this.channelSwitcher = new Dropdown("Channel", []);
this.channelSwitcher.setPosition(30, 10, 30, 10);
this.channelInfoButton = new Button("Channel info");
this.channelInfoButton.setPosition(60, 10, 30, 10);
this.searchButton = new Button("Search");
this.searchButton.setPosition(90, 10, 10, 10);
this.searchButton.onClick(async () => this.openSearchDialog());
2024-08-23 14:45:28 +00:00
this.fileInput = new FileInput("Upload file");
this.fileInput.setPosition(0, 90, 15, 10);
this.imageInput = new Button("Image");
this.imageInput.setPosition(15, 90, 15, 10);
this.messageInput = new MultilineInput("New message");
this.messageInput.setPosition(30, 90, 60, 10);
this.messageInput.getElement().autofocus = true;
this.voiceMessageInput = new Button("Voice message");
this.voiceMessageInput.setPosition(70, 90, 30, 10);
this.messageList = new List("Messages");
this.messageList.setPosition(30, 30, 60, 50);
this.window.add(this.settingsButton);
this.window.add(this.channelSwitcher);
this.window.add(this.channelInfoButton);
this.window.add(this.searchButton);
this.window.add(this.messageList);
this.window.add(this.messageInput);
this.window.add(this.fileInput);
this.window.add(this.imageInput);
this.window.add(this.voiceMessageInput);
this.channelSwitcher.getElement().addEventListener("change", (e) => this.handleChannelSwitcher(e));
this.voiceMessageInput.onClick(async () => this.handleVoiceMessageButton());
2024-08-23 14:45:28 +00:00
this.messageInput.onKeyDown((key: string, alt: boolean | undefined, shift: boolean | undefined, ctrl: boolean | undefined) => {
if (key === "Enter") {
if (!shift) {
console.log(key, alt, shift, ctrl);
this.sendMessage();
}
}
});
this.channelInfoButton.onClick(() => this.handleChannelInfoButton());
this.imageInput.onClick(async () => this.handleImageButton());
2024-08-30 14:15:15 +00:00
this.setHotkeys();
2024-08-23 14:45:28 +00:00
}
public onDestroy(): void {
2024-08-23 14:45:28 +00:00
}
private async syncChannels() {
const channels = await API.getChannels();
channels.forEach((chan) => state.addChannel(new Channel(chan)));
this.updateChannelList();
if (!state.currentChannel) {
if (state.defaultChannelId) {
this.switchChannel(state.defaultChannelId.toString());
} else {
if (state.channelList.channels.length > 0) {
this.switchChannel(state.channelList.channels[0].id.toString());
} else {
this.createNewChannel();
}
2024-08-23 14:45:28 +00:00
}
}
state.save();
}
private updateChannelList() {
this.channelSwitcher.clearOptions();
state.getChannels().forEach((chan) => {
this.channelSwitcher.addOption(chan.id.toString(), chan.name);
});
this.channelSwitcher.addOption("__new__", "Add new channel");
}
private checkAuthorization() {
if (!state.token || !state.apiUrl) {
this.viewManager.push(new AuthorizeView(this.viewManager));
} else {
API.token = state.token;
API.path = state.apiUrl;
connectToWebsocket();
}
state.save();
}
private async syncMessages() {
if (!state.currentChannel) return;
if (!state.currentChannel.messages) state.currentChannel.messages = [];
const channelId = state.currentChannel.id;
if (channelId) {
const messages = await API.getMessages(channelId.toString());
// only render new list items, or list items that have changed.
const proc = new ChunkProcessor<IMessage>(100);
proc.processArray(messages, (chunk: IMessage[]) => {
chunk.forEach((message: IMessage) => {
// TODO: this could do with a lot of perf improvements. I'll get to it once this is an issue.
const existing = state.currentChannel!.getMessage(message.id);
if (!existing) {
state.currentChannel!.addMessage(new Message(message));
this.renderAndAddMessage(message);
} else {
// TODO: this is awful and needs to be updated, but it works for now.
if (existing.content !== message.content || existing.fileId !== message.fileId || existing.filePath !== message.filePath || existing.fileType !== message.fileType || existing.createdAt !== message.createdAt) {
existing.content = message.content;
existing.fileId = message.fileId;
existing.filePath = message.filePath;
existing.fileType = message.fileType;
existing.createdAt = message.createdAt;
existing.fileId = message.fileId;
existing.filePath = message.filePath;
existing.fileSize = message.fileSize;
const renderedMessage = this.messageElementMap.get(message.id);
if (renderedMessage) {
2024-08-24 16:18:09 +00:00
(renderedMessage as ListItem).setText(`${message.content}; ${this.convertIsoTimeStringToFriendly(message.createdAt)}`);
2024-08-23 14:45:28 +00:00
}
}
}
});
});
}
state.save();
}
public switchChannel(channelId: string) {
if (this.messageList.children.length > 0) this.messageList.clear();
const chan = state.getChannelById(parseInt(channelId));
if (!chan) {
throw new Error("Could not find channel " + channelId);
}
state.currentChannel = chan;
state.save();
}
private renderMessage(message: IMessage): UINode {
2024-08-24 16:18:09 +00:00
const itm = new ListItem(`${message.content}; ${this.convertIsoTimeStringToFriendly(message.createdAt)}`);
itm.setUserData(message.id);
2024-08-23 14:45:28 +00:00
itm.onClick(() => {
new MessageDialog(message).open();
})
return itm;
}
private renderInitialMessageList(reset: boolean = false) {
if (!state.currentChannel) return;
if (!state.currentChannel.messages || state.currentChannel.messages.length < 1) return;
if (this.messageList.children.length > 0 && !reset) {
return;
} else {
this.messageList.clear();
this.messageElementMap.clear();
}
state.currentChannel.messages.forEach((message) => {
this.renderAndAddMessage(message);
});
this.messageList.scrollToBottom();
}
private async createNewChannel() {
const name = await new CreateChannelDialog().open();
if (name) {
const chan = await API.createChannel(name);
state.addChannel(new Channel(chan));
this.updateChannelList();
if (state.channelList.channels.length < 2) {
state.defaultChannelId = chan.id;
}
state.save();
}
}
private async sendMessage() {
if (this.fileInput && this.fileInput.getFiles() && this.fileInput.getFiles()!.length > 0) {
return this.uploadFile();
}
if (this.messageInput.getValue().length > 0) {
const messageContent = this.messageInput.getValue();
this.messageInput.setValue("");
playWater();
try {
const message: IMessage = await API.createMessage(state.currentChannel!.id.toString(), messageContent);
this.messageInput.setValue("");
this.renderAndAddMessage(message);
this.messageList.scrollToBottom();
playSent();
state.save();
} catch (e) {
showToast("Could not post message. Will retry later.", 3000);
playSound("uploadFailed");
const unsentId = Date.now();
state.unsentMessages.push(new UnsentMessage({
channelId: state.currentChannel!.id,
content: messageContent,
createdAt: new Date().toISOString(),
id: unsentId
}));
const tmpMessage: IMessage = new Message({
id: unsentId,
content: messageContent,
createdAt: new Date().toISOString(),
});
state.currentChannel!.addMessage(tmpMessage);
this.renderAndAddMessage(tmpMessage);
state.save();
}
}
}
private async uploadVoiceMessage(blob: Blob) {
playWater();
const msgContent = this.messageInput.getValue() !== "" ? this.messageInput.getValue() : "Voice message";
this.messageInput.setValue("");
const msg = await API.createMessage(state.currentChannel!.id.toString(), msgContent);
const id = msg.id;
try {
const response: any = await API.uploadFile(state.currentChannel!.id.toString(), id.toString(), blob);
if (msg) {
msg.fileId = response.fileId;
msg.filePath = response.filePath;
msg.fileType = response.fileType;
state.currentChannel!.addMessage(new Message(msg));
this.renderAndAddMessage(msg);
playSent();
state.save();
} else {
showToast("Something went wrong during message file upload.");
playSound("uploadFailed");
// TODO: Handle the case when no message is found
}
} catch (e) {
playSound("uploadFailed");
showToast("Unable to send message. Will retry later.", 3000);
state.unsentMessages.push(new UnsentMessage({
channelId: state.currentChannel!.id,
content: msgContent,
createdAt: new Date().toISOString(),
blob: blob,
id: Date.now()
}));
state.save();
}
}
private async uploadFile() {
if (!this.fileInput.getFiles()) return;
if (this.fileInput!.getFiles()!.length < 1) return;
const file = this.fileInput!.getFiles()![0];
if (file) {
playWater();
const msgContent = this.messageInput.getValue() !== "" ? this.messageInput.getValue() : "File upload";
this.messageInput.setValue("");
2024-08-23 14:45:28 +00:00
try {
const msg = await API.createMessage(state.currentChannel!.id.toString(), msgContent);
const id = msg.id;
const response: any = await API.uploadFile(state.currentChannel!.id.toString(), id.toString(), file);
if (msg) {
msg.fileId = response.fileId;
msg.filePath = response.filePath;
msg.fileType = response.fileType;
state.currentChannel!.addMessage(new Message(msg));
this.renderAndAddMessage(msg);
playSent();
this.messageInput.setValue("");
// reset the file picker
(this.fileInput.getElement() as HTMLInputElement).value = "";
state.save();
} else {
showToast("Error while uploading file.");
playSound("uploadFailed");
// TODO: Handle the case when no message is found
}
} catch (e) {
showToast("Could not post message. Will retry later.", 3000);
playSound("uploadFailed");
state.unsentMessages.push(new UnsentMessage({
channelId: state.currentChannel!.id,
content: this.messageInput.getValue(),
createdAt: new Date().toISOString(),
blob: file,
id: Date.now()
}));
state.save();
}
}
}
private convertIsoTimeStringToRelative(isoTimeString: string): string {
const date = new Date(isoTimeString);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 1000 * 60) {
return `${Math.floor(diff / 1000)} seconds ago`;
} else if (diff < 1000 * 60 * 60) {
// return both minutes and seconds
return `${Math.floor(diff / (1000 * 60))} minutes ${Math.floor((diff % (1000 * 60)) / 1000)} seconds ago`;
} else if (diff < 1000 * 60 * 60 * 24) {
// return hours, minutes, seconds ago
return `${Math.floor(diff / (1000 * 60 * 60))} hours ${Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))} minutes ${Math.floor((diff % (1000 * 60)) / 1000)} seconds ago`;
} else {
return date.toLocaleString();
}
}
2024-08-24 16:18:09 +00:00
private convertIsoTimeStringToFriendly(isoTimeString: string): string {
const date = new Date(isoTimeString);
const now = new Date();
if (date.getDate() === now.getDate() && date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear()) {
return date.toLocaleTimeString();
} else {
return date.toLocaleString();
}
}
2024-08-23 14:45:28 +00:00
private updateVisibleMessageShownTimestamps() {
const lowerIndex = Math.max(this.messageList.getFocus() - 10, 0);
const upperIndex = Math.min(this.messageList.getFocus() + 10, this.messageList.children.length);
for (let i = lowerIndex; i < upperIndex; i++) {
const child = this.messageList.children[i];
if (!child) break;
const messageId = child.getUserData() as number;
const message = state.currentChannel?.getMessage(messageId);
2024-08-23 14:45:28 +00:00
if (message) {
2024-08-24 16:25:22 +00:00
(child as ListItem).setText(`${message.content}; ${this.convertIsoTimeStringToFriendly(message.createdAt)}`);
2024-08-23 14:45:28 +00:00
}
}
}
private async attemptToSendUnsentMessages() {
state.unsentMessages.forEach(async (msg) => {
if (msg.blob) {
const apiMsg = await API.createMessage(msg.channelId.toString(), msg.content);
const id = apiMsg.id;
const response: any = await API.uploadFile(msg.channelId.toString(), id.toString(), msg.blob);
if (apiMsg) {
apiMsg.fileId = response.fileId;
apiMsg.filePath = response.filePath;
apiMsg.fileType = response.fileType;
state.currentChannel!.addMessage(new Message(apiMsg));
this.renderAndAddMessage(apiMsg);
playSent();
state.unsentMessages = state.unsentMessages.filter((m) => m !== msg);
this.removeSpecificMessageFromList(msg.id);
state.save();
}
} else {
const apiMsg = await API.createMessage(msg.channelId.toString(), msg.content);
state.currentChannel!.addMessage(new Message(apiMsg));
this.renderAndAddMessage(apiMsg);
state.unsentMessages = state.unsentMessages.filter((m) => m !== msg);
playSent();
this.removeSpecificMessageFromList(msg.id);
state.save();
}
});
}
private clearUnsentMessageDisplay() {
this.messageList.children.forEach((msg) => {
const data = msg.getUserData() as IMessage;
if (data.id === -1) {
this.messageList.remove(msg);
}
})
}
private removeSpecificMessageFromList(id: number) {
const elem = this.messageElementMap.get(id);
if (elem) {
this.messageList.remove(elem);
this.messageElementMap.delete(id);
}
}
private async uploadImage(blob: Blob) {
playWater();
try {
const msg = await API.createMessage(state.currentChannel!.id.toString(), "Image");
const id = msg.id;
const response: any = await API.uploadFile(state.currentChannel!.id.toString(), id.toString(), blob);
if (msg) {
msg.fileId = response.fileId;
msg.filePath = response.filePath;
msg.fileType = response.fileType;
state.currentChannel!.addMessage(new Message(msg));
this.renderAndAddMessage(msg);
playSent();
state.save();
} else {
showToast("Error while uploading file.");
playSound("uploadFailed");
// TODO: Handle the case when no message is found
}
} catch (e) {
showToast("Could not post message. Will retry later.", 3000);
playSound("uploadFailed");
state.unsentMessages.push(new UnsentMessage({
channelId: state.currentChannel!.id,
content: "Image",
createdAt: new Date().toISOString(),
blob: blob,
id: Date.now()
}));
state.save();
}
}
private renderAndAddMessage(message: IMessage) {
const elem = this.renderMessage(message);
this.messageList.add(elem);
this.messageElementMap.set(message.id, elem);
}
private async openSettingsDialog() {
const d = new SettingsDialog();
d.open();
}
private async openSearchDialog() {
const searchDialog = new SearchDialog();
const res = await searchDialog.open();
if (res) {
if (res.channelId && res.messageId) {
if (state.currentChannel?.id !== res.channelId) {
this.switchChannel(res.channelId.toString());
this.renderInitialMessageList();
}
const message = state.currentChannel!.getMessage(res.messageId);
if (message) {
this.messageElementMap.get(message.id)?.focus();
}
}
}
}
private handleChannelSwitcher(e: Event) {
const target = e.target as HTMLSelectElement;
if (target.value === "__new__") {
this.createNewChannel();
} else {
this.switchChannel(target.value);
this.renderInitialMessageList();
this.syncMessages();
}
}
private async handleVoiceMessageButton() {
const blob = await new RecordAudioDialog().open();
if (blob) {
this.uploadVoiceMessage(blob);
}
}
private async handleChannelInfoButton() {
if (this.channelSwitcher.getSelectedValue() === "__new__") {
this.createNewChannel();
return;
}
const d = new ChannelDialog(state.currentChannel!);
d.open().then((chan) => {
if (!chan) {
state.removeChannel(state.currentChannel!);
state.currentChannel = null;
this.updateChannelList();
state.save();
if (state.channelList.channels.length > 0) {
return this.switchChannel(state.channelList.channels[0].id.toString());
} else {
return this.createNewChannel();
}
}
if (chan.messages.length < 1) {
this.renderInitialMessageList(true);
this.syncMessages();
}
state.save();
this.updateChannelList();
});
}
private async handleImageButton() {
const photo = await new TakePhotoDialog().open();
this.uploadImage(photo);
}
2024-08-30 14:15:15 +00:00
private setHotkeys() {
this.hotkeyMap.set("s", () => this.openSettingsDialog());
this.hotkeyMap.set("c", () => this.channelSwitcher.focus());
this.hotkeyMap.set("x", () => this.handleChannelInfoButton());
this.hotkeyMap.set("f", () => this.openSearchDialog());
this.hotkeyMap.set("v", () => this.handleVoiceMessageButton());
this.hotkeyMap.set(" ", () => this.messageInput.focus());
}
private handleHotkey(e: KeyboardEvent) {
if (e.altKey) {
const action = this.hotkeyMap.get(e.key);
if (action) {
e.preventDefault();
action();
}
}
}
2024-08-23 14:45:28 +00:00
}