diff --git a/dist/index.js b/dist/index.js index da73f535..328bb2a4 100644 --- a/dist/index.js +++ b/dist/index.js @@ -603,6 +603,10 @@ info: (id) => [...filePaths.chat.chatBase(id), "info"], color: (id) => [...filePaths.chat.chatBase(id), "color"], messages: (id) => [...filePaths.chat.chatBase(id), "messages"], + reactions: (id) => [ + ...filePaths.chat.chatBase(id), + "reactions" + ], lastUsedPage: (id) => [ ...filePaths.chat.chatBase(id), "last-used-page" @@ -1037,6 +1041,7 @@ const didStore = this.storeFileContent(fileContent); if (didStore == false) return; this.boardsAndTasksModel.handleFileContent(fileContent); + this.chatModel.handleReaction(fileContent); }; // methods this.addFileContentAndSend = (fileContent) => { @@ -1203,6 +1208,7 @@ // init constructor(storageModel2, connectionModel2, settingsModel2, chatListModel2, chatId) { this.chatMessageHandlerManager = new HandlerManager(); + this.reactionHandlerManager = new HandlerManager(); // paths this.getBasePath = () => { return StorageModel.getPath( @@ -1231,6 +1237,15 @@ this.getMessagePath = (id) => { return [...this.getMessageDirPath(), id]; }; + this.getReactionDirPath = () => { + return StorageModel.getPath( + "chat" /* Chat */, + filePaths.chat.reactions(this.id) + ); + }; + this.getReactionPath = (id) => { + return [...this.getReactionDirPath(), id]; + }; // handlers this.handleMessage = (body) => { const chatMessage = parseValidObject( @@ -1242,6 +1257,10 @@ this.addMessage(chatMessage); this.setReadStatus(true); }; + this.handleReaction = (reaction) => { + if (!checkMatchesObjectStructure(reaction, ChatMessageReactionReference)) return; + this.addReaction(reaction); + }; this.handleMessageSent = (chatMessage) => { chatMessage.status = "sent" /* Sent */; this.addMessage(chatMessage); @@ -1276,7 +1295,12 @@ chatMessage.stringifiedFile ); }; - this.sendMessage = async (body, fileContent) => { + this.addReaction = async (reaction) => { + const reactionPath = this.getReactionPath(reaction.fileId); + this.storageModel.writeStringifiable(reactionPath, reaction); + this.reactionHandlerManager.trigger(reaction); + }; + this.getNameAndChannel = () => { const senderName = this.settingsModel.username; if (senderName == "") return false; const allChannels = [this.info.primaryChannel]; @@ -1284,6 +1308,12 @@ allChannels.push(secondaryChannel); } const combinedChannel = allChannels.join("/"); + return [senderName, combinedChannel]; + }; + this.sendMessage = async (body, fileContent) => { + const nameAndChannel = this.getNameAndChannel(); + if (nameAndChannel == false) return; + const [senderName, combinedChannel] = nameAndChannel; const chatMessage = await _ChatModel.createChatMessage( combinedChannel, senderName, @@ -1307,6 +1337,18 @@ chatMessage.body = decryptedBody; chatMessage.stringifiedFile = decryptedFile; }; + this.sendReaction = async (messageId, content) => { + const nameAndChannel = this.getNameAndChannel(); + if (nameAndChannel == false) return; + const [senderName, combinedChannel] = nameAndChannel; + const reaction = _ChatModel.createMessageReaction( + messageId, + senderName, + content + ); + this.sendMessage("", reaction); + this.addReaction(reaction); + }; this.subscribe = () => { this.connectionModel.addChannel(this.info.primaryChannel); }; @@ -1376,6 +1418,7 @@ messagePath, ChatMessageReference ); + if (chatMessage == null) continue; chatMessages.push(chatMessage); } const sorted = chatMessages.sort( @@ -1383,6 +1426,23 @@ ); return sorted; } + get reactions() { + const reactionIds = this.storageModel.list( + this.getReactionDirPath() + ); + if (!Array.isArray(reactionIds)) return []; + const reactions = []; + for (const reactionId of reactionIds) { + const reactionPath = this.getReactionPath(reactionId); + const reaction = this.storageModel.readStringifiable( + reactionPath, + ChatMessageReactionReference + ); + if (reaction == null) continue; + reactions.push(reaction); + } + return reactions; + } // utility static splitChannel(channelString) { return channelString.split("/"); @@ -1427,6 +1487,18 @@ return chatMessage; }; } + static { + this.createMessageReaction = (messageId, sender, content) => { + const fileContent = FileModel2.createFileContent(v4_default(), "reaction"); + const reaction = { + ...fileContent, + messageId, + sender, + content + }; + return reaction; + }; + } }; var ChatInfoReference = { dataVersion: DATA_VERSION, @@ -1445,6 +1517,16 @@ status: "", stringifiedFile: "" }; + var ChatMessageReactionReference = { + dataVersion: DATA_VERSION, + fileId: "", + fileContentId: "", + creationDate: "", + type: "reaction", + messageId: "", + sender: "", + content: "" + }; // src/Model/Chat/chatListModel.ts var ChatListModel = class { @@ -1923,6 +2005,11 @@ this.status = new State( void 0 ); + this.reactionsThumbsUp = new MapState(); + this.reactionsCheck = new MapState(); + this.reactionsAttention = new MapState(); + this.reactionsDoubleAttention = new MapState(); + this.reactionsQuestion = new MapState(); // state this.isPresentingInfoModal = new State(false); // methods @@ -1942,6 +2029,21 @@ this.hideInfoModal = () => { this.isPresentingInfoModal.value = false; }; + // reactions + this.addReaction = (reaction) => { + switch (reaction.content) { + case "\u{1F44D}": + return this.reactionsThumbsUp.set(reaction.fileId, reaction); + case "\u2705": + return this.reactionsCheck.set(reaction.fileId, reaction); + case "\u2757\uFE0F": + return this.reactionsAttention.set(reaction.fileId, reaction); + case "\u203C\uFE0F": + return this.reactionsDoubleAttention.set(reaction.fileId, reaction); + case "\u2753": + return this.reactionsQuestion.set(reaction.fileId, reaction); + } + }; // load this.loadData = () => { this.channel = this.chatMessage.channel; @@ -1980,6 +2082,9 @@ this.chatViewModel.chatModel.addMessage(chatMessage); messageViewModel.loadData(); }; + this.sendReaction = (messageId, content) => { + this.chatViewModel.chatModel.sendReaction(messageId, content); + }; // view this.showChatMessage = (chatMessage) => { const chatMessageViewModel = new ChatMessageViewModel( @@ -1999,12 +2104,20 @@ ); } }; + this.showReaction = (reaction) => { + const messageViewModel = this.chatMessageViewModels.value.get(reaction.messageId); + if (messageViewModel == void 0) return; + messageViewModel.addReaction(reaction); + }; // load this.loadData = () => { this.chatMessageViewModels.clear(); for (const chatMessage of this.chatViewModel.chatModel.messages) { this.showChatMessage(chatMessage); } + for (const reaction of this.chatViewModel.chatModel.reactions) { + this.showReaction(reaction); + } }; this.chatViewModel = chatViewModel; this.cannotSendMessage = createProxyState( @@ -3149,6 +3262,9 @@ this.notificationViewModel.showNotification(chatMessage); } ); + chatModel.reactionHandlerManager.addHandler((reaction) => { + this.messagePageViewModel.showReaction(reaction); + }); this.loadPageSelection(); this.resetColor(); this.loadInfo(); diff --git a/src/Model/Chat/chatModel.ts b/src/Model/Chat/chatModel.ts index bbe5fc51..db27a55e 100644 --- a/src/Model/Chat/chatModel.ts +++ b/src/Model/Chat/chatModel.ts @@ -1,11 +1,16 @@ // this file is responsible for managing chats. -import { DATA_VERSION, ValidObject } from "../Utility/typeSafety"; +import { + checkMatchesObjectStructure, + DATA_VERSION, + ValidObject, +} from "../Utility/typeSafety"; import FileModel, { FileContent } from "../Files/fileModel"; import { HandlerManager, createTimestamp, localeCompare, + parse, parseValidObject, stringify, } from "../Utility/utility"; @@ -36,6 +41,8 @@ export default class ChatModel { chatMessageHandlerManager: HandlerManager = new HandlerManager(); + reactionHandlerManager: HandlerManager = + new HandlerManager(); get secondaryChannels(): string[] { return this.info.secondaryChannels.sort(localeCompare); @@ -74,6 +81,17 @@ export default class ChatModel { return [...this.getMessageDirPath(), id]; }; + getReactionDirPath = (): string[] => { + return StorageModel.getPath( + StorageModelSubPath.Chat, + filePaths.chat.reactions(this.id), + ); + }; + + getReactionPath = (id: string): string[] => { + return [...this.getReactionDirPath(), id]; + }; + // handlers handleMessage = (body: string): void => { const chatMessage: ChatMessage | null = parseValidObject( @@ -83,11 +101,19 @@ export default class ChatModel { if (chatMessage == null) return; chatMessage.status = ChatMessageStatus.Received; - this.addMessage(chatMessage); + this.addMessage(chatMessage); this.setReadStatus(true); }; + handleReaction = (reaction: ChatMessageReaction | any): void => { + if ( + !checkMatchesObjectStructure(reaction, ChatMessageReactionReference) + ) + return; + this.addReaction(reaction); + }; + handleMessageSent = (chatMessage: ChatMessage): void => { chatMessage.status = ChatMessageStatus.Sent; this.addMessage(chatMessage); @@ -132,10 +158,13 @@ export default class ChatModel { ); }; - sendMessage = async ( - body: string, - fileContent?: FileContent, - ): Promise => { + addReaction = async (reaction: ChatMessageReaction): Promise => { + const reactionPath: string[] = this.getReactionPath(reaction.fileId); + this.storageModel.writeStringifiable(reactionPath, reaction); + this.reactionHandlerManager.trigger(reaction); + }; + + getNameAndChannel = (): [string, string] | false => { const senderName = this.settingsModel.username; if (senderName == "") return false; @@ -146,6 +175,17 @@ export default class ChatModel { const combinedChannel: string = allChannels.join("/"); + return [senderName, combinedChannel]; + }; + + sendMessage = async ( + body: string, + fileContent?: FileContent, + ): Promise => { + const nameAndChannel = this.getNameAndChannel(); + if (nameAndChannel == false) return; + const [senderName, combinedChannel] = nameAndChannel; + const chatMessage: ChatMessage = await ChatModel.createChatMessage( combinedChannel, senderName, @@ -172,6 +212,23 @@ export default class ChatModel { chatMessage.stringifiedFile = decryptedFile; }; + sendReaction = async ( + messageId: string, + content: ReactionSymbol, + ): Promise => { + const nameAndChannel = this.getNameAndChannel(); + if (nameAndChannel == false) return; + const [senderName, combinedChannel] = nameAndChannel; + + const reaction = ChatModel.createMessageReaction( + messageId, + senderName, + content, + ); + this.sendMessage("", reaction); + this.addReaction(reaction); + }; + subscribe = (): void => { this.connectionModel.addChannel(this.info.primaryChannel); }; @@ -231,11 +288,12 @@ export default class ChatModel { const chatMessages: ChatMessage[] = []; for (const messageId of messageIds) { const messagePath: string[] = this.getMessagePath(messageId); - const chatMessage: ChatMessage | any = + const chatMessage: ChatMessage | null = this.storageModel.readStringifiable( messagePath, ChatMessageReference, ); + if (chatMessage == null) continue; chatMessages.push(chatMessage); } @@ -245,6 +303,27 @@ export default class ChatModel { return sorted; } + get reactions(): ChatMessageReaction[] { + const reactionIds: string[] = this.storageModel.list( + this.getReactionDirPath(), + ); + if (!Array.isArray(reactionIds)) return []; + + const reactions: ChatMessageReaction[] = []; + for (const reactionId of reactionIds) { + const reactionPath: string[] = this.getReactionPath(reactionId); + const reaction: ChatMessageReaction | null = + this.storageModel.readStringifiable( + reactionPath, + ChatMessageReactionReference, + ); + if (reaction == null) continue; + reactions.push(reaction); + } + + return reactions; + } + // init constructor( storageModel: StorageModel, @@ -324,9 +403,27 @@ export default class ChatModel { return chatMessage; }; + + static createMessageReaction = ( + messageId: string, + sender: string, + content: ReactionSymbol, + ): ChatMessageReaction => { + const fileContent = FileModel.createFileContent(v4(), "reaction"); + const reaction: ChatMessageReaction = { + ...fileContent, + + messageId, + sender, + content, + }; + return reaction; + }; } // types +export type ReactionSymbol = "👍" | "✅" | "❗️" | "‼️" | "❓"; + export interface ChatInfo extends ValidObject { primaryChannel: string; secondaryChannels: string[]; @@ -355,6 +452,13 @@ export interface ChatMessage extends ValidObject { stringifiedFile: string; } +export interface ChatMessageReaction + extends ValidObject, FileContent<"reaction"> { + messageId: string; + sender: string; + content: ReactionSymbol | string; +} + // references export const ChatInfoReference: ChatInfo = { dataVersion: DATA_VERSION, @@ -380,3 +484,16 @@ export const ChatMessageReference: ChatMessage = { stringifiedFile: "", }; + +export const ChatMessageReactionReference: ChatMessageReaction = { + dataVersion: DATA_VERSION, + + fileId: "", + fileContentId: "", + creationDate: "", + type: "reaction", + + messageId: "", + sender: "", + content: "", +}; diff --git a/src/Model/Files/fileModel.ts b/src/Model/Files/fileModel.ts index 89ee5ff1..8d58e2fc 100644 --- a/src/Model/Files/fileModel.ts +++ b/src/Model/Files/fileModel.ts @@ -65,6 +65,7 @@ export default class FileModel { if (didStore == false) return; this.boardsAndTasksModel.handleFileContent(fileContent); + this.chatModel.handleReaction(fileContent); }; // methods diff --git a/src/Model/Global/storageModel.ts b/src/Model/Global/storageModel.ts index 490bcf48..6dcf536f 100644 --- a/src/Model/Global/storageModel.ts +++ b/src/Model/Global/storageModel.ts @@ -247,6 +247,10 @@ export const filePaths = { info: (id: string) => [...filePaths.chat.chatBase(id), "info"], color: (id: string) => [...filePaths.chat.chatBase(id), "color"], messages: (id: string) => [...filePaths.chat.chatBase(id), "messages"], + reactions: (id: string) => [ + ...filePaths.chat.chatBase(id), + "reactions", + ], lastUsedPage: (id: string) => [ ...filePaths.chat.chatBase(id), "last-used-page", diff --git a/src/ViewModel/Chat/chatMessageViewModel.ts b/src/ViewModel/Chat/chatMessageViewModel.ts index 3a9a79f5..442199eb 100644 --- a/src/ViewModel/Chat/chatMessageViewModel.ts +++ b/src/ViewModel/Chat/chatMessageViewModel.ts @@ -1,6 +1,12 @@ import * as React from "bloatless-react"; -import { ChatMessage, ChatMessageStatus } from "../../Model/Chat/chatModel"; +import { + ChatMessage, + ChatMessageReaction, + ChatMessageReactionReference, + ChatMessageStatus, + ReactionSymbol, +} from "../../Model/Chat/chatModel"; import CoreViewModel from "../Global/coreViewModel"; import MessagePageViewModel from "../Pages/messagePageViewModel"; @@ -19,6 +25,12 @@ export default class ChatMessageViewModel { ); sentByUser: boolean; + reactionsThumbsUp = new React.MapState(); + reactionsCheck = new React.MapState(); + reactionsAttention = new React.MapState(); + reactionsDoubleAttention = new React.MapState(); + reactionsQuestion = new React.MapState(); + // state isPresentingInfoModal: React.State = new React.State(false); @@ -42,6 +54,25 @@ export default class ChatMessageViewModel { this.isPresentingInfoModal.value = false; }; + // reactions + addReaction = (reaction: ChatMessageReaction): void => { + switch (reaction.content) { + case "👍": + return this.reactionsThumbsUp.set(reaction.fileId, reaction); + case "✅": + return this.reactionsCheck.set(reaction.fileId, reaction); + case "❗️": + return this.reactionsAttention.set(reaction.fileId, reaction); + case "‼️": + return this.reactionsDoubleAttention.set( + reaction.fileId, + reaction, + ); + case "❓": + return this.reactionsQuestion.set(reaction.fileId, reaction); + } + }; + // load loadData = (): void => { this.channel = this.chatMessage.channel; diff --git a/src/ViewModel/Chat/chatViewModel.ts b/src/ViewModel/Chat/chatViewModel.ts index e763c869..0fbf46a8 100644 --- a/src/ViewModel/Chat/chatViewModel.ts +++ b/src/ViewModel/Chat/chatViewModel.ts @@ -3,7 +3,10 @@ import * as React from "bloatless-react"; import CalendarPageViewModel, { CALENDAR_EVENT_BOARD_ID, } from "../Pages/calendarPageViewModel"; -import ChatModel, { ChatMessage } from "../../Model/Chat/chatModel"; +import ChatModel, { + ChatMessage, + ChatMessageReaction, +} from "../../Model/Chat/chatModel"; import StorageModel, { StorageModelSubPath, filePaths, @@ -176,6 +179,11 @@ export default class ChatViewModel { this.notificationViewModel.showNotification(chatMessage); }, ); + chatModel.reactionHandlerManager.addHandler( + (reaction: ChatMessageReaction) => { + this.messagePageViewModel.showReaction(reaction); + }, + ); // load this.loadPageSelection(); diff --git a/src/ViewModel/Pages/messagePageViewModel.ts b/src/ViewModel/Pages/messagePageViewModel.ts index 9b024a1b..e80b00cf 100644 --- a/src/ViewModel/Pages/messagePageViewModel.ts +++ b/src/ViewModel/Pages/messagePageViewModel.ts @@ -1,6 +1,10 @@ import * as React from "bloatless-react"; -import { ChatMessage } from "../../Model/Chat/chatModel"; +import { + ChatMessage, + ChatMessageReaction, + ReactionSymbol, +} from "../../Model/Chat/chatModel"; import ChatMessageViewModel from "../Chat/chatMessageViewModel"; import ChatViewModel from "../Chat/chatViewModel"; import CoreViewModel from "../Global/coreViewModel"; @@ -37,6 +41,10 @@ export default class MessagePageViewModel { messageViewModel.loadData(); }; + sendReaction = (messageId: string, content: ReactionSymbol): void => { + this.chatViewModel.chatModel.sendReaction(messageId, content); + }; + // view showChatMessage = (chatMessage: ChatMessage): void => { const chatMessageViewModel = new ChatMessageViewModel( @@ -60,12 +68,22 @@ export default class MessagePageViewModel { } }; + showReaction = (reaction: ChatMessageReaction): void => { + const messageViewModel: ChatMessageViewModel | undefined = + this.chatMessageViewModels.value.get(reaction.messageId); + if (messageViewModel == undefined) return; + messageViewModel.addReaction(reaction); + }; + // load loadData = (): void => { this.chatMessageViewModels.clear(); for (const chatMessage of this.chatViewModel.chatModel.messages) { this.showChatMessage(chatMessage); } + for (const reaction of this.chatViewModel.chatModel.reactions) { + this.showReaction(reaction); + } }; // init