diff --git a/net/web/src/api/addChannelTopic.js b/net/web/src/api/addChannelTopic.js index e30106c3..f70e5a8d 100644 --- a/net/web/src/api/addChannelTopic.js +++ b/net/web/src/api/addChannelTopic.js @@ -1,6 +1,6 @@ import { checkResponse, fetchWithTimeout } from './fetchUtil'; -export async function addChannelTopic(token, channelId, datatype, message, assets ): string { +export async function addChannelTopic(token, channelId, datatype, message, assets ) { if (message == null && (assets == null || assets.length === 0)) { let topic = await fetchWithTimeout(`/content/channels/${channelId}/topics?agent=${token}`, diff --git a/net/web/src/context/useChannelContext.hook.js b/net/web/src/context/useChannelContext.hook.js index 7a476d4c..eaa19c69 100644 --- a/net/web/src/context/useChannelContext.hook.js +++ b/net/web/src/context/useChannelContext.hook.js @@ -14,216 +14,107 @@ import { setChannelSubject } from 'api/setChannelSubject'; import { setChannelCard } from 'api/setChannelCard'; import { clearChannelCard } from 'api/clearChannelCard'; import { UploadContext } from 'context/UploadContext'; -import CryptoJS from 'crypto-js'; -import { JSEncrypt } from 'jsencrypt' export function useChannelContext() { const [state, setState] = useState({ - init: false, + offsync: false, channels: new Map(), }); const upload = useContext(UploadContext); const access = useRef(null); - const revision = useRef(null); + const setRevision = useRef(null); + const curRevision = useRef(null); const channels = useRef(new Map()); - const next = useRef(null); + const syncing = useRef(false); const updateState = (value) => { setState((s) => ({ ...s, ...value })) } - const unsealKey = (seals, sealKey) => { - let unsealedKey; - if (seals?.length) { - seals.forEach(seal => { - if (seal.publicKey === sealKey.public) { - let crypto = new JSEncrypt(); - crypto.setPrivateKey(sealKey.private); - unsealedKey = crypto.decrypt(seal.sealedKey); - } - }); - } - return unsealedKey; - } + const sync = async () => { + if (!syncing.current && setRevision.current !== curRevision.current) { + syncing.current = true; - const updateChannels = async () => { - let delta = await getChannels(access.current, revision.current); - for (let channel of delta) { - if (channel.data) { - let cur = channels.current.get(channel.id); - if (cur == null) { - cur = { id: channel.id, data: { } } - } - if (cur.data.detailRevision !== channel.data.detailRevision) { - if (channel.data.channelDetail != null) { - cur.data.channelDetail = channel.data.channelDetail; + try { + const token = access.current; + const revision = curRevision.current; + const delta = await getChannels(token, setRevision.current); + for (let channel of delta) { + if (channel.data) { + let cur = channels.current.get(channel.id); + if (cur == null) { + cur = { id: channel.id, data: { } } + } + if (cur.data.detailRevision !== channel.data.detailRevision) { + if (channel.data.channelDetail != null) { + cur.data.channelDetail = channel.data.channelDetail; + } + else { + let detail = await getChannelDetail(token, channel.id); + cur.data.channelDetail = detail; + } + cur.data.unsealedSubject = null; + cur.data.detailRevision = channel.data.detailRevision; + } + if (cur.data.topicRevision !== channel.data.topicRevision) { + if (channel.data.channelSummary != null) { + cur.data.channelSummary = channel.data.channelSummary; + } + else { + let summary = await getChannelSummary(token, channel.id); + cur.data.channelSummary = summary; + } + cur.data.unsealedSummary = null; + cur.data.topicRevision = channel.data.topicRevision; + } + cur.revision = channel.revision; + channels.current.set(channel.id, cur); } else { - let detail = await getChannelDetail(access.current, channel.id); - cur.data.channelDetail = detail; + channels.current.delete(channel.id); } - cur.data.unsealedChannel = null; - cur.data.detailRevision = channel.data.detailRevision; } - if (cur.data.topicRevision !== channel.data.topicRevision) { - if (channel.data.channelSummary != null) { - cur.data.channelSummary = channel.data.channelSummary; - } - else { - let summary = await getChannelSummary(access.current, channel.id); - cur.data.channelSummary = summary; - } - cur.data.unsealedSummary = null; - cur.data.topicRevision = channel.data.topicRevision; - } - cur.revision = channel.revision; - channels.current.set(channel.id, { ...cur }); + setRevision.current = revision; + updateState({ offsync: false, channels: channels.current }); } - else { - channels.current.delete(channel.id); + catch(err) { + console.log(err); + syncing.current = false; + updateState({ offsync: true }); + return; } - } - } - const setChannels = async (rev) => { - let force = false; - if (rev == null) { - force = true; - rev = revision.current; - } - if (next.current == null) { - next.current = rev; - if (force || revision.current !== rev) { - await updateChannels(); - updateState({ init: true, channels: channels.current }); - revision.current = rev; - } - let r = next.current; - next.current = null; - if (revision.current !== r) { - setChannels(r); - } - } - else { - next.current = rev; + syncing.current = false; + await sync(); } } const actions = { setToken: (token) => { + if (access.current || syncing.current) { + throw new Error("invalid session state"); + } access.current = token; + channels.current = new Map(); + curRevision.current = null; + setRevision.current = null; + setState({ offsync: false, channels: new Map() }); }, clearToken: () => { access.current = null; - channels.current = new Map(); - revision.current = null; - setState({ init: false, channels: new Map() }); }, setRevision: async (rev) => { - setChannels(rev); + curRevision.current = rev; + await sync(); }, - addBasicChannel: async (cards, subject) => { - return await addChannel(access.current, 'superbasic', cards, { subject }); + addChannel: async (type, subject, cards) => { + return await addChannel(access.current, type, cards, subject); }, - addSealedChannel: async (cards, subject, keys) => { - const key = CryptoJS.lib.WordArray.random(256 / 8); - const iv = CryptoJS.lib.WordArray.random(128 / 8); - const encrypted = CryptoJS.AES.encrypt(JSON.stringify({ subject }), key, { iv: iv }); - const subjectEncrypted = encrypted.ciphertext.toString(CryptoJS.enc.Base64) - const subjectIv = iv.toString(); - const keyHex = key.toString(); - - let seals = []; - let crypto = new JSEncrypt(); - keys.forEach(publicKey => { - crypto.setPublicKey(publicKey); - const sealedKey = crypto.encrypt(keyHex); - seals.push({ publicKey, sealedKey }); - }); - - const data = { subjectEncrypted, subjectIv, seals }; - return await addChannel(access.current, 'sealed', cards, data); + removeChannel: async (channelId) => { + return await removeChannel(access.current, channelId); }, - unsealChannelSubject: (channelId, sealKey) => { - try { - const channel = channels.current.get(channelId); - const { subjectEncrypted, subjectIv, seals } = JSON.parse(channel.data.channelDetail.data); - const unsealedKey = unsealKey(seals, sealKey); - if (unsealKey) { - const iv = CryptoJS.enc.Hex.parse(subjectIv); - const key = CryptoJS.enc.Hex.parse(unsealedKey); - const enc = CryptoJS.enc.Base64.parse(subjectEncrypted); - const cipher = CryptoJS.lib.CipherParams.create({ ciphertext: enc, iv: iv }); - const dec = CryptoJS.AES.decrypt(cipher, key, { iv: iv }); - channel.data.unsealedChannel = JSON.parse(dec.toString(CryptoJS.enc.Utf8)); - channels.current.set(channel.id, { ...channel }); - updateState({ channels: channels.current }); - } - } - catch(err) { - console.log(err); - } - }, - isUnsealed: (channelId, sealKey) => { - try { - const channel = channels.current.get(channelId); - const { seals } = JSON.parse(channel.data.channelDetail.data); - for (let i = 0; i < seals.length; i++) { - if (seals[i].publicKey === sealKey.public) { - return sealKey.private != null; - } - } - } - catch(err) { - console.log(err); - } - return false; - }, - unsealChannelSummary: (channelId, sealKey) => { - try { - const channel = channels.current.get(channelId); - const { seals } = JSON.parse(channel.data.channelDetail.data); - const { messageEncrypted, messageIv } = JSON.parse(channel.data.channelSummary.lastTopic.data); - const unsealedKey = unsealKey(seals, sealKey); - if (unsealKey) { - const iv = CryptoJS.enc.Hex.parse(messageIv); - const key = CryptoJS.enc.Hex.parse(unsealedKey); - const enc = CryptoJS.enc.Base64.parse(messageEncrypted); - const cipher = CryptoJS.lib.CipherParams.create({ ciphertext: enc, iv: iv }); - const dec = CryptoJS.AES.decrypt(cipher, key, { iv: iv }); - channel.data.unsealedSummary = JSON.parse(dec.toString(CryptoJS.enc.Utf8)); - channels.current.set(channel.id, { ...channel }); - updateState({ channels: channels.current }); - } - } - catch(err) { - console.log(err); - } - }, - setChannelSubject: async (channelId, subject) => { - return await setChannelSubject(access.current, channelId, 'superbasic', { subject }); - }, - setChannelSealedSubject: async (channelId, subject, sealKey) => { - const channel = channels.current.get(channelId); - - let { seals, subjectEncrypted, subjectIv } = JSON.parse(channel.data.channelDetail.data); - if (seals?.length) { - seals.forEach(seal => { - if (seal.publicKey === sealKey.public) { - let crypto = new JSEncrypt(); - crypto.setPrivateKey(sealKey.private); - const unsealedKey = crypto.decrypt(seal.sealedKey); - const key = CryptoJS.enc.Hex.parse(unsealedKey); - - const iv = CryptoJS.lib.WordArray.random(128 / 8); - const encrypted = CryptoJS.AES.encrypt(JSON.stringify({ subject }), key, { iv: iv }); - subjectEncrypted = encrypted.ciphertext.toString(CryptoJS.enc.Base64) - subjectIv = iv.toString(); - } - }); - } - const data = { subjectEncrypted, subjectIv, seals }; - return await setChannelSubject(access.current, channelId, 'sealed', data); + setChannelSubject: async (channelId, type, subject) => { + return await setChannelSubject(access.current, channelId, type, subject); }, setChannelCard: async (channelId, cardId) => { return await setChannelCard(access.current, channelId, cardId); @@ -231,47 +122,28 @@ export function useChannelContext() { clearChannelCard: async (channelId, cardId) => { return await clearChannelCard(access.current, channelId, cardId); }, - removeChannel: async (channelId) => { - return await removeChannel(access.current, channelId); - }, - removeChannelTopic: async (channelId, topicId) => { - await removeChannelTopic(access.current, channelId, topicId); - try { - await setChannels(null); - } - catch (err) { - console.log(err); + unsealChannelSubject: async (channelId, unsealed, revision) => { + const channel = channels.current.get(channelId); + if (channel.revision === revision) { + channel.data.unsealedSubject = unsealed; + channels.current.set(channelId, channel); + updateState({ channels: channels.current }); } }, - setChannelTopicSubject: async (channelId, topicId, data) => { - await setChannelTopicSubject(access.current, channelId, topicId, 'superbasictopic', data); - try { - await setChannels(null); - } - catch (err) { - console.log(err); + unsealChannelSummary: async (channelId, unsealed, revision) => { + const channel = channels.current.get(channelId); + if (channel.revision === revision) { + channel.data.unsealedSummary = unsealed; + channels.current.set(channelId, chanel); + updateState({ channels: channels.current }); } }, - setSealedChannelTopicSubject: async (channelId, topicId, data, sealKey) => { - const iv = CryptoJS.lib.WordArray.random(128 / 8); - const key = CryptoJS.enc.Hex.parse(sealKey); - const encrypted = CryptoJS.AES.encrypt(JSON.stringify({ message: data }), key, { iv: iv }); - const messageEncrypted = encrypted.ciphertext.toString(CryptoJS.enc.Base64) - const messageIv = iv.toString(); - await setChannelTopicSubject(access.current, channelId, topicId, 'sealedtopic', { messageEncrypted, messageIv }); - try { - await setChannels(null); - } - catch (err) { - console.log(err); - } - }, - addChannelTopic: async (channelId, message, files) => { + addTopic: async (channelId, type, message, files) => { if (files?.length) { const topicId = await addChannelTopic(access.current, channelId, null, null, null); upload.actions.addTopic(access.current, channelId, topicId, files, async (assets) => { - message.assets = assets; - await setChannelTopicSubject(access.current, channelId, topicId, 'superbasictopic', message); + const subject = message(assets); + await setChannelTopicSubject(access.current, channelId, topicId, type, subject); }, async () => { try { await removeChannelTopic(access.current, channelId, topicId); @@ -282,7 +154,8 @@ export function useChannelContext() { }); } else { - await addChannelTopic(access.current, channelId, 'superbasictopic', message, files); + const subject = message([]); + await addChannelTopic(access.current, channelId, type, subject); } try { await setChannels(null); @@ -290,21 +163,16 @@ export function useChannelContext() { catch (err) { console.log(err); } + }, - addSealedChannelTopic: async (channelId, sealKey, message) => { - const iv = CryptoJS.lib.WordArray.random(128 / 8); - const key = CryptoJS.enc.Hex.parse(sealKey); - const encrypted = CryptoJS.AES.encrypt(JSON.stringify({ message }), key, { iv: iv }); - const messageEncrypted = encrypted.ciphertext.toString(CryptoJS.enc.Base64) - const messageIv = iv.toString(); - await addChannelTopic(access.current, channelId, 'sealedtopic', { messageEncrypted, messageIv }); + removeTopic: async (channelId, topicId) => { + await removeChannelTopic(access.current, channelId, topicId); }, - getChannel: (channelId) => { - return channels.current.get(channelId); + setTopicSubject: async (channelId, topicId, type, subject) => { + await setChannelTopicSubject(access.current, channelId, topicId, type, subject); }, - getChannelRevision: (channelId) => { - let channel = channels.current.get(channelId); - return channel?.revision; + getChannelTopicAssetUrl: (channelId, topicId, assetId) => { + return getChannelTopicAssetUrl(access.current, channelId, topicId, assetId); }, getChannelTopics: async (channelId, revision, count, begin, end) => { return await getChannelTopics(access.current, channelId, revision, count, begin, end); @@ -312,10 +180,10 @@ export function useChannelContext() { getChannelTopic: async (channelId, topicId) => { return await getChannelTopic(access.current, channelId, topicId); }, - getChannelTopicAssetUrl: (channelId, topicId, assetId) => { - return getChannelTopicAssetUrl(access.current, channelId, topicId, assetId); - } - } + resync: async () => { + await sync(); + }, + }; return { state, actions } } diff --git a/net/web/src/context/useProfileContext.hook.js b/net/web/src/context/useProfileContext.hook.js index 7073cd31..d328f1f7 100644 --- a/net/web/src/context/useProfileContext.hook.js +++ b/net/web/src/context/useProfileContext.hook.js @@ -7,6 +7,7 @@ import { getProfileImageUrl } from 'api/getProfileImageUrl'; export function useProfileContext() { const [state, setState] = useState({ + offsync: false, identity: {}, imageUrl: null, }); @@ -24,36 +25,41 @@ export function useProfileContext() { syncing.current = true; try { + const token = access.current; const revision = curRevision.current; const identity = await getProfile(access.current); - const imageUrl = identity.image ? getProfileImageUrl(access.current, revision) : null; - updateState({ identity, imageUrl }); + const imageUrl = identity.image ? getProfileImageUrl(token, revision) : null; setRevision.current = revision; + updateState({ offsync: false, identity, imageUrl }); } catch(err) { console.log(err); syncing.current = false; + updateState({ offsync: true }); return; } syncing.current = false; - sync(); + await sync(); } } const actions = { setToken: (token) => { + if (access.current || syncing.current) { + throw new Error("invalid session state"); + } access.current = token; + curRevision.current = null; + setRevision.current = null; + setState({ offsync: false, identity: {}, imageUrl: null }); }, clearToken: () => { access.current = null; - curRevision.current = null; - setRevision.current = null; - setState({ identity: {}, imageUrl: null }); }, - setRevision: (rev) => { + setRevision: async (rev) => { curRevision.current = rev; - sync(); + await sync(); }, setProfileData: async (name, location, description) => { await setProfileData(access.current, name, location, description); @@ -64,6 +70,9 @@ export function useProfileContext() { getHandleStatus: async (name) => { return await getUsername(name, access.current); }, + resync: async () => { + await sync(); + }, } return { state, actions } diff --git a/net/web/test/Channel.test.js b/net/web/test/Channel.test.js new file mode 100644 index 00000000..07a84329 --- /dev/null +++ b/net/web/test/Channel.test.js @@ -0,0 +1,61 @@ +import React, { useState, useEffect, useContext } from 'react'; +import {render, act, screen, waitFor, fireEvent} from '@testing-library/react' +import { ChannelContextProvider, ChannelContext } from 'context/ChannelContext'; +import * as fetchUtil from 'api/fetchUtil'; + +let channelContext = null; +function ChannelView() { + const [renderCount, setRenderCount] = useState(0); + const channel = useContext(ChannelContext); + channelContext = channel; + + useEffect(() => { + setRenderCount(renderCount + 1); + }, [channel.state]); + + return ( +