refactor of conversation in webapp

This commit is contained in:
Roland Osborne 2023-01-25 10:56:43 -08:00
parent 7f41ce6566
commit ba279dfcda
10 changed files with 223 additions and 219 deletions

View File

@ -11,9 +11,9 @@ export function getCardByGuid(cards, guid) {
export function getProfileByGuid(cards, guid) { export function getProfileByGuid(cards, guid) {
const card = getCardByGuid(cards, guid); const card = getCardByGuid(cards, guid);
if (card?.data?.cardProfile) { if (card?.data?.cardProfile) {
const { name, handle, imageSet } = card.data.cardProfile; const { name, handle, imageSet, node } = card.data.cardProfile;
const cardId = card.id; const cardId = card.id;
return { cardId, name, handle, imageSet } return { cardId, name, handle, imageSet, node }
} }
return {}; return {};
} }

View File

@ -63,7 +63,7 @@ export function decryptChannelSubject(subject, contentKey) {
export function encryptTopicSubject(subject, contentKey) { export function encryptTopicSubject(subject, contentKey) {
const iv = CryptoJS.lib.WordArray.random(128 / 8); const iv = CryptoJS.lib.WordArray.random(128 / 8);
const key = CryptoJS.enc.Hex.parse(contentKey); const key = CryptoJS.enc.Hex.parse(contentKey);
const encrypted = CryptoJS.AES.encrypt(JSON.stringify({ subject }), key, { iv: iv }); const encrypted = CryptoJS.AES.encrypt(JSON.stringify(subject), key, { iv: iv });
const messageEncrypted = encrypted.ciphertext.toString(CryptoJS.enc.Base64) const messageEncrypted = encrypted.ciphertext.toString(CryptoJS.enc.Base64)
const messageIv = iv.toString(); const messageIv = iv.toString();
return { messageEncrypted, messageIv }; return { messageEncrypted, messageIv };

View File

@ -185,6 +185,7 @@ export function useCardContext() {
else { else {
delta = await getContactChannels(node, token, setNotifiedView, setNotifiedChannel); delta = await getContactChannels(node, token, setNotifiedView, setNotifiedChannel);
} }
for (let channel of delta) { for (let channel of delta) {
if (channel.data) { if (channel.data) {
let cur = card.channels.get(channel.id); let cur = card.channels.get(channel.id);
@ -299,7 +300,7 @@ export function useCardContext() {
const subject = message([]); const subject = message([]);
await addContactChannelTopic(node, token, channelId, type, subject, files); await addContactChannelTopic(node, token, channelId, type, subject, files);
} }
resyncCard(cardId); //resyncCard(cardId);
}, },
removeTopic: async (cardId, channelId, topicId) => { removeTopic: async (cardId, channelId, topicId) => {
const card = cards.current.get(cardId); const card = cards.current.get(cardId);

View File

@ -153,7 +153,7 @@ export function useChannelContext() {
const subject = message([]); const subject = message([]);
await addChannelTopic(access.current, channelId, type, subject); await addChannelTopic(access.current, channelId, type, subject);
} }
await resync(); //await resync();
}, },
removeTopic: async (channelId, topicId) => { removeTopic: async (channelId, topicId) => {
await removeChannelTopic(access.current, channelId, topicId); await removeChannelTopic(access.current, channelId, topicId);

View File

@ -212,7 +212,7 @@ export function useConversationContext() {
delta = await getTopicDelta(cardId, channelId, null, COUNT, null, marker.current); delta = await getTopicDelta(cardId, channelId, null, COUNT, null, marker.current);
} }
else { else {
delta = await getTopicDelta(cardId, channelId, topicRevision, null, marker.current, null); delta = await getTopicDelta(cardId, channelId, setTopicRevision.current, null, marker.current, null);
} }
for (let topic of delta?.topics) { for (let topic of delta?.topics) {
@ -240,8 +240,9 @@ export function useConversationContext() {
} }
} }
marker.current = delta.marker; marker.current = delta.marker ? delta.marker : marker.current;
setTopicRevision.current = topicRevision; setTopicRevision.current = topicRevision;
updateState({ offsync: false, topicRevision: topicRevision, topics: topics.current }); updateState({ offsync: false, topicRevision: topicRevision, topics: topics.current });
} }
} }

View File

@ -14,7 +14,7 @@ export function Conversation({ closeConversation, openDetails, cardId, channelId
const thread = useRef(null); const thread = useRef(null);
const topicRenderer = (topic) => { const topicRenderer = (topic) => {
return (<TopicItem host={cardId == null} topic={topic} sealed={state.sealed} sealKey={state.sealKey} />) return (<TopicItem host={cardId == null} topic={topic} />)
} }
// an unfortunate cludge for the mobile browser // an unfortunate cludge for the mobile browser
@ -107,8 +107,8 @@ export function Conversation({ closeConversation, openDetails, cardId, channelId
)} )}
</div> </div>
<div class="topic"> <div class="topic">
{ (!state.sealed || state.sealKey) && ( { (!state.sealed || state.contentKey) && (
<AddTopic cardId={cardId} channelId={channelId} sealed={state.sealed} sealKey={state.sealKey} /> <AddTopic contentKey={state.contentKey} />
)} )}
{ state.uploadError && ( { state.uploadError && (
<div class="upload-error"> <div class="upload-error">

View File

@ -8,10 +8,11 @@ import { AudioFile } from './audioFile/AudioFile';
import { VideoFile } from './videoFile/VideoFile'; import { VideoFile } from './videoFile/VideoFile';
import { Carousel } from 'carousel/Carousel'; import { Carousel } from 'carousel/Carousel';
export function AddTopic({ cardId, channelId, sealed, sealKey }) { export function AddTopic({ contentKey }) {
const { state, actions } = useAddTopic();
const [modal, modalContext] = Modal.useModal(); const [modal, modalContext] = Modal.useModal();
const { state, actions } = useAddTopic(cardId, channelId);
const attachImage = useRef(null); const attachImage = useRef(null);
const attachAudio = useRef(null); const attachAudio = useRef(null);
const attachVideo = useRef(null); const attachVideo = useRef(null);
@ -27,7 +28,7 @@ export function AddTopic({ cardId, channelId, sealed, sealKey }) {
const addTopic = async () => { const addTopic = async () => {
if (state.messageText || state.assets.length) { if (state.messageText || state.assets.length) {
try { try {
await actions.addTopic(sealed, sealKey); await actions.addTopic(contentKey);
} }
catch (err) { catch (err) {
console.log(err); console.log(err);
@ -106,22 +107,22 @@ export function AddTopic({ cardId, channelId, sealed, sealKey }) {
value={state.messageText} autocapitalize="none" /> value={state.messageText} autocapitalize="none" />
</div> </div>
<div class="buttons"> <div class="buttons">
{ !state.sealed && state.enableImage && ( { !contentKey && state.enableImage && (
<div class="button space" onClick={() => attachImage.current.click()}> <div class="button space" onClick={() => attachImage.current.click()}>
<PictureOutlined /> <PictureOutlined />
</div> </div>
)} )}
{ !state.sealed && state.enableVideo && ( { !contentKey && state.enableVideo && (
<div class="button space" onClick={() => attachVideo.current.click()}> <div class="button space" onClick={() => attachVideo.current.click()}>
<VideoCameraOutlined /> <VideoCameraOutlined />
</div> </div>
)} )}
{ !state.sealed && state.enableAudio && ( { !contentKey && state.enableAudio && (
<div class="button space" onClick={() => attachAudio.current.click()}> <div class="button space" onClick={() => attachAudio.current.click()}>
<SoundOutlined /> <SoundOutlined />
</div> </div>
)} )}
{ !state.sealed && ( { !contentKey && (
<div class="bar space" /> <div class="bar space" />
)} )}
<div class="button space"> <div class="button space">

View File

@ -2,14 +2,14 @@ import { useContext, useState, useEffect } from 'react';
import { CardContext } from 'context/CardContext'; import { CardContext } from 'context/CardContext';
import { ChannelContext } from 'context/ChannelContext'; import { ChannelContext } from 'context/ChannelContext';
import { ConversationContext } from 'context/ConversationContext'; import { ConversationContext } from 'context/ConversationContext';
import { encryptTopicSubject } from 'context/sealUtil';
export function useAddTopic(cardId, channelId) { export function useAddTopic() {
const [state, setState] = useState({ const [state, setState] = useState({
enableImage: null, enableImage: null,
enableAudio: null, enableAudio: null,
enableVideo: null, enableVideo: null,
sealed: false,
assets: [], assets: [],
messageText: null, messageText: null,
textColor: '#444444', textColor: '#444444',
@ -50,9 +50,9 @@ export function useAddTopic(cardId, channelId) {
} }
useEffect(() => { useEffect(() => {
const { enableImage, enableAudio, enableVideo, sealed } = conversation.state; const { enableImage, enableAudio, enableVideo } = conversation.state.channel?.data?.channelDetail || {};
updateState({ enableImage, enableAudio, enableVideo, sealed }); updateState({ enableImage, enableAudio, enableVideo });
}, [conversation]); }, [conversation.state.channel?.data?.channelDetail]);
const actions = { const actions = {
addImage: (image) => { addImage: (image) => {
@ -73,7 +73,9 @@ export function useAddTopic(cardId, channelId) {
setPosition: (index, position) => { setPosition: (index, position) => {
updateAsset(index, { position }); updateAsset(index, { position });
}, },
removeAsset: (idx) => { removeAsset(idx) }, removeAsset: (idx) => {
removeAsset(idx)
},
setTextColor: (value) => { setTextColor: (value) => {
updateState({ textColorSet: true, textColor: value }); updateState({ textColorSet: true, textColor: value });
}, },
@ -83,31 +85,42 @@ export function useAddTopic(cardId, channelId) {
setTextSize: (value) => { setTextSize: (value) => {
updateState({ textSizeSet: true, textSize: value }); updateState({ textSizeSet: true, textSize: value });
}, },
addTopic: async (sealed, sealKey) => { addTopic: async (contentKey) => {
if (!state.busy) { if (!state.busy) {
try { try {
updateState({ busy: true }); updateState({ busy: true });
let message = { const type = contentKey ? 'sealedtopic' : 'superbasictopic';
text: state.messageText, const message = (assets) => {
textColor: state.textColorSet ? state.textColor : null, if (contentKey) {
textSize: state.textSizeSet ? state.textSize : null, if (assets?.length) {
console.log('assets not yet supported on sealed channels');
}
const message = {
text: state.messageText,
textColor: state.textColorSet ? state.textColor : null,
textSize: state.textSizeSet ? state.textSize : null,
}
return encryptTopicSubject({ message }, contentKey);
}
else {
if (assets?.length) {
return {
assets,
text: state.messageText,
textColor: state.textColorSet ? state.textColor : null,
textSize: state.textSizeSet ? state.textSize : null,
}
}
else {
return {
text: state.messageText,
textColor: state.textColorSet ? state.textColor : null,
textSize: state.textSizeSet ? state.textSize : null,
}
}
}
}; };
if (cardId) { await conversation.actions.addTopic(type, message, state.assets);
if (sealed) {
await card.actions.addSealedChannelTopic(cardId, channelId, sealKey, message, state.assets);
}
else {
await card.actions.addChannelTopic(cardId, channelId, message, state.assets);
}
}
else {
if (sealed) {
await channel.actions.addSealedChannelTopic(channelId, sealKey, message, state.assets);
}
else {
await channel.actions.addChannelTopic(channelId, message, state.assets);
}
}
updateState({ busy: false, messageText: null, textColor: '#444444', textColorSet: false, updateState({ busy: false, messageText: null, textColor: '#444444', textColorSet: false,
textSize: 12, textSizeSet: false, assets: [] }); textSize: 12, textSizeSet: false, assets: [] });
} }

View File

@ -8,141 +8,22 @@ import { Space, Skeleton, Button, Modal, Input } from 'antd';
import { ExclamationCircleOutlined, DeleteOutlined, EditOutlined, FireOutlined, PictureOutlined } from '@ant-design/icons'; import { ExclamationCircleOutlined, DeleteOutlined, EditOutlined, FireOutlined, PictureOutlined } from '@ant-design/icons';
import { Carousel } from 'carousel/Carousel'; import { Carousel } from 'carousel/Carousel';
export function TopicItem({ host, topic, sealed, sealKey }) { export function TopicItem({ host, topic }) {
const { state, actions } = useTopicItem(topic, sealed, sealKey);
let name = state.name ? state.name : state.handle;
let nameClass = state.name ? 'set' : 'unset';
if (name == null) {
name = "unknown contact"
nameClass = "unknown"
}
const renderAsset = (asset, idx, topicId) => {
if (asset.image) {
return <ImageAsset thumbUrl={actions.getAssetUrl(asset.image.thumb, topicId)}
fullUrl={actions.getAssetUrl(asset.image.full, topicId)} />
}
if (asset.video) {
return <VideoAsset thumbUrl={actions.getAssetUrl(asset.video.thumb, topicId)}
lqUrl={actions.getAssetUrl(asset.video.lq, topicId)} hdUrl={actions.getAssetUrl(asset.video.hd, topicId)} />
}
if (asset.audio) {
return <AudioAsset label={asset.audio.label} audioUrl={actions.getAssetUrl(asset.audio.full, topicId)} />
}
return <></>
}
const removeTopic = () => {
Modal.confirm({
title: 'Do you want to delete this message?',
icon: <ExclamationCircleOutlined />,
okText: 'Yes, Delete',
cancelText: 'No, Cancel',
onOk() { actions.removeTopic() },
});
}
const Options = () => {
if (state.editing) {
return <></>;
}
if (state.owner) {
return (
<div class="buttons">
<div class="button" onClick={() => actions.setEditing(true)}>
<EditOutlined />
</div>
<div class="button" onClick={() => removeTopic()}>
<DeleteOutlined />
</div>
</div>
);
}
if (host) {
return (
<div class="buttons">
<div class="button" onClick={() => removeTopic()}>
<DeleteOutlined />
</div>
</div>
);
}
return <></>;
}
const Message = () => {
if (state.editing) {
return (
<div class="editing">
<Input.TextArea defaultValue={state.text} placeholder="message"
style={{ resize: 'none', color: state.textColor, fontSize: state.textSize }}
onChange={(e) => actions.setEdit(e.target.value)} rows={3} bordered={false}/>
<div class="controls">
<Space>
<Button onClick={() => actions.setEditing(false)}>Cancel</Button>
<Button type="primary" onClick={() => actions.setMessage()} loading={state.body}>Save</Button>
</Space>
</div>
</div>
);
}
return <div style={{ color: state.textColor, fontSize: state.textSize }}>{ state.text }</div>
}
return ( return (
<TopicItemWrapper> <TopicItemWrapper>
{ state.init && ( <div class="topic-header">
<> <div class="avatar">
<div class="topic-header"> <Logo width={32} height={32} radius={4} url={topic.imageUrl} />
<div class="avatar"> </div>
<Logo width={32} height={32} radius={4} url={state.imageUrl} /> <div class="info">
</div> <div class={ topic.nameSet ? 'set' : 'unset' }>{ topic.name }</div>
<div class="info"> <div>{ topic.createdStr }</div>
<div class={nameClass}>{ name }</div> </div>
<div>{ state.created }</div> </div>
</div> <div class="message">
{ !state.sealed && ( <div style={{ color: topic.textColor, fontSize: topic.textSize }}>{ topic.text }</div>
<div class="topic-options"> </div>
<Options />
</div>
)}
</div>
{ !state.confirmed && (
<div class="skeleton">
<Skeleton size={'small'} active={true} title={false} />
</div>
)}
{ state.confirmed && (
<div>
{ state.error && (
<div class="asset-placeholder">
<FireOutlined style={{ fontSize: 32, color: '#ff8888' }} />
</div>
)}
{ !state.error && !state.ready && (
<div class="asset-placeholder">
<PictureOutlined style={{ fontSize: 32 }} />
</div>
)}
{ !state.error && state.ready && state.assets.length > 0 && (
<div class="topic-assets">
<Carousel pad={40} items={state.assets} itemRenderer={renderAsset} />
</div>
)}
<div class="message">
{ !state.sealed && (
<Message />
)}
{ state.sealed && (
<div class="sealed-message">sealed message</div>
)}
</div>
</div>
)}
</>
)}
</TopicItemWrapper> </TopicItemWrapper>
) )
} }

View File

@ -1,35 +1,42 @@
import { useContext, useState, useEffect } from 'react'; import { useContext, useRef, useState, useEffect } from 'react';
import { ViewportContext } from 'context/ViewportContext'; import { ViewportContext } from 'context/ViewportContext';
import { AccountContext } from 'context/AccountContext'; import { AccountContext } from 'context/AccountContext';
import { ConversationContext } from 'context/ConversationContext'; import { ConversationContext } from 'context/ConversationContext';
import { UploadContext } from 'context/UploadContext'; import { UploadContext } from 'context/UploadContext';
import { StoreContext } from 'context/StoreContext'; import { StoreContext } from 'context/StoreContext';
import { CardContext } from 'context/CardContext';
import { ProfileContext } from 'context/ProfileContext';
import { isUnsealed, getChannelSeals, getContentKey } from 'context/sealUtil';
import { JSEncrypt } from 'jsencrypt' import { JSEncrypt } from 'jsencrypt'
import { decryptTopicSubject } from 'context/sealUtil';
import { getProfileByGuid } from 'context/cardUtil';
export function useConversation(cardId, channelId) { export function useConversation(cardId, channelId) {
const [state, setState] = useState({ const [state, setState] = useState({
display: null, display: null,
logo: null,
subject: null,
topics: [],
loadingInit: false,
loadingMore: false,
upload: false, upload: false,
uploadError: false, uploadError: false,
uploadPercent: 0, uploadPercent: 0,
error: false, topics: [],
loading: false,
sealed: false, sealed: false,
sealKey: null, contentKey: null,
delayed: false,
}); });
const profile = useContext(ProfileContext);
const card = useContext(CardContext);
const account = useContext(AccountContext); const account = useContext(AccountContext);
const viewport = useContext(ViewportContext); const viewport = useContext(ViewportContext);
const conversation = useContext(ConversationContext); const conversation = useContext(ConversationContext);
const upload = useContext(UploadContext); const upload = useContext(UploadContext);
const store = useContext(StoreContext); const store = useContext(StoreContext);
const loading = useRef(false);
const conversationId = useRef(null);
const topics = useRef(new Map());
const updateState = (value) => { const updateState = (value) => {
setState((s) => ({ ...s, ...value })); setState((s) => ({ ...s, ...value }));
} }
@ -39,19 +46,28 @@ export function useConversation(cardId, channelId) {
}, [viewport]); }, [viewport]);
useEffect(() => { useEffect(() => {
let sealKey; const { dataType, data } = conversation.state.channel?.data?.channelDetail || {};
const seals = conversation.state.seals; if (dataType === 'sealed') {
if (seals?.length > 0) { try {
seals.forEach(seal => { const { sealKey } = account.state;
if (seal.publicKey === account.state.sealKey?.public) { const seals = getChannelSeals(data);
let crypto = new JSEncrypt(); if (isUnsealed(seals, sealKey)) {
crypto.setPrivateKey(account.state.sealKey.private); const contentKey = getContentKey(seals, sealKey);
sealKey = crypto.decrypt(seal.sealedKey); updateState({ sealed: true, contentKey });
} }
}); else {
updateState({ sealed: true, contentKey: null });
}
}
catch (err) {
console.log(err);
updateState({ sealed: true, contentKey: null });
}
} }
updateState({ sealed: conversation.state.sealed, sealKey }); else {
}, [account.state.sealKey, conversation.state.seals, conversation.state.sealed]); updateState({ sealed: false, contentKey: null });
}
}, [account.state.sealKey, conversation.state.channel?.data?.channelDetail]);
useEffect(() => { useEffect(() => {
let active = false; let active = false;
@ -83,42 +99,133 @@ export function useConversation(cardId, channelId) {
} }
updateState({ upload: active, uploadError, uploadPercent }); updateState({ upload: active, uploadError, uploadPercent });
}, [cardId, channelId, upload]); }, [cardId, channelId, upload.state]);
const setChannel = async () => {
if (!loading.current && conversationId.current) {
const { card, channel } = conversationId.current;
loading.current = true;
conversationId.current = null;
updateState({ loading: true });
await conversation.setChannel(card, channel);
updateState({ loading: false });
loading.current = false;
await setChannel();
}
}
useEffect(() => { useEffect(() => {
updateState({ delayed: false, topics: [] });
setTimeout(() => {
updateState({ delayed: true });
}, 250);
conversation.actions.setChannel(cardId, channelId); conversation.actions.setChannel(cardId, channelId);
// eslint-disable-next-line // eslint-disable-next-line
}, [cardId, channelId]); }, [cardId, channelId]);
const syncTopic = async (item, value) => {
const revision = value.data?.detailRevision;
const detail = value.data?.topicDetail || {};
const identity = profile.state.identity || {};
item.create = detail.created;
const date = new Date(detail.created * 1000);
const now = new Date();
const offset = now.getTime() - date.getTime();
if(offset < 86400000) {
item.createdStr = date.toLocaleTimeString([], {hour: 'numeric', minute:'2-digit'});
}
else if (offset < 31449600000) {
item.createdStr = date.toLocaleDateString("en-US", {day: 'numeric', month:'numeric'});
}
else {
item.createdStr = date.toLocaleDateString("en-US");
}
if (detail.guid === identity.guid) {
item.creator = true;
item.imageUrl = profile.state.imageUrl;
if (identity.name) {
item.name = identity.name;
item.nameSet = true;
}
else {
item.name = `${identity.handle}@${identity.node}`;
item.nameSet = false;
}
}
else {
item.creator = false;
const contact = getProfileByGuid(card.state.cards, detail.guid);
if (contact) {
item.imageUrl = contact.imageSet ? card.actions.getCardImageUrl(contact.cardId) : null;
if (contact.name) {
item.name = contact.name;
item.nameSet = true;
}
else {
item.name = `${contact.handle}@${contact.node}`;
item.nameSet = false;
}
}
else {
item.imageUrl = null;
item.name = 'unknown';
item.nameSet = false;
}
}
if (detail.dataType === 'superbasictopic') {
if (item.revision !== revision) {
try {
const message = JSON.parse(detail.data);
item.text = message.text;
item.textColor = message.textColor ? message.textColor : '#444444';
item.textSize = message.textSize ? message.textSize : 14;
}
catch (err) {
console.log(err);
}
}
}
if (detail.dataType === 'sealedtopic') {
if (item.revision !== revision || item.contentKey !== state.contentKey) {
item.contentKey = state.contentKey;
try {
const subject = decryptTopicSubject(detail.data, state.contentKey);
item.text = subject.message.text;
item.textColor = subject.message.textColor ? subject.message.textColor : '#444444';
item.textSize = subject.message.textSize ? subject.message.textSize : 14;
}
catch (err) {
console.log(err);
}
}
}
item.revision = revision;
};
useEffect(() => { useEffect(() => {
let topics = Array.from(conversation.state.topics.values()).sort((a, b) => { const messages = new Map();
const aTimestamp = a?.data?.topicDetail?.created; conversation.state.topics.forEach((value, topicId) => {
const bTimestamp = b?.data?.topicDetail?.created; let item = topics.current.get(topicId);
if(aTimestamp === bTimestamp) { if (!item) {
item = { topicId };
}
syncTopic(item, value);
messages.set(topicId, item);
});
topics.current = messages;
const sorted = Array.from(messages.values()).sort((a, b) => {
if(a.created === b.created) {
return 0; return 0;
} }
if(aTimestamp == null || aTimestamp < bTimestamp) { if(a.created == null || a.created < b.created) {
return -1; return -1;
} }
return 1; return 1;
}); });
if (topics.length) {
updateState({ delayed: false }); updateState({ topics: sorted });
} // eslint-disable-next-line
else { }, [conversation.state, profile.state, card.state, state.contentKey]);
setTimeout(() => {
updateState({ delayed: true });
}, 250);
}
const { error, loadingInit, loadingMore, subject, logoUrl, logoImg } = conversation.state;
updateState({ topics, error, loadingInit, loadingMore, subject, logoUrl, logoImg });
store.actions.setValue(`${channelId}::${cardId}`, Number(conversation.state.topicRevision));
// eslint-disable-next-line
}, [conversation]);
const actions = { const actions = {
more: () => { more: () => {