diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 3d4b8f4..310773f 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -56,6 +56,7 @@ "crypto-browserify": "^3.12.0", "mobx": "^6.3.2", "mobx-react-lite": "^3.2.0", + "mobx-sync": "^3.0.0", "react-router-dom": "^5.2.0", "simple-encryptor": "^4.0.0", "socket.io-client": "^4.1.3", diff --git a/packages/plugin/src/Ui.tsx b/packages/plugin/src/Ui.tsx index f7cbc96..aaaf3f1 100644 --- a/packages/plugin/src/Ui.tsx +++ b/packages/plugin/src/Ui.tsx @@ -1,7 +1,12 @@ import Header from '@plugin/components/Header'; import Notifications from '@plugin/components/Notifications'; import { sendMainMessage } from '@plugin/shared/utils'; -import { StoreProvider, useStore } from '@plugin/store'; +import { + getStoreFromMain, + StoreProvider, + trunk, + useStore, +} from '@plugin/store'; import '@plugin/style.css'; import ChatView from '@plugin/views/Chat'; import MinimizedView from '@plugin/views/Minimized'; @@ -22,20 +27,22 @@ import styled, { createGlobalStyle, ThemeProvider } from 'styled-components'; import { SocketProvider } from '@shared/utils/SocketProvider'; import { ConnectionEnum } from '@shared/utils/interfaces'; -onmessage = (message) => { - if (message.data.pluginMessage) { - const { type } = message.data.pluginMessage; +import EventEmitter from './shared/EventEmitter'; - // initialize - if (type === 'ready') { - sendMainMessage('initialize'); - } +// onmessage = (message) => { +// if (message.data.pluginMessage) { +// const { type } = message.data.pluginMessage; - if (type === 'initialize') { - init(); - } - } -}; +// // initialize +// if (type === 'ready') { +// sendMainMessage('initialize'); +// } + +// if (type === 'initialize') { +// init(); +// } +// } +// }; const GlobalStyle = createGlobalStyle` body { @@ -51,133 +58,138 @@ const AppWrapper = styled.div` overflow: hidden; `; -const init = () => { - const App = observer(() => { - const store = useStore(); - const [socket, setSocket] = useState(null); +// const init = () => { +const App = observer(() => { + const store = useStore(); + const [socket, setSocket] = useState(null); - const onFocus = () => { - sendMainMessage('focus', false); - store.setIsFocused(false); - }; + const onFocus = () => { + sendMainMessage('focus', false); + store.setIsFocused(false); + }; - const onFocusOut = () => { - sendMainMessage('focus', false); - store.setIsFocused(false); - }; + const onFocusOut = () => { + sendMainMessage('focus', false); + store.setIsFocused(false); + }; - const initSocketConnection = (url: string) => { - store.setStatus(ConnectionEnum.NONE); + const initSocketConnection = (url: string) => { + store.setStatus(ConnectionEnum.NONE); - if (socket) { - socket.offAny(); - socket.disconnect(); - } - - setSocket( - io(url, { - reconnectionAttempts: 3, - forceNew: true, - transports: ['websocket'], - }) - ); - }; + if (socket) { + socket.offAny(); + socket.disconnect(); + } - useEffect(() => { - if (socket && store.status === ConnectionEnum.NONE) { - sendMainMessage('get-root-data'); - socket.on('connect', () => { - store.setStatus(ConnectionEnum.CONNECTED); + setSocket( + io(url, { + reconnectionAttempts: 3, + forceNew: true, + transports: ['websocket'], + }) + ); + }; + + useEffect(() => { + if (socket && store.status === ConnectionEnum.NONE) { + // sendMainMessage('get-root-data'); + EventEmitter.emit('root-data'); + socket.on('connect', () => { + store.setStatus(ConnectionEnum.CONNECTED); + + socket.emit('set user', store.settings); + socket.emit('join room', { + room: store.roomName, + settings: store.settings, + }); - socket.emit('set user', store.settings); - socket.emit('join room', { - room: store.roomName, - settings: store.settings, - }); + sendMainMessage('ask-for-relaunch-message'); + }); - sendMainMessage('ask-for-relaunch-message'); - }); + socket.io.on('error', () => store.setStatus(ConnectionEnum.ERROR)); - socket.io.on('error', () => store.setStatus(ConnectionEnum.ERROR)); + socket.io.on('reconnect_error', () => + store.setStatus(ConnectionEnum.ERROR) + ); - socket.io.on('reconnect_error', () => - store.setStatus(ConnectionEnum.ERROR) - ); + socket.on('chat message', (data) => { + store.addMessage(data); + }); - socket.on('chat message', (data) => { - store.addMessage(data); - }); + socket.on('join leave message', (data) => { + const username = data.user.name || 'Anon'; + let message = 'joins the conversation'; - socket.on('join leave message', (data) => { - const username = data.user.name || 'Anon'; - let message = 'joins the conversation'; + if (data.type === 'LEAVE') { + message = 'leaves the conversation'; + } + store.addNotification(`${username} ${message}`); + }); - if (data.type === 'LEAVE') { - message = 'leaves the conversation'; - } - store.addNotification(`${username} ${message}`); - }); + socket.on('online', (data) => store.setOnline(data)); + } - socket.on('online', (data) => store.setOnline(data)); + return () => { + if (socket) { + socket.offAny(); + socket.disconnect(); } + }; + }, [socket]); - return () => { - if (socket) { - socket.offAny(); - socket.disconnect(); - } - }; - }, [socket]); + useEffect(() => { + if (store.settings.url) { + initSocketConnection(store.settings.url); + } + // check focus + window.addEventListener('focus', onFocus); + window.addEventListener('blur', onFocusOut); - useEffect(() => { - if (store.settings.url) { - initSocketConnection(store.settings.url); - } - // check focus - window.addEventListener('focus', onFocus); - window.addEventListener('blur', onFocusOut); - - return () => { - window.removeEventListener('focus', onFocus); - window.removeEventListener('blur', onFocusOut); - }; - }, [store.settings.url]); - - return ( - - - - - - - - {store.settings.name &&
} - {store.isMinimized && } - - - - - - - - - - - - - - - - - - + return () => { + window.removeEventListener('focus', onFocus); + window.removeEventListener('blur', onFocusOut); + }; + }, [store.settings.url]); + + return ( + + + + + + + + {store.settings.name &&
} + {store.isMinimized && } + + + + + + + + + + + + + + + + + + + ); +}); + +getStoreFromMain().then((store) => { + trunk.init(store).then(() => { + ReactDOM.render( + + + , + document.getElementById('app') ); }); - - ReactDOM.render( - - - , - document.getElementById('app') - ); -}; +}); +// }; diff --git a/packages/plugin/src/code.ts b/packages/plugin/src/code.ts deleted file mode 100644 index 58a27b4..0000000 --- a/packages/plugin/src/code.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { DEFAULT_SERVER_URL } from '@shared/utils/constants'; -import { generateString } from '@shared/utils/helpers'; - -let isMinimized = false; -let isFocused = true; -let sendNotifications = false; -let triggerSelectionEvent = true; - -const isRelaunch = figma.command === 'relaunch'; - -figma.showUI(__html__, { - width: 333, - height: 490, - // visible: !isRelaunch -}); - -figma.root.setRelaunchData({ - open: '', -}); - -const main = async () => { - const timestamp = +new Date(); - - // random user id for current user - let instanceId = await figma.clientStorage.getAsync('id'); - // figma.root.setPluginData('history', ''); - let history = figma.root.getPluginData('history'); - let roomName = figma.root.getPluginData('roomName'); - let secret = figma.root.getPluginData('secret'); - // const ownerId = figma.root.getPluginData('ownerId'); - - const settings = await figma.clientStorage.getAsync('user-settings'); - - if (!settings || !settings.url) { - await figma.clientStorage.setAsync('user-settings', { - ...settings, - url: DEFAULT_SERVER_URL, - }); - } - - try { - JSON.parse(history); - } catch { - history = ''; - } - - if (!instanceId) { - instanceId = 'user-' + timestamp + '-' + generateString(15); - await figma.clientStorage.setAsync('id', instanceId); - } - - if (!roomName && !secret) { - figma.root.setPluginData('ownerId', instanceId); - } - - if (!roomName) { - const randomRoomName = timestamp + '-' + generateString(15); - figma.root.setPluginData('roomName', randomRoomName); - roomName = randomRoomName; - } - - if (!secret) { - secret = generateString(20); - figma.root.setPluginData('secret', secret); - } - - if (!history) { - history = '[]'; - figma.root.setPluginData('history', history); - } - - // Parse History - try { - history = typeof history === 'string' ? JSON.parse(history) : []; - } catch {} - - return { - roomName, - secret, - history, - instanceId, - settings, - }; -}; - -const postMessage = (type = '', payload = {}) => - figma.ui.postMessage({ - type, - payload, - }); - -const getSelectionIds = () => { - return figma.currentPage.selection.map((n) => n.id); -}; - -const sendSelection = () => { - postMessage('selection', { - page: { - id: figma.currentPage.id, - name: figma.currentPage.name, - }, - nodes: getSelectionIds(), - }); -}; - -const sendRootData = async ({ roomName, secret, history, instanceId }) => { - const settings = await figma.clientStorage.getAsync('user-settings'); - - postMessage('root-data', { - roomName, - secret, - history, - instanceId, - settings, - selection: getSelectionIds(), - }); -}; - -let alreadyAskedForRelaunchMessage = false; - -const isValidShape = (node) => - node.type === 'RECTANGLE' || - node.type === 'ELLIPSE' || - node.type === 'GROUP' || - node.type === 'TEXT' || - node.type === 'VECTOR' || - node.type === 'FRAME' || - node.type === 'COMPONENT' || - node.type === 'INSTANCE' || - node.type === 'POLYGON'; - -const goToPage = (id) => { - if (figma.getNodeById(id)) { - figma.currentPage = figma.getNodeById(id) as PageNode; - } -}; - -let previousSelection = figma.currentPage.selection || []; - -main().then(({ roomName, secret, history, instanceId }) => { - postMessage('ready'); - - // events - figma.on('selectionchange', () => { - if (figma.currentPage.selection.length > 0) { - for (const node of figma.currentPage.selection) { - if (node.setRelaunchData && isValidShape(node)) { - node.setRelaunchData({ - relaunch: '', - }); - } - } - previousSelection = figma.currentPage.selection; - } else { - if (previousSelection.length > 0) { - // tidy up ๐Ÿงน - for (const node of previousSelection) { - if (node.setRelaunchData && isValidShape(node)) { - node.setRelaunchData({}); - } - } - } - } - if (triggerSelectionEvent) { - sendSelection(); - } - }); - - figma.ui.onmessage = async (message) => { - switch (message.action) { - case 'save-user-settings': - await figma.clientStorage.setAsync('user-settings', message.payload); - const settings = await figma.clientStorage.getAsync('user-settings'); - - postMessage('user-settings', settings); - break; - case 'add-message-to-history': - { - const messageHistory = JSON.parse( - figma.root.getPluginData('history') - ); - - figma.root.setPluginData( - 'history', - JSON.stringify(messageHistory.concat(message.payload)) - ); - } - break; - case 'get-history': - { - postMessage( - 'history', - JSON.parse(figma.root.getPluginData('history')) - ); - } - break; - case 'notify': - figma.notify(message.payload); - break; - case 'notification': - if (sendNotifications) { - figma.notify(message.payload); - } - break; - case 'initialize': - postMessage('initialize'); - - sendRootData({ roomName, secret, history, instanceId }); - break; - case 'get-selection': - sendSelection(); - break; - case 'clear-chat-history': - figma.root.setPluginData('history', '[]'); - - postMessage('history', JSON.parse('[]')); - break; - case 'minimize': - isMinimized = message.payload; - sendNotifications = isMinimized; - - // resize window - figma.ui.resize( - message.payload ? 180 : 333, - message.payload ? 108 : 490 - ); - break; - case 'focus': - if (!isMinimized) { - isFocused = message.payload; - - if (!isFocused) { - sendNotifications = true; - } - } - break; - case 'focus-nodes': - let selectedNodes = []; - triggerSelectionEvent = false; - - // fallback for ids - if (message.payload.ids) { - selectedNodes = message.payload.ids; - } else { - goToPage(message.payload?.page?.id); - selectedNodes = message.payload.nodes; - } - - const nodes = figma.currentPage.findAll( - (n) => selectedNodes.indexOf(n.id) !== -1 - ); - - figma.currentPage.selection = nodes; - figma.viewport.scrollAndZoomIntoView(nodes); - - setTimeout(() => (triggerSelectionEvent = true)); - - break; - case 'get-root-data': - sendRootData({ roomName, secret, history, instanceId }); - break; - - case 'ask-for-relaunch-message': - if (isRelaunch && !alreadyAskedForRelaunchMessage) { - alreadyAskedForRelaunchMessage = true; - postMessage('relaunch-message', { - selection: { - page: { - id: figma.currentPage.id, - name: figma.currentPage.name, - }, - nodes: getSelectionIds(), - }, - }); - } - break; - case 'cancel': - figma.closePlugin(); - break; - } - }; -}); diff --git a/packages/plugin/src/main/index.ts b/packages/plugin/src/main/index.ts new file mode 100644 index 0000000..6d409b2 --- /dev/null +++ b/packages/plugin/src/main/index.ts @@ -0,0 +1,264 @@ +import './store'; +import { DEFAULT_SERVER_URL } from '@shared/utils/constants'; +import { generateString } from '@shared/utils/helpers'; + +import EventEmitter from '../shared/EventEmitter'; + +let isMinimized = false; +let isFocused = true; +let sendNotifications = false; +let triggerSelectionEvent = true; + +const isRelaunch = figma.command === 'relaunch'; + +figma.showUI(__html__, { + width: 333, + height: 490, + // visible: !isRelaunch +}); + +figma.root.setRelaunchData({ + open: '', +}); + +const main = async () => { + const timestamp = +new Date(); + + // random user id for current user + let instanceId = await figma.clientStorage.getAsync('id'); + let history = figma.root.getPluginData('history'); + let roomName = figma.root.getPluginData('roomName'); + let secret = figma.root.getPluginData('secret'); + // const ownerId = figma.root.getPluginData('ownerId'); + + const settings = await figma.clientStorage.getAsync('user-settings'); + + if (!settings || !settings.url) { + await figma.clientStorage.setAsync('user-settings', { + ...settings, + url: DEFAULT_SERVER_URL, + }); + } + + try { + JSON.parse(history); + } catch { + history = ''; + } + + if (!instanceId) { + instanceId = 'user-' + timestamp + '-' + generateString(15); + await figma.clientStorage.setAsync('id', instanceId); + } + + if (!roomName && !secret) { + figma.root.setPluginData('ownerId', instanceId); + } + + if (!roomName) { + const randomRoomName = timestamp + '-' + generateString(15); + figma.root.setPluginData('roomName', randomRoomName); + roomName = randomRoomName; + } + + if (!secret) { + secret = generateString(20); + figma.root.setPluginData('secret', secret); + } + + if (!history) { + history = '[]'; + figma.root.setPluginData('history', history); + } + + // Parse History + try { + history = typeof history === 'string' ? JSON.parse(history) : []; + } catch {} + + return { + roomName, + secret, + history, + instanceId, + settings, + }; +}; + +const postMessage = (type = '', payload = {}) => + figma.ui.postMessage({ + type, + payload, + }); + +const getSelectionIds = () => figma.currentPage.selection.map((n) => n.id); + +const sendSelection = () => { + EventEmitter.emit('selection', { + page: { + id: figma.currentPage.id, + name: figma.currentPage.name, + }, + nodes: getSelectionIds(), + }); +}; + +const sendRootData = async ({ roomName, secret, history, instanceId }) => { + const settings = await figma.clientStorage.getAsync('user-settings'); + + postMessage('root-data', { + roomName, + secret, + history, + instanceId, + settings, + selection: getSelectionIds(), + }); +}; + +let alreadyAskedForRelaunchMessage = false; + +const isValidShape = (node) => + node.type === 'RECTANGLE' || + node.type === 'ELLIPSE' || + node.type === 'GROUP' || + node.type === 'TEXT' || + node.type === 'VECTOR' || + node.type === 'FRAME' || + node.type === 'COMPONENT' || + node.type === 'INSTANCE' || + node.type === 'POLYGON'; + +const goToPage = (id) => { + if (figma.getNodeById(id)) { + figma.currentPage = figma.getNodeById(id) as PageNode; + } +}; + +let previousSelection = figma.currentPage.selection || []; + +EventEmitter.on('clear-chat-history', () => { + figma.root.setPluginData('history', '[]'); + + postMessage('history', JSON.parse('[]')); +}); + +EventEmitter.on('minimize', (flag) => { + isMinimized = flag; + sendNotifications = isMinimized; + + // resize window + figma.ui.resize(flag ? 180 : 333, flag ? 108 : 490); +}); + +EventEmitter.on('save-user-settings', async (payload, emit) => { + await figma.clientStorage.setAsync('user-settings', payload); + const settings = await figma.clientStorage.getAsync('user-settings'); + + emit('user-settings', settings); +}); + +EventEmitter.on('add-message-to-history', (payload) => { + const messageHistory = JSON.parse(figma.root.getPluginData('history')); + + figma.root.setPluginData( + 'history', + JSON.stringify(messageHistory.concat(payload)) + ); +}); + +EventEmitter.answer( + 'get-history', + JSON.parse(figma.root.getPluginData('history')) +); + +EventEmitter.on('notify', (payload) => { + figma.notify(payload); +}); + +EventEmitter.on('notification', (payload) => { + if (sendNotifications) { + figma.notify(payload); + } +}); + +EventEmitter.on('root-data', async (_, emit) => { + const { roomName, secret, history, instanceId } = await main(); + + emit('root-data', { roomName, secret, history, instanceId }); +}); + +EventEmitter.on('focus', (payload) => { + if (!isMinimized) { + isFocused = payload; + + if (!isFocused) { + sendNotifications = true; + } + } +}); + +EventEmitter.on('focus-nodes', (payload) => { + let selectedNodes = []; + triggerSelectionEvent = false; + + // fallback for ids + if (payload.ids) { + selectedNodes = payload.ids; + } else { + goToPage(payload?.page?.id); + selectedNodes = payload.nodes; + } + + const nodes = figma.currentPage.findAll( + (n) => selectedNodes.indexOf(n.id) !== -1 + ); + + figma.currentPage.selection = nodes; + figma.viewport.scrollAndZoomIntoView(nodes); + + setTimeout(() => (triggerSelectionEvent = true)); +}); + +EventEmitter.on('ask-for-relaunch-message', (_, emit) => { + if (isRelaunch && !alreadyAskedForRelaunchMessage) { + alreadyAskedForRelaunchMessage = true; + emit('relaunch-message', { + selection: { + page: { + id: figma.currentPage.id, + name: figma.currentPage.name, + }, + nodes: getSelectionIds(), + }, + }); + } +}); + +EventEmitter.on('cancel', () => {}); + +// events +figma.on('selectionchange', () => { + if (figma.currentPage.selection.length > 0) { + for (const node of figma.currentPage.selection) { + if (node.setRelaunchData && isValidShape(node)) { + node.setRelaunchData({ + relaunch: '', + }); + } + } + previousSelection = figma.currentPage.selection; + } else { + if (previousSelection.length > 0) { + // tidy up ๐Ÿงน + for (const node of previousSelection) { + if (node.setRelaunchData && isValidShape(node)) { + node.setRelaunchData({}); + } + } + } + } + if (triggerSelectionEvent) { + sendSelection(); + } +}); diff --git a/packages/plugin/src/main/store.ts b/packages/plugin/src/main/store.ts new file mode 100644 index 0000000..c6bbcff --- /dev/null +++ b/packages/plugin/src/main/store.ts @@ -0,0 +1,38 @@ +import EventEmitter from '../shared/EventEmitter'; + +export const getState = async () => + JSON.parse(await figma.clientStorage.getAsync('figma-chat')); + +EventEmitter.on('storage', async (key, send) => { + try { + send('storage', await figma.clientStorage.getAsync(key)); + } catch { + send('storage', '{}'); + } +}); + +EventEmitter.on('storage set item', ({ key, value }, send) => { + figma.clientStorage.setAsync(key, value); + + send('storage set item', true); +}); + +EventEmitter.on('storage get item', async (key, send) => { + try { + const store = await figma.clientStorage.getAsync(key); + + send('storage get item', store[key]); + } catch { + send('storage get item', false); + } +}); + +EventEmitter.on('storage remove item', async (key, send) => { + try { + await figma.clientStorage.setAsync(key, undefined); + + send('storage remove item', true); + } catch { + send('storage remove item', false); + } +}); diff --git a/packages/plugin/src/shared/EventEmitter.ts b/packages/plugin/src/shared/EventEmitter.ts new file mode 100644 index 0000000..4ed380e --- /dev/null +++ b/packages/plugin/src/shared/EventEmitter.ts @@ -0,0 +1,131 @@ +/** + * An structured way to handle renderer and main messages + */ +class EventEmitter { + messageEvent = new Map(); + emit: ( + name: string, + data?: + | Record + | number + | string + | Uint8Array + | unknown[] + | boolean + ) => void; + + constructor() { + // MAIN PROCESS + try { + this.emit = (name, data) => { + figma.ui.postMessage({ + name, + data: data || null, + }); + }; + + figma.ui.onmessage = (event) => { + if (this.messageEvent.has(event.name)) { + this.messageEvent.get(event.name)(event.data, this.emit); + } + }; + } catch { + // we ignore the error, because it only says, that "figma" is undefined + // RENDERER PROCESS + onmessage = (event) => { + if (this.messageEvent.has(event.data.pluginMessage.name)) { + this.messageEvent.get(event.data.pluginMessage.name)( + event.data.pluginMessage.data, + this.emit + ); + } + }; + + this.emit = (name = '', data = {}) => { + parent.postMessage( + { + pluginMessage: { + name, + data: data || null, + }, + }, + '*' + ); + }; + } + } + + /** + * This method emits a message to main or renderer + * @param name string + * @param callback function + */ + on(name, callback) { + this.messageEvent.set(name, callback); + + return () => this.remove(name); + } + + /** + * Listen to a message once + * @param name + * @param callback + */ + once(name, callback) { + const remove = this.on(name, (data, emit) => { + callback(data, emit); + remove(); + }); + } + + /** + * Ask for data + * @param name + */ + ask(name, data = undefined) { + this.emit(name, data); + + return new Promise((resolve) => this.once(name, resolve)); + } + + /** + * Answer data from "ask" + * @param name + * @param functionOrValue + */ + answer(name, functionOrValue) { + this.on(name, (incomingData, emit) => { + if (this.isAsyncFunction(functionOrValue)) { + functionOrValue(incomingData).then((data) => emit(name, data)); + } else if (typeof functionOrValue === 'function') { + emit(name, functionOrValue(incomingData)); + } else { + emit(name, functionOrValue); + } + }); + } + + /** + * Remove and active listener + * @param name + */ + remove(name) { + if (this.messageEvent.has(name)) { + this.messageEvent.delete(name); + } + } + + /** + * This function checks if it is asynchronous or not + * @param func + */ + isAsyncFunction(func) { + func = func.toString().trim(); + + return ( + func.match('__awaiter') || func.match('function*') || func.match('async') + ); + } +} + +export default new EventEmitter(); diff --git a/packages/plugin/src/store/index.tsx b/packages/plugin/src/store/index.tsx index 078d608..70922ee 100644 --- a/packages/plugin/src/store/index.tsx +++ b/packages/plugin/src/store/index.tsx @@ -1,4 +1,6 @@ +import EventEmitter from '@plugin/shared/EventEmitter'; import { makeAutoObservable, toJS } from 'mobx'; +import { AsyncTrunk, ignore } from 'mobx-sync'; import React, { createRef } from 'react'; import { createEncryptor } from 'simple-encryptor'; import { DefaultTheme } from 'styled-components'; @@ -7,8 +9,6 @@ import MessageSound from '@shared/assets/sound.mp3'; import { ConnectionEnum } from '@shared/utils/interfaces'; import { darkTheme, lightTheme } from '@shared/utils/theme'; -import { sendMainMessage } from '../shared/utils'; - interface StoreSettings { name: string; avatar: string; @@ -18,7 +18,7 @@ interface StoreSettings { enableNotificationSound: boolean; isDarkTheme: boolean; } -class RootStore { +export class RootStore { constructor() { makeAutoObservable(this); } @@ -33,6 +33,7 @@ class RootStore { instanceId = ''; online = []; messages = []; + @ignore messagesRef = createRef(); autoScrollDisabled = false; selection = undefined; @@ -138,7 +139,7 @@ class RootStore { // --- toggleMinimizeChat() { this.isMinimized = !this.isMinimized; - sendMainMessage('minimize', this.isMinimized); + EventEmitter.emit('minimize', this.isMinimized); } clearChatHistory(cb: () => void) { @@ -147,7 +148,8 @@ class RootStore { 'Do you really want to delete the complete chat history? (This cannot be undone)' ) ) { - sendMainMessage('clear-chat-history'); + EventEmitter.emit('clear-chat-history'); + this.messages = []; cb(); this.addNotification('Chat history successfully deleted'); @@ -163,7 +165,7 @@ class RootStore { }; // save user settings in main - sendMainMessage('save-user-settings', { ...toJS(this.settings) }); + EventEmitter.emit('save-user-settings', { ...toJS(this.settings) }); // set server URL if (!isInit && settings.url && settings.url !== oldUrl) { @@ -182,8 +184,6 @@ class RootStore { isLocal ? messageData : messageData.message ); - console.log(messageData, decryptedMessage); - // silent on error try { const data = JSON.parse(decryptedMessage); @@ -207,7 +207,7 @@ class RootStore { }, }; - sendMainMessage('add-message-to-history', newMessage); + EventEmitter.emit('add-message-to-history', newMessage); } else { newMessage = { id: messageData.id, @@ -218,7 +218,7 @@ class RootStore { }; if (data.external) { - sendMainMessage('add-message-to-history', newMessage); + EventEmitter.emit('add-message-to-history', newMessage); } if (this.settings.enableNotificationSound) { @@ -234,7 +234,7 @@ class RootStore { : data.text; } - sendMainMessage( + EventEmitter.emit( 'notification', messageData?.user?.name ? `${text} ยท ${messageData.user.avatar} ${messageData.user.name}` @@ -252,18 +252,13 @@ class RootStore { } } -export const createStore = () => new RootStore(); +export const rootStore = new RootStore(); -export type TStore = ReturnType; +const StoreContext = React.createContext(null); -const StoreContext = React.createContext(null); - -export const StoreProvider = ({ children }) => { - const store = createStore(); - return ( - {children} - ); -}; +export const StoreProvider = ({ children }) => ( + {children} +); export const useStore = () => { const store = React.useContext(StoreContext); @@ -272,3 +267,38 @@ export const useStore = () => { } return store; }; + +export const trunk = new AsyncTrunk(rootStore, { + storageKey: 'figma-chat', + storage: { + getItem: (key: string) => { + EventEmitter.emit('storage get item', key); + return new Promise((resolve) => + EventEmitter.once('storage get item', resolve) + ); + }, + setItem: (key: string, value: string) => { + EventEmitter.emit('storage set item', { + key, + value, + }); + return new Promise((resolve) => + EventEmitter.once('storage set item', resolve) + ); + }, + removeItem: (key: string) => { + EventEmitter.emit('storage remove item', key); + return new Promise((resolve) => + EventEmitter.once('storage remove item', resolve) + ); + }, + }, +}); + +export const getStoreFromMain = (): Promise => + new Promise((resolve) => { + EventEmitter.emit('storage', 'figma-chat'); + EventEmitter.once('storage', (store) => { + resolve(JSON.parse(store || '{}')); + }); + }); diff --git a/packages/plugin/src/views/Chat/index.tsx b/packages/plugin/src/views/Chat/index.tsx index 8e34348..60ca5a5 100644 --- a/packages/plugin/src/views/Chat/index.tsx +++ b/packages/plugin/src/views/Chat/index.tsx @@ -1,3 +1,4 @@ +import EventEmitter from '@plugin/shared/EventEmitter'; import { toJS } from 'mobx'; import { observer, useLocalObservable } from 'mobx-react-lite'; import React, { useEffect, FunctionComponent } from 'react'; @@ -81,59 +82,59 @@ const ChatView: FunctionComponent = observer(() => { } }; - // All messages from main - onmessage = (message) => { - const pmessage = message.data.pluginMessage; + useEffect(() => { + EventEmitter.on('selection', (selection) => { + const hasSelection = + selection?.length > 0 || selection?.nodes?.length > 0; - if (pmessage) { - // set selection - if (pmessage.type === 'selection') { - const hasSelection = - pmessage.payload?.length > 0 || pmessage.payload?.nodes?.length > 0; + store.setSelection(hasSelection ? selection : {}); - store.setSelection(hasSelection ? pmessage.payload : {}); - - if (!hasSelection) { - chatState.selectionIsChecked = false; - } + if (!hasSelection) { + chatState.selectionIsChecked = false; } + }); + + EventEmitter.on('root-data', (data) => { + const { + roomName: dataRoomName = '', + secret: dataSecret = '', + history: messages = [], + selection = { + page: '', + nodes: [], + }, + settings = {}, + instanceId = '', + } = { + ...data, + }; - if (pmessage.type === 'root-data') { - const { - roomName: dataRoomName = '', - secret: dataSecret = '', - history: messages = [], - selection = { - page: '', - nodes: [], - }, - settings = {}, - instanceId = '', - } = { - ...pmessage.payload, - }; - - store.setSecret(dataSecret); - store.setRoomName(dataRoomName); - store.setMessages(messages); - store.setInstanceId(instanceId); - store.setSelection(selection); + store.setSecret(dataSecret); + store.setRoomName(dataRoomName); + store.setMessages(messages); + store.setInstanceId(instanceId); + store.setSelection(selection); - store.persistSettings(settings, socket, true); - } + store.persistSettings(settings, socket, true); + }); - if (pmessage.type === 'relaunch-message') { - chatState.selectionIsChecked = true; + EventEmitter.on('relaunch-message', (data) => { + chatState.selectionIsChecked = true; - store.setSelection(pmessage.payload.selection || {}); + store.setSelection(data.selection || {}); - if (store.selectionCount) { - sendMessage(); - sendMainMessage('notify', 'Selection sent successfully'); - } + if (store.selectionCount) { + sendMessage(); + sendMainMessage('notify', 'Selection sent successfully'); } - } - }; + }); + + return () => { + EventEmitter.remove('selection'); + EventEmitter.remove('root-data'); + EventEmitter.remove('relaunch-message'); + }; + }, []); useEffect(() => store.scrollToBottom(), [store.messages]); diff --git a/packages/plugin/webpack.config.js b/packages/plugin/webpack.config.js index 40edc0d..4b6de74 100644 --- a/packages/plugin/webpack.config.js +++ b/packages/plugin/webpack.config.js @@ -28,7 +28,7 @@ module.exports = (env, argv) => ({ }, entry: { ui: './src/Ui.tsx', - code: './src/code.ts', + code: './src/main/index.ts', }, watchOptions: { ignored: ['node_modules/**'], diff --git a/packages/shared/src/components/ChatBar.tsx b/packages/shared/src/components/ChatBar.tsx new file mode 100644 index 0000000..d42bc67 --- /dev/null +++ b/packages/shared/src/components/ChatBar.tsx @@ -0,0 +1,236 @@ +import { autorun } from 'mobx'; +import { observer } from 'mobx-react-lite'; +import React, { FunctionComponent, useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; + +import EmojiIcon from '@shared/assets/icons/EmojiIcon'; +import SendArrowIcon from '@shared/assets/icons/SendArrowIcon'; +import Tooltip from '@shared/components/Tooltip'; +import { useSocket } from '@shared/utils/SocketProvider'; +import { ConnectionEnum } from '@shared/utils/interfaces'; + +import { useStore } from '../../../store/RootStore'; + +export const ChatBar: FunctionComponent = observer(() => { + const store = useStore(); + const socket = useSocket(); + const emojiPickerRef = useRef>(null); + const chatTextInput = useRef(null); + const [messageText, setMessageText] = useState(''); + + const [isFailed, setIsFailed] = useState( + store.status === ConnectionEnum.ERROR + ); + const [connected, setConnected] = useState( + store.status === ConnectionEnum.CONNECTED + ); + + useEffect( + () => + autorun(() => { + setIsFailed(store.status === ConnectionEnum.ERROR); + setConnected(store.status === ConnectionEnum.CONNECTED); + }), + [] + ); + + const sendMessage = (e: any, msg?: string) => { + e.preventDefault(); + + const text = msg || messageText; + + if (socket && chatTextInput.current && text) { + const message = store.encryptor.encrypt( + JSON.stringify({ + text, + date: new Date(), + external: true, + }) + ); + + socket.emit('chat message', { + roomName: store.room, + message, + }); + + store.addLocalMessage(message); + + chatTextInput.current.value = ''; + setMessageText(''); + } + }; + + return ( + + + {isFailed ? 'connection failed ๐Ÿ™ˆ' : 'connecting...'} + + + + + + setMessageText(target.value.substr(0, 1000)) + } + placeholder="Write something ..." + /> + + ) => ( + + + + ) + )} + > + + {['๐Ÿ˜‚', '๐Ÿ˜Š', '๐Ÿ‘', '๐Ÿ™ˆ', '๐Ÿ”ฅ', '๐Ÿค”', '๐Ÿ’ฉ'].map((emoji) => ( + { + sendMessage(e, emoji); + emojiPickerRef.current.hide(); + }} + /> + ))} + + + + + + + + + + ); +}); + +const EmojiList = styled.div` + display: flex; + font-size: 25px; + width: 240px; + justify-content: space-between; + span { + cursor: pointer; + &::after { + content: attr(data-emoji); + } + } +`; + +const EmojiPickerStyled = styled.div` + position: absolute; + right: 51px; + top: 11px; + z-index: 5; + cursor: pointer; +`; + +const ConnectionInfo = styled.div<{ connected: boolean }>` + display: flex; + justify-content: center; + align-items: center; + position: absolute; + left: 0; + top: 0; + width: 100%; + z-index: 6; + bottom: -5px; + text-align: center; + color: ${(p) => p.theme.fontColor}; + font-weight: bold; + transition: opacity 0.2s; + opacity: ${(props) => (props.connected ? 0 : 1)}; + z-index: ${(props) => (props.connected ? -1 : 1)}; + span { + text-decoration: underline; + cursor: pointer; + margin-left: 5px; + } +`; + +const ChatBarForm = styled.form` + padding: 0 20px 20px 20px; + z-index: 3; + margin: 0; + transition: opacity 0.2s; + position: relative; +`; + +const ChatInputWrapper = styled.div<{ connected: boolean }>` + display: flex; + transition: opacity 0.3s; + opacity: ${(props) => (props.connected ? 1 : 0)}; + position: relative; +`; + +const ChatInput = styled.div` + display: flex; + margin: 0; + z-index: 3; + transition: width 0.3s; + background-color: ${(p) => p.theme.secondaryBackgroundColor}; + border-radius: 10px 10px 0 10px; + width: 100%; + + input { + background-color: transparent; + z-index: 2; + font-size: 11.5px; + font-weight: 400; + border-radius: 6px; + width: 100%; + border: 0; + padding: 14px 82px 14px 18px; + height: 41px; + outline: none; + color: ${(p) => p.theme.fontColor}; + &::placeholder { + color: ${(p) => p.theme.placeholder}; + } + } + + button { + border: 0; + padding: 6px 5px; + margin: 0; + background-color: transparent; + outline: none; + cursor: pointer; + &:hover { + .icon { + background-color: rgba(0, 0, 0, 0.06); + cursor: pointer; + border-radius: 5px; + } + } + } +`; + +const SendButton = styled.div` + position: absolute; + display: flex; + z-index: 3; + cursor: pointer; + right: 4px; + top: 4px; + background-color: ${(props) => props.color}; + width: 33px; + height: 33px; + border-radius: 9px 9px 4px 9px; + justify-content: center; + svg { + align-self: center; + } +`; diff --git a/packages/shared/src/store/index.tsx b/packages/shared/src/store/index.tsx new file mode 100644 index 0000000..5b59a21 --- /dev/null +++ b/packages/shared/src/store/index.tsx @@ -0,0 +1,173 @@ +import { makeAutoObservable, toJS } from 'mobx'; +import { AsyncTrunk } from 'mobx-sync'; +import React, { FunctionComponent, createRef } from 'react'; +import { createEncryptor } from 'simple-encryptor'; +import { DefaultTheme } from 'styled-components'; + +import MessageSound from '@shared/assets/sound.mp3'; +import { EColors } from '@shared/utils/constants'; +import { + ConnectionEnum, + MessageData, + StoreSettings, +} from '@shared/utils/interfaces'; +import { darkTheme, lightTheme } from '@shared/utils/theme'; + +class RootStore { + url = 'https://figma-chat.ph1p.dev'; + room = ''; + secret = ''; + instanceId = ''; + status = ConnectionEnum.NONE; + settings: StoreSettings = { + name: '', + avatar: '', + color: '#4F4F4F', + url: '', + enableNotificationTooltip: true, + enableNotificationSound: true, + isDarkTheme: false, + }; + online: any[] = []; + messages: any[] = []; + messagesRef = createRef(); + autoScrollDisabled = false; + + constructor() { + makeAutoObservable(this); + } + + disableAutoScroll(autoScrollDisabled: boolean) { + this.autoScrollDisabled = autoScrollDisabled; + } + + setStatus(status: ConnectionEnum) { + this.status = status; + } + + setUrl(url: string) { + this.url = url; + } + + setInstanceId(instanceId: string) { + this.instanceId = instanceId; + } + + setMessagesRef(messagesRef: HTMLDivElement) { + this.messagesRef = { + current: messagesRef, + }; + } + + setIsDarkTheme(isDarkTheme: boolean) { + this.settings.isDarkTheme = isDarkTheme; + } + + setSecret(secret: string) { + this.secret = secret; + } + + setOnline(online: any[]) { + this.online = online; + } + + addLocalMessage(messageData: string) { + const decryptedMessage = this.encryptor.decrypt(messageData); + + try { + const data = JSON.parse(decryptedMessage); + const newMessage: MessageData = { + id: this.instanceId, + user: { + avatar: this.settings.avatar, + color: this.settings.color as keyof typeof EColors, + name: this.settings.name, + }, + message: { + ...data, + }, + }; + + this.messages.push(newMessage); + + setTimeout(() => this.scrollToBottom(), 0); + } catch (e) { + console.log(e); + } + } + + addReceivedMessage(messageData: MessageData) { + const decryptedMessage = this.encryptor.decrypt( + messageData.message as unknown as string + ); + + // silent on error + try { + const data = JSON.parse(decryptedMessage); + const newMessage: MessageData = { + id: messageData.id, + user: messageData.user, + message: { + ...data, + }, + }; + + if (this.settings.enableNotificationSound) { + const audio = new Audio(MessageSound); + audio.play(); + } + + this.messages.push(newMessage); + + setTimeout(() => this.scrollToBottom(), 0); + } catch (e) { + console.log(e); + } + } + + scrollToBottom() { + if (!this.autoScrollDisabled) { + const ref = toJS(this.messagesRef); + // scroll to bottom + if (ref?.current) { + ref.current.scrollTop = ref.current.scrollHeight; + } + } + } + + get theme(): DefaultTheme { + return this.settings.isDarkTheme ? darkTheme : lightTheme; + } + + get encryptor() { + return createEncryptor(this.secret); + } +} + +const rootStore = new RootStore(); + +export const trunk = new AsyncTrunk(rootStore, { + storage: localStorage, + storageKey: 'figma-chat', +}); + +const StoreContext = React.createContext(null); + +export const StoreProvider: FunctionComponent = (props) => { + return ( + + {props.children} + + ); +}; + +export const useStore = () => { + const store = React.useContext(StoreContext); + + if (!store) { + throw new Error('useStore must be used within a StoreProvider.'); + } + return store; +}; + +export default rootStore; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 2044166..5509aae 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,5 +1,4 @@ import { Chat } from '@web/views/Chat'; -import React from 'react'; import { Route, BrowserRouter as Router, Switch } from 'react-router-dom'; import styled from 'styled-components'; diff --git a/yarn.lock b/yarn.lock index 4c93f6f..bbe61ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3378,12 +3378,7 @@ bluebird@^3.5.5: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== -bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9: - version "4.12.0" - resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" - integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== - -bn.js@^5.0.0, bn.js@^5.1.1: +bn.js@5.2.0, bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9, bn.js@^5.0.0, bn.js@^5.1.1: version "5.2.0" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.0.tgz#358860674396c6997771a9d051fcc1b57d4ae002" integrity sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw==