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) {
const card = getCardByGuid(cards, guid);
if (card?.data?.cardProfile) {
const { name, handle, imageSet } = card.data.cardProfile;
const { name, handle, imageSet, node } = card.data.cardProfile;
const cardId = card.id;
return { cardId, name, handle, imageSet }
return { cardId, name, handle, imageSet, node }
}
return {};
}

View File

@ -63,7 +63,7 @@ export function decryptChannelSubject(subject, contentKey) {
export function encryptTopicSubject(subject, contentKey) {
const iv = CryptoJS.lib.WordArray.random(128 / 8);
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 messageIv = iv.toString();
return { messageEncrypted, messageIv };

View File

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

View File

@ -153,7 +153,7 @@ export function useChannelContext() {
const subject = message([]);
await addChannelTopic(access.current, channelId, type, subject);
}
await resync();
//await resync();
},
removeTopic: async (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);
}
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) {
@ -240,8 +240,9 @@ export function useConversationContext() {
}
}
marker.current = delta.marker;
marker.current = delta.marker ? delta.marker : marker.current;
setTopicRevision.current = topicRevision;
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 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
@ -107,8 +107,8 @@ export function Conversation({ closeConversation, openDetails, cardId, channelId
)}
</div>
<div class="topic">
{ (!state.sealed || state.sealKey) && (
<AddTopic cardId={cardId} channelId={channelId} sealed={state.sealed} sealKey={state.sealKey} />
{ (!state.sealed || state.contentKey) && (
<AddTopic contentKey={state.contentKey} />
)}
{ state.uploadError && (
<div class="upload-error">

View File

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

View File

@ -2,14 +2,14 @@ import { useContext, useState, useEffect } from 'react';
import { CardContext } from 'context/CardContext';
import { ChannelContext } from 'context/ChannelContext';
import { ConversationContext } from 'context/ConversationContext';
import { encryptTopicSubject } from 'context/sealUtil';
export function useAddTopic(cardId, channelId) {
export function useAddTopic() {
const [state, setState] = useState({
enableImage: null,
enableAudio: null,
enableVideo: null,
sealed: false,
assets: [],
messageText: null,
textColor: '#444444',
@ -50,9 +50,9 @@ export function useAddTopic(cardId, channelId) {
}
useEffect(() => {
const { enableImage, enableAudio, enableVideo, sealed } = conversation.state;
updateState({ enableImage, enableAudio, enableVideo, sealed });
}, [conversation]);
const { enableImage, enableAudio, enableVideo } = conversation.state.channel?.data?.channelDetail || {};
updateState({ enableImage, enableAudio, enableVideo });
}, [conversation.state.channel?.data?.channelDetail]);
const actions = {
addImage: (image) => {
@ -73,7 +73,9 @@ export function useAddTopic(cardId, channelId) {
setPosition: (index, position) => {
updateAsset(index, { position });
},
removeAsset: (idx) => { removeAsset(idx) },
removeAsset: (idx) => {
removeAsset(idx)
},
setTextColor: (value) => {
updateState({ textColorSet: true, textColor: value });
},
@ -83,31 +85,42 @@ export function useAddTopic(cardId, channelId) {
setTextSize: (value) => {
updateState({ textSizeSet: true, textSize: value });
},
addTopic: async (sealed, sealKey) => {
addTopic: async (contentKey) => {
if (!state.busy) {
try {
updateState({ busy: true });
let message = {
text: state.messageText,
textColor: state.textColorSet ? state.textColor : null,
textSize: state.textSizeSet ? state.textSize : null,
const type = contentKey ? 'sealedtopic' : 'superbasictopic';
const message = (assets) => {
if (contentKey) {
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) {
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);
}
}
await conversation.actions.addTopic(type, message, state.assets);
updateState({ busy: false, messageText: null, textColor: '#444444', textColorSet: false,
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 { Carousel } from 'carousel/Carousel';
export function TopicItem({ host, topic, sealed, sealKey }) {
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>
}
export function TopicItem({ host, topic }) {
return (
<TopicItemWrapper>
{ state.init && (
<>
<div class="topic-header">
<div class="avatar">
<Logo width={32} height={32} radius={4} url={state.imageUrl} />
</div>
<div class="info">
<div class={nameClass}>{ name }</div>
<div>{ state.created }</div>
</div>
{ !state.sealed && (
<div class="topic-options">
<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>
)}
</>
)}
<div class="topic-header">
<div class="avatar">
<Logo width={32} height={32} radius={4} url={topic.imageUrl} />
</div>
<div class="info">
<div class={ topic.nameSet ? 'set' : 'unset' }>{ topic.name }</div>
<div>{ topic.createdStr }</div>
</div>
</div>
<div class="message">
<div style={{ color: topic.textColor, fontSize: topic.textSize }}>{ topic.text }</div>
</div>
</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 { AccountContext } from 'context/AccountContext';
import { ConversationContext } from 'context/ConversationContext';
import { UploadContext } from 'context/UploadContext';
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 { decryptTopicSubject } from 'context/sealUtil';
import { getProfileByGuid } from 'context/cardUtil';
export function useConversation(cardId, channelId) {
const [state, setState] = useState({
display: null,
logo: null,
subject: null,
topics: [],
loadingInit: false,
loadingMore: false,
upload: false,
uploadError: false,
uploadPercent: 0,
error: false,
topics: [],
loading: false,
sealed: false,
sealKey: null,
delayed: false,
contentKey: null,
});
const profile = useContext(ProfileContext);
const card = useContext(CardContext);
const account = useContext(AccountContext);
const viewport = useContext(ViewportContext);
const conversation = useContext(ConversationContext);
const upload = useContext(UploadContext);
const store = useContext(StoreContext);
const loading = useRef(false);
const conversationId = useRef(null);
const topics = useRef(new Map());
const updateState = (value) => {
setState((s) => ({ ...s, ...value }));
}
@ -39,19 +46,28 @@ export function useConversation(cardId, channelId) {
}, [viewport]);
useEffect(() => {
let sealKey;
const seals = conversation.state.seals;
if (seals?.length > 0) {
seals.forEach(seal => {
if (seal.publicKey === account.state.sealKey?.public) {
let crypto = new JSEncrypt();
crypto.setPrivateKey(account.state.sealKey.private);
sealKey = crypto.decrypt(seal.sealedKey);
const { dataType, data } = conversation.state.channel?.data?.channelDetail || {};
if (dataType === 'sealed') {
try {
const { sealKey } = account.state;
const seals = getChannelSeals(data);
if (isUnsealed(seals, sealKey)) {
const contentKey = getContentKey(seals, sealKey);
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 });
}, [account.state.sealKey, conversation.state.seals, conversation.state.sealed]);
else {
updateState({ sealed: false, contentKey: null });
}
}, [account.state.sealKey, conversation.state.channel?.data?.channelDetail]);
useEffect(() => {
let active = false;
@ -83,42 +99,133 @@ export function useConversation(cardId, channelId) {
}
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(() => {
updateState({ delayed: false, topics: [] });
setTimeout(() => {
updateState({ delayed: true });
}, 250);
conversation.actions.setChannel(cardId, channelId);
// eslint-disable-next-line
}, [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(() => {
let topics = Array.from(conversation.state.topics.values()).sort((a, b) => {
const aTimestamp = a?.data?.topicDetail?.created;
const bTimestamp = b?.data?.topicDetail?.created;
if(aTimestamp === bTimestamp) {
const messages = new Map();
conversation.state.topics.forEach((value, topicId) => {
let item = topics.current.get(topicId);
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;
}
if(aTimestamp == null || aTimestamp < bTimestamp) {
if(a.created == null || a.created < b.created) {
return -1;
}
return 1;
});
if (topics.length) {
updateState({ delayed: false });
}
else {
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]);
updateState({ topics: sorted });
// eslint-disable-next-line
}, [conversation.state, profile.state, card.state, state.contentKey]);
const actions = {
more: () => {