UNPKG

fbz

Version:

Fork of the OpenBazaar 2.0 browser-based client.

1,027 lines (864 loc) 26.5 kB
import { omit, orderBy, } from 'lodash'; import arrayMove from 'array-move'; import multihashes from 'multihashes'; import crypto from 'crypto'; import { get as getDb } from 'util/database'; import { eventChannel, END } from 'redux-saga' import { takeEvery, put, call, select, fork, } from 'redux-saga/effects'; import { animationFrameInterval } from 'util/index'; import { sendMessage as sendChatMessage } from 'util/messaging/index'; import messageTypes from 'util/messaging/types'; import { convosRequest, convosSuccess, convosFail, convoChange, activateConvo, convoActivated, convoMessagesRequest, convoMessagesSuccess, convoMessagesFail, messageDbChange, activeConvoMessagesChange, sendMessage, cancelMessage, convoMarkRead, } from 'actions/chat'; import { directMessage } from 'actions/messaging'; import { AUTH_LOGOUT } from 'actions/auth'; let _chatData = null; let _gettingChatData = null; window.muchData = () => { const promises = []; for (var i = 0; i < 1400; i++) { promises.push(() => window.inboundChatMessage()); } promises.reduce( async (previousPromise, nextProm) => { await previousPromise; return nextProm(); }, Promise.resolve()); } const _cloneConvo = (base = {}) => { return { messages: { ...base.messages } || {}, unread: base.unread || 0, _sorted: base._sorted, get sorted() { if (!this._sorted) { this._sorted = orderBy( Object.keys(this.messages).map(messageID => ({ messageID, timestamp: this.messages[messageID].timestamp, })), ['timestamp'], ['asc'] ).map(message => message.messageID); } return this._sorted; }, set sorted(arr) { this._sorted = arr; }, }; }; const _removeMessage = (peerID, messageID) => { if ( !_chatData || !_chatData[peerID] || !_chatData[peerID].messages || !_chatData[peerID].messages[messageID] ) return; const convo = _cloneConvo(_chatData[peerID]); delete convo.messages[messageID]; if ( convo._sorted && convo._sorted.includes(messageID) ) { const newSorted = [...convo._sorted]; newSorted.splice(newSorted.indexOf(messageID), 1); convo._sorted = newSorted; } if (!Object.keys(convo.messages).length) { delete _chatData[peerID]; } else { _chatData[peerID] = convo; } } const _setMessage = (peerID, message) => { const convo = _cloneConvo(_chatData[peerID]); const prevMessage = convo.messages[message.messageID]; const curMessage = { ...prevMessage, ...message }; if (!curMessage.outgoing) { if ((prevMessage && prevMessage.read !== curMessage.read)) { if (curMessage.read && convo.unread > 0) { convo.unread -= 1; } else if (!curMessage.read) { convo.unread += 1; } } else if (!prevMessage && !curMessage.read) { convo.unread += 1; } } const _sorted = convo._sorted; if (_sorted && (!prevMessage || prevMessage.timestamp !== curMessage.timestamp)) { // If it's a new message or the timestamp changed and there was already a sorted // cached list, we'll try and insert the new message in the right place starting // from the bottom, since the vast majority of new messages should be at the end // of list. let i = _sorted.length; while (i > 0 && curMessage.timestamp < convo.messages[_sorted[i - 1]].timestamp) { i--; } if (_sorted.includes(message.messageID)) { convo.sorted = arrayMove(convo.sorted, _sorted.indexOf(message.messageID), i); } else { convo.sorted = [..._sorted.slice(0, i), curMessage.messageID, ..._sorted.slice(i)]; } } convo.messages[curMessage.messageID] = curMessage; _chatData[peerID] = convo; } const createTimeoutChannel = time => eventChannel(emitter => { const timeout = setTimeout(() => { emitter('something'); emitter(END); }, time); return () => { clearInterval(timeout) } } ); let pendingActiveConvoMessageChange = null; /* * This method will dispatch an activeConvoMessagesChange and conditionally * debounce it. If the changed data is a message being marked as read or * vice-versa, then any such changes will be debounced into a single change * action that contains the cumulitive changed data. Otherwise, the change data * will immediatally be dispatched. */ const dispatchActiveConvoMessagesChangeAction = function* (payload) { if (!payload.unreadUpdate) { // If it's not an update of the read bool, fire the action right away. yield put(activeConvoMessagesChange(payload)); } else { if (pendingActiveConvoMessageChange) { pendingActiveConvoMessageChange.channel.close(); } pendingActiveConvoMessageChange = pendingActiveConvoMessageChange || {}; pendingActiveConvoMessageChange.channel = yield call(createTimeoutChannel, 100); const prevPayload = { ...pendingActiveConvoMessageChange.payload } || {}; pendingActiveConvoMessageChange.payload = { messages: { ...prevPayload.messages, ...payload.messages, }, removed: [ ...new Set( [...prevPayload.removed || [], ...payload.removed || []] ) ], } if (payload.sorted) { pendingActiveConvoMessageChange.payload.sorted = payload.sorted; } else if ( pendingActiveConvoMessageChange.payload && pendingActiveConvoMessageChange.payload.sorted ) { pendingActiveConvoMessageChange.payload.sorted = prevPayload.sorted; } yield takeEvery(pendingActiveConvoMessageChange.channel, function* () { yield put(activeConvoMessagesChange(pendingActiveConvoMessageChange.payload)); }); } } const convoChangeChannels = {}; const setMessage = function* (message, options = {}) { yield call(getChatData); const opts = { remove: false, ...options, }; let messageID; let peerID = message ? message.peerID : null; if (typeof message !== 'object') { throw new Error('A message should be provided as an object.'); } if (typeof message.messageID !== 'string' || !message.messageID) { throw new Error('The message object must contain a messageID string.'); } else { messageID = message.messageID; } if (!opts.remove) { if (Object.keys(message).length < 2) { throw new Error('The message object should contain at least one property ' + 'in addition to the messageID.'); } } if (!peerID) { const peerData = Object.keys(_chatData) .find(peer => { const message = _chatData[peer] && _chatData[peer].messages ? _chatData[peer].messages[messageID] : null; if (message) { peerID = peer; } return !!message; }); if (!peerData) { if (opts.remove) return; throw new Error('Unable to find the peer for the given messageID. If this is ' + 'a new message, please pass in the peerID.'); } } const state = yield select(); const chatData = yield call(getChatData); const prevConvo = chatData[peerID]; opts.remove ? _removeMessage(peerID, messageID) : _setMessage(peerID, message); const curConvo = chatData[peerID]; const isUpdate = !!( !opts.remove && prevConvo && prevConvo.messages[messageID] ); const isInsert = !opts.remove && !isUpdate; let convoChangeData; const setConvoChangeData = (topLevel = {}, data = {}) => { convoChangeData = { peerID, removed: false, ...convoChangeData, ...topLevel, data: { ...(convoChangeData && convoChangeData.data) || {}, ...data, }, }; } if (!curConvo) { if (prevConvo) { setConvoChangeData({ removed: true }); } } else { if (!prevConvo || prevConvo.unread !== curConvo.unread) { setConvoChangeData({}, { unread: curConvo.unread }); } const prevLastMessage = prevConvo ? prevConvo.sorted[prevConvo.sorted.length - 1] : null; const curLastMessage = curConvo ? curConvo.sorted[curConvo.sorted.length - 1] : null; if (prevLastMessage !== curLastMessage) { setConvoChangeData( { message: curConvo.messages[curLastMessage] }, { lastMessage: curLastMessage } ); } } let activeConvoMessageChangeData; const setActiveConvoMessageChangeData = (data = {}) => { activeConvoMessageChangeData = { removed: [], ...data, unreadUpdate: isUpdate && prevConvo.messages[messageID].read !== curConvo.messages[messageID].read }; } let activeConvoPeerID; try { activeConvoPeerID = state.chat.activeConvo.peerID; } catch { // pass } // For efficiency purposes, there is no actual checking if the message // changed. The assumption is if you're calling this method it's with // changed message data. if (activeConvoPeerID === peerID) { if (options.remove) { const data = { removed: [ messageID ] }; if (curConvo) { data.sorted = curConvo.sorted; } setActiveConvoMessageChangeData(data); } else { const data = { messages: { [message.messageID]: { ...curConvo.messages[messageID], } }, }; if ( isInsert || prevConvo.messages[messageID].timestamp !== curConvo.messages[messageID].timestamp ) { data.sorted = curConvo.sorted; } setActiveConvoMessageChangeData(data); } } if (convoChangeData) { // We will debounce the convoChange action so, for example, if you // mark a convo as read with 1000 unread messages, it results in only // a single convoChange action. if (convoChangeChannels[peerID]) { convoChangeChannels[peerID].close(); } convoChangeChannels[peerID] = yield call(createTimeoutChannel, 100); yield takeEvery(convoChangeChannels[peerID], function* () { yield put(convoChange(convoChangeData)); }); } if (activeConvoMessageChangeData) { yield call(dispatchActiveConvoMessagesChangeAction, activeConvoMessageChangeData); } } /* * On first call, this method will pull all the chat data from the database. * decrypt it and store it in memory. Subsequent calls will return in-memory * data. Any chat changes that happen subsequent to the first call should * use setMessage to update the in-memory data structure. setMessage will * also dispatch change actions so reducers can update state if necessary. */ const getChatData = async peerID => { if (!_chatData) { if (!_gettingChatData) { console.time('getMessages'); _gettingChatData = new Promise(async (chatDataResolve, chatDataReject) => { let unsentMessages = []; const db = await getDb(); console.time('allDocs'); const docs = await Promise.all([ db.collections.chatmessage.pouch .allDocs({ include_docs: true, }), db.collections.unsentchatmessages.pouch .allDocs({ include_docs: true, }), ]); console.timeEnd('allDocs'); // Some weird meta records of some sort are coming in here. For now, we'll // just filter them out. const filterOutMeta = arr => arr.filter(doc => !doc.id.startsWith('_design')); const messagesSent = filterOutMeta(docs[0].rows); const messagesUnsent = filterOutMeta(docs[1].rows); unsentMessages = messagesUnsent.map(msg => msg.id); const combined = messagesUnsent .concat(messagesSent); console.log(`${combined.length} total messages`); const decrypted = []; // todo: don't fail everything if one decrypt fails. await animationFrameInterval( () => { const doc = combined[decrypted.length]; decrypted.push({ ...db.collections.chatmessage._crypter.decrypt({ ...omit(doc.doc, ['_id']), }), messageID: doc.id, }); }, () => decrypted.length < combined.length, { maxOpsPerFrame: 25 } ); _chatData = {}; decrypted.forEach(doc => _setMessage(doc.peerID, { ...doc, sent: !inTransitMessages[doc.messageID] && !unsentMessages.includes(doc.messageID), sending: !!inTransitMessages[doc.messageID], })); chatDataResolve(_chatData); console.timeEnd('getMessages'); }); } } const chatData = await _gettingChatData; return !!peerID ? chatData[peerID] : chatData; }; function* getConvos(action) { try { console.time('getConvos'); const chatData = yield call(getChatData); const convos = {}; const messages = {}; Object .keys(chatData) .forEach(peerID => { const lastMessage = chatData[peerID].messages[ chatData[peerID].sorted[ chatData[peerID].sorted.length - 1 ] ]; convos[peerID] = { unread: chatData[peerID].unread, lastMessage: lastMessage.messageID, }; messages[lastMessage.messageID] = lastMessage; }); console.timeEnd('getConvos'); yield put(convosSuccess({ convos, messages, })); } catch (e) { console.error(e); yield put(convosFail(e.message || '')); } } // TODO: cancel existing async tasks on deactivate convo and logout // this might make the noAuthNoChat middleware moot. const getMessagesList = async (db, peerID) => { const convoData = await getChatData(peerID); let sorted = []; let messages = {}; if (convoData) { sorted = convoData.sorted; messages = convoData.messages; } return { sorted, messages, }; }; function* getConvoMessages(action) { const peerID = action.payload.peerID; try { const db = yield call(getDb); const messages = yield call(getMessagesList, db, peerID); yield put( convoMessagesSuccess({ peerID, ...messages, }) ); } catch (e) { yield put( convoMessagesFail({ peerID, error: e.message || '' }) ); } } function* handleActivateConvo(action) { const peerID = action.payload; yield put(convoActivated({ peerID })); yield put(convoMessagesRequest({ peerID })); } function* handleMessageDbChange(action) { // The majority of these actions can and should be ignored because if // the action that caused the DB change was initiated from this app, // then the data is already in the in-mem data structure and any necessary // state should have already been updated. // // The exception to that is data that is being synced over, e.g. data that // was generated by using the app on a different browser / device. // // Since there doesn't appear to be a way to distinguish a synced change // event from a non-synced one, we'll do a little data introspection to find // what's useful for us. // TODO: note about calling setMessage before a db change. if ( action.payload.operation === 'DELETE' || !action.payload.sent ) return; const { peerID, messageID, read, } = action.payload.data; const convo = yield call(getChatData, peerID); let message = convo ? convo[messageID] : null; // Really, at this time, the only actions that would come via syncing would // be new messages or message being marked as read. if ( !message || message.read !== read ) { console.log('round and around and around and around we go'); yield call(setMessage, { ...action.payload.data, sent: true, sending: false, }); } } function generatePbTimestamp(timestamp) { if (!(timestamp instanceof Date)) { throw new Error('A timestamp must be provided as a Date instance.'); } return { seconds: Math.floor(timestamp / 1000), nanos: timestamp % 1000, }; } function generateChatMessageData(message, options = {}) { if ( typeof options.timestamp !== 'undefined' && !(options.timestamp instanceof Date) ) { throw new Error('If providing a timestamp, it must be provided as ' + 'a Date instance.'); } if ( typeof options.subject !== 'undefined' && typeof options.subject !== 'string' ) { throw new Error('If providing a subject, it must be provided as ' + 'a string.'); } const opts = { subject: '', timestamp: new Date(), ...options, }; const combinationString = `${opts.subject}!${opts.timestamp.toISOString()}`; const idBytes = crypto.createHash('sha256').update(combinationString).digest(); const idBytesArray = new Uint8Array(idBytes); const idBytesBuffer = new Buffer(idBytesArray.buffer); const encoded = multihashes.encode(idBytesBuffer, 0x12); const messageID = multihashes.toB58String(encoded); return { messageID, timestamp: opts.timestamp.toISOString(), timestampPB: generatePbTimestamp(opts.timestamp), } } const inTransitMessages = {}; /* * If sending a new message, only the peerID and message (actual text of the * message) should be provided. If retrying a failed message, it is necessary * to additionally provide the messageID and timestamp of the original send * attempt. */ function* handleSendMessage(action) { if (typeof action.payload.peerID !== 'string' || !action.payload.peerID) { throw new Error('Please provide a peerID as a string.'); } // This will likely need to be adjusted for "typing" messages where I believe // we send an empty message (?) if (typeof action.payload.message !== 'string' || !action.payload.message) { throw new Error('Please provide a message as a string.'); } const isRetry = !!action.payload.messageID; if (isRetry) { if (typeof action.payload.timestamp !== 'string' || !action.payload.timestamp) { throw new Error('When retrying a failed message, please include the original ' + 'message timestamp.'); } } const peerID = action.payload.peerID; const message = action.payload.message; const generatedChatMessageData = generateChatMessageData(message); const messageID = isRetry ? action.payload.messageID : generatedChatMessageData.messageID; const { timestamp, timestampPB, } = generatedChatMessageData; const messageData = { messageID, peerID, message, outgoing: true, timestamp, read: false, subject: '', } yield call(setMessage, { ...( !isRetry ? messageData : { messageID } ), sending: true, sent: false, }); inTransitMessages[peerID] = inTransitMessages[peerID] || {}; inTransitMessages[peerID][messageID] = true; const db = yield call(getDb); let unsentMessageDoc; try { unsentMessageDoc = yield call( [db.collections.unsentchatmessages, 'upsert'], messageData, ); } catch (e) { const msg = message.length > 10 ? `${message.slice(0, 10)}…` : message; console.error(`Unable to save message "${msg}" in the ` + 'unsent chat messages DB.'); // We'll just proceed without it. It really just means that if the // send fails and the user closes the app, it will be lost. } let messageSendFailed; try { yield call( sendChatMessage, messageTypes.CHAT, peerID, { messageId: messageID, subject: messageData.subject, message: messageData.message, timestamp: timestampPB, flag: 0 } ); } catch (e) { const msg = message.length > 10 ? `${message.slice(0, 10)}…` : message; console.error(`Unable to send the chat message "${msg}".`); console.error(e); messageSendFailed = true; } finally { delete inTransitMessages[peerID][messageID]; } let sentMessageInsertedDoc; if (!messageSendFailed) { try { sentMessageInsertedDoc = yield call( [db.collections.chatmessage, 'insert'], messageData ); } catch (e) { const msg = message.length > 10 ? `${message.slice(0, 10)}…` : message; console.error(`Unable to save the sent message "${msg}" in the ` + 'chat messages DB.'); console.error(e); } } const completedData = { sent: !messageSendFailed, sending: false, timestamp: isRetry && messageSendFailed ? action.payload.timestamp : timestamp, } // _rev is needed for bulkDocs operations, so putting it in the cache if (sentMessageInsertedDoc) { completedData._rev = sentMessageInsertedDoc.get('_rev'); } yield call(setMessage, { peerID, messageID, ...completedData }); if (messageSendFailed || !sentMessageInsertedDoc) return; if (unsentMessageDoc) { try { yield call([unsentMessageDoc, 'remove']); } catch (e) { // pass } } } function* handleCancelMessage(action) { const messageID = action.payload.messageID; if ( typeof messageID !== 'string' || !messageID ) { throw new Error('A messageID is required in order to cancel a message.'); } yield call(setMessage, { messageID }, { remove: true }); const db = yield call(getDb); let unsentMessageDoc; try { unsentMessageDoc = yield call( async () => await db.collections.unsentchatmessages .findOne() .where('messageID') .eq(messageID) .exec() .then() ); } catch { // pass } if (unsentMessageDoc) { yield call([unsentMessageDoc, 'remove']); } } const convoMarkingAsRead = {}; function* handleConvoMarkRead(action) { console.time('markAsRead'); const peerID = action.payload.peerID; if (convoMarkingAsRead[peerID]) { console.log('no soup for you. no soup for you.'); return; } convoMarkingAsRead[peerID] = true; const convoData = yield call(getChatData, peerID); const updateMessages = Object.keys(convoData.messages || {}) .filter(messageID => { const msg = convoData.messages[messageID]; return !msg.read && !msg.outgoing; }); console.time('markAsReadSetMessage'); for (let i = 0; i < updateMessages.length; i++) { yield fork( setMessage, { peerID, messageID: updateMessages[i], read: true }); } console.timeEnd('markAsReadSetMessage'); const updateMessagesDb = updateMessages .filter(messageID => !!convoData.messages[messageID]._rev); const encryptedUpdateMessagesDb = []; if (!updateMessagesDb.length) { console.timeEnd('markAsRead'); return; } const db = yield call(getDb); console.time('markAsReadEncrypt'); yield call( animationFrameInterval, () => { console.log('boom'); const msg = { ...convoData.messages[updateMessages[encryptedUpdateMessagesDb.length]] }; const _rev = msg._rev; const _id = msg.messageID; delete msg._rev; delete msg.messageID; if (encryptedUpdateMessagesDb.length < updateMessagesDb.length) { encryptedUpdateMessagesDb.push({ ...db.chatmessage._crypter.encrypt({ ...msg, read: true, }), _id, _rev, }); } }, () => encryptedUpdateMessagesDb.length < updateMessagesDb.length, { maxOpsPerFrame: 25 } ); console.timeEnd('markAsReadEncrypt'); console.time('markAsReadBulkDocs'); yield call( [db.chatmessage.pouch, 'bulkDocs'], encryptedUpdateMessagesDb ); console.timeEnd('markAsReadBulkDocs'); console.timeEnd('markAsRead'); convoMarkingAsRead[peerID] = false; } function* handleDirectMessage(action) { if (action.payload && action.payload.type === messageTypes.CHAT) { const peerID = action.payload.peerID; const message = action.payload.payload; if (message.flag) { // ignore "read" and "typing" messages for now return; } const msg = message.message.length > 10 ? `${message.message.slice(0, 10)}…` : message.message; console.log(`writing "${msg}" from ${peerID} to the database`); const db = yield call(getDb); const state = yield select(); const msgData = { peerID, message: message.message, messageID: message.messageId, timestamp: ( (new Date( Number( String(message.timestamp.seconds) + String(message.timestamp.nanos) ) )).toISOString() ), subject: message.subject, outgoing: false, read: !!( state.chat && state.chat.chatOpen && state.chat.activeConvo && state.chat.activeConvo.peerID === peerID ), }; let chatDoc; try { chatDoc = yield call( [db.collections.chatmessage, 'insert'], msgData, ); } catch (e) { // TODO: maybe some type of retry? A db insertion failure I would think // would be very rare. console.error(`Unable to insert direct message ${msg} from ${peerID} ` + 'into the database.'); console.error(e); } const setMessageData = { ...msgData, sending: false, sent: false, }; if (chatDoc) { setMessageData._rev = chatDoc.get('_rev'); } yield call(setMessage, setMessageData); } } function handleLogout() { _gettingChatData = null; _chatData = null; } export function* convosRequestWatcher() { yield takeEvery(convosRequest, getConvos); } export function* activateConvoWatcher() { yield takeEvery(activateConvo, handleActivateConvo); } export function* convoMessagesRequestWatcher() { yield takeEvery(convoMessagesRequest, getConvoMessages); } export function* messageDbChangeWatcher() { yield takeEvery(messageDbChange, handleMessageDbChange); } export function* sendMessageWatcher() { yield takeEvery(sendMessage, handleSendMessage); } export function* convoMarkReadWatcher() { yield takeEvery(convoMarkRead, handleConvoMarkRead); } export function* directMessageWatcher() { yield takeEvery(directMessage, handleDirectMessage); } export function* cancelMessageWatcher() { yield takeEvery(cancelMessage, handleCancelMessage); } export function* logoutWatcher() { yield takeEvery(AUTH_LOGOUT, handleLogout); }