refactor of conversation detail in webapp

This commit is contained in:
Roland Osborne 2023-01-27 14:51:24 -08:00
parent a1549747db
commit 9fbfe85d60
8 changed files with 264 additions and 163 deletions

View File

@ -46,6 +46,16 @@ export function encryptChannelSubject(subject, publicKeys) {
return { subjectEncrypted, subjectIv, seals }; return { subjectEncrypted, subjectIv, seals };
} }
export function updateChannelSubject(subject, contentKey) {
const key = CryptoJS.enc.Hex.parse(contentKey);
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();
return { subjectEncrypted, subjectIv };
}
export function decryptChannelSubject(subject, contentKey) { export function decryptChannelSubject(subject, contentKey) {
const { subjectEncrypted, subjectIv } = JSON.parse(subject); const { subjectEncrypted, subjectIv } = JSON.parse(subject);
const iv = CryptoJS.enc.Hex.parse(subjectIv); const iv = CryptoJS.enc.Hex.parse(subjectIv);

View File

@ -4,7 +4,7 @@ import { SelectItem } from './selectItem/SelectItem';
import { useCardSelect } from './useCardSelect.hook'; import { useCardSelect } from './useCardSelect.hook';
import { Logo } from 'logo/Logo'; import { Logo } from 'logo/Logo';
export function CardSelect({ filter, unknown, select, selected, markup, emptyMessage }) { export function CardSelect({ filter, unknown, select, selected, markup, emptyMessage, setItem, clearItem }) {
const { state } = useCardSelect(filter); const { state } = useCardSelect(filter);
@ -13,7 +13,7 @@ export function CardSelect({ filter, unknown, select, selected, markup, emptyMes
{ state.cards?.length > 0 && ( { state.cards?.length > 0 && (
<List local={{ emptyText: '' }} itemLayout="horizontal" dataSource={state.cards} gutter="0" <List local={{ emptyText: '' }} itemLayout="horizontal" dataSource={state.cards} gutter="0"
renderItem={item => ( renderItem={item => (
<SelectItem item={item} select={select} selected={selected} markup={markup} /> <SelectItem item={item} select={select} selected={selected} markup={markup} setItem={setItem} clearItem={clearItem} />
)} )}
/> />
)} )}

View File

@ -3,7 +3,7 @@ import { SelectItemWrapper, Markup } from './SelectItem.styled';
import { useSelectItem } from './useSelectItem.hook'; import { useSelectItem } from './useSelectItem.hook';
import { Logo } from 'logo/Logo'; import { Logo } from 'logo/Logo';
export function SelectItem({ item, select, selected, markup }) { export function SelectItem({ item, select, selected, markup, setItem, clearItem }) {
const { state } = useSelectItem(item, selected, markup); const { state } = useSelectItem(item, selected, markup);
const profile = item?.data?.cardProfile; const profile = item?.data?.cardProfile;
@ -19,6 +19,12 @@ export function SelectItem({ item, select, selected, markup }) {
if (select) { if (select) {
select(item.id); select(item.id);
} }
if (setItem && !state.selected) {
setItem(item.id);
}
if (clearItem && state.selected) {
clearItem(item.id);
}
} }
return ( return (
@ -29,7 +35,7 @@ export function SelectItem({ item, select, selected, markup }) {
<div class="name">{ profile?.name }</div> <div class="name">{ profile?.name }</div>
<div class="handle">{ handle() }</div> <div class="handle">{ handle() }</div>
</div> </div>
{ select && ( { (select || setItem || clearItem) && (
<div class="switch"> <div class="switch">
<Switch checked={state.selected} onChange={onSelect} size="small" /> <Switch checked={state.selected} onChange={onSelect} size="small" />
</div> </div>

View File

@ -132,7 +132,7 @@ export function useChannels() {
item.sealKey = sealKey; item.sealKey = sealKey;
} }
if (item.title == null || typeof item.title !== 'string') { if (item.title == null || item.title === '' || typeof item.title !== 'string') {
item.subject = item.label; item.subject = item.label;
} }
else { else {

View File

@ -9,10 +9,38 @@ import { EditSubject } from './editSubject/EditSubject';
import { EditMembers } from './editMembers/EditMembers'; import { EditMembers } from './editMembers/EditMembers';
import { UnlockOutlined, LockFilled } from '@ant-design/icons'; import { UnlockOutlined, LockFilled } from '@ant-design/icons';
export function Details({ cardId, channelId, closeDetails, closeConversation, openContact }) { export function Details({ closeDetails, closeConversation, openContact }) {
const [modal, modalContext] = Modal.useModal(); const [modal, modalContext] = Modal.useModal();
const { state, actions } = useDetails(cardId, channelId); const { state, actions } = useDetails();
const setMember = async (id) => {
try {
await actions.setMember(id);
}
catch(err) {
console.log(err);
modal.error({
title: 'Failed to Set Conversation Member',
content: 'Please try again.',
bodyStyle: { padding: 16 },
});
}
}
const clearMember = async (id) => {
try {
await actions.clearMember(id);
}
catch(err) {
console.log(err);
modal.error({
title: 'Failed to Clear Conversation Member',
content: 'Please try again.',
bodyStyle: { padding: 16 },
});
}
}
const deleteChannel = async () => { const deleteChannel = async () => {
modal.confirm({ modal.confirm({
@ -30,7 +58,7 @@ export function Details({ cardId, channelId, closeDetails, closeConversation, op
const applyDeleteChannel = async () => { const applyDeleteChannel = async () => {
try { try {
await actions.deleteChannel(); await actions.removeChannel();
closeConversation(); closeConversation();
} }
catch(err) { catch(err) {
@ -58,7 +86,7 @@ export function Details({ cardId, channelId, closeDetails, closeConversation, op
const applyLeaveChannel = async () => { const applyLeaveChannel = async () => {
try { try {
await actions.leaveChannel(); await actions.removeChannel();
closeConversation(); closeConversation();
} }
catch(err) { catch(err) {
@ -121,19 +149,24 @@ export function Details({ cardId, channelId, closeDetails, closeConversation, op
<div class="content"> <div class="content">
<div class="description"> <div class="description">
<div class="logo"> <div class="logo">
<Logo width={72} height={72} radius={4} img={state.img} /> <Logo src={state.logo} width={72} height={72} radius={4} img={state.img} />
</div> </div>
<div class="stats"> <div class="stats">
{ state.host && ( { state.host && (
<div class="subject" onClick={actions.setEditSubject}> <div class="subject" onClick={actions.setEditSubject}>
{ state.locked && !state.unlocked && ( { state.sealed && !state.contentKey && (
<LockFilled style={{ paddingRight: 4 }} /> <LockFilled style={{ paddingRight: 4 }} />
)} )}
{ state.locked && state.unlocked && ( { state.sealed && state.contentKey && (
<UnlockOutlined style={{ paddingRight: 4 }} /> <UnlockOutlined style={{ paddingRight: 4 }} />
)} )}
<span>{ state.subject }</span> { state.title && (
{ state.editable && ( <span>{ state.title }</span>
)}
{ !state.title && (
<span>{ state.label }</span>
)}
{ (!state.sealed || state.contentKey) && (
<span class="edit" onClick={actions.setEditSubject}> <span class="edit" onClick={actions.setEditSubject}>
<EditOutlined style={{ paddingLeft: 4 }}/> <EditOutlined style={{ paddingLeft: 4 }}/>
</span> </span>
@ -142,13 +175,18 @@ export function Details({ cardId, channelId, closeDetails, closeConversation, op
)} )}
{ !state.host && ( { !state.host && (
<div class="subject"> <div class="subject">
{ state.locked && !state.unlocked && ( { state.sealed && !state.contentKey && (
<LockFilled style={{ paddingRight: 4 }} /> <LockFilled style={{ paddingRight: 4 }} />
)} )}
{ state.locked && state.unlocked && ( { state.sealed && state.contentKey && (
<UnlockOutlined style={{ paddingRight: 4 }} /> <UnlockOutlined style={{ paddingRight: 4 }} />
)} )}
<span>{ state.subject }</span> { state.title && (
<span>{ state.title }</span>
)}
{ !state.title && (
<span>{ state.label }</span>
)}
</div> </div>
)} )}
{ state.host && ( { state.host && (
@ -163,7 +201,7 @@ export function Details({ cardId, channelId, closeDetails, closeConversation, op
{ state.host && ( { state.host && (
<div class="button" onClick={deleteChannel}>Delete Topic</div> <div class="button" onClick={deleteChannel}>Delete Topic</div>
)} )}
{ state.host && !state.locked && ( { state.host && !state.sealed && (
<div class="button" onClick={actions.setEditMembers}>Edit Membership</div> <div class="button" onClick={actions.setEditMembers}>Edit Membership</div>
)} )}
{ !state.host && ( { !state.host && (
@ -172,21 +210,21 @@ export function Details({ cardId, channelId, closeDetails, closeConversation, op
<div class="label">Members</div> <div class="label">Members</div>
<div class="members"> <div class="members">
<CardSelect filter={(item) => { <CardSelect filter={(item) => {
if(state.contacts.includes(item.id)) { if(state.members.includes(item.id)) {
return true; return true;
} }
return false; return false;
}} unknown={state.unknown} }} unknown={state.unknown}
markup={cardId} /> />
</div> </div>
</div> </div>
<Modal title="Edit Subject" centered visible={state.editSubject} footer={editSubjectFooter} <Modal title="Edit Subject" centered visible={state.showEditSubject} footer={editSubjectFooter}
bodyStyle={{ padding: 16 }} onCancel={actions.clearEditSubject}> bodyStyle={{ padding: 16 }} onCancel={actions.clearEditSubject}>
<EditSubject state={state} actions={actions} /> <EditSubject subject={state.editSubject} setSubject={actions.setSubjectUpdate} />
</Modal> </Modal>
<Modal title="Edit Members" centered visible={state.editMembers} footer={editMembersFooter} <Modal title="Edit Members" centered visible={state.showEditMembers} footer={editMembersFooter}
bodyStyle={{ padding: 16 }} onCancel={actions.clearEditMembers}> bodyStyle={{ padding: 16 }} onCancel={actions.clearEditMembers}>
<EditMembers state={state} actions={actions} /> <EditMembers members={state.editMembers} setMember={setMember} clearMember={clearMember} />
</Modal> </Modal>
</DetailsWrapper> </DetailsWrapper>
); );

View File

@ -1,14 +1,15 @@
import { EditMembersWrapper } from './EditMembers.styled'; import { EditMembersWrapper } from './EditMembers.styled';
import { CardSelect } from '../../cardSelect/CardSelect'; import { CardSelect } from '../../cardSelect/CardSelect';
export function EditMembers({ state, actions }) { export function EditMembers({ members, setMember, clearMember }) {
return ( return (
<EditMembersWrapper> <EditMembersWrapper>
<div class="list"> <div class="list">
<CardSelect <CardSelect
select={actions.onMember} setItem={setMember}
selected={state.members} clearItem={clearMember}
selected={members}
filter={(card) => card?.data?.cardDetail?.status === 'connected'} filter={(card) => card?.data?.cardDetail?.status === 'connected'}
/> />
</div> </div>

View File

@ -1,12 +1,12 @@
import { Input } from 'antd'; import { Input } from 'antd';
import { EditSubjectWrapper } from './EditSubject.styled'; import { EditSubjectWrapper } from './EditSubject.styled';
export function EditSubject({ state, actions }) { export function EditSubject({ subject, setSubject }) {
return ( return (
<EditSubjectWrapper> <EditSubjectWrapper>
<Input placeholder="Subject (optional)" spellCheck="false" autocapitalize="word" <Input placeholder="Subject (optional)" spellCheck="false" autocapitalize="word"
value={state.subjectUpdate} onChange={(e) => actions.setSubjectUpdate(e.target.value)} /> value={subject} onChange={(e) => setSubject(e.target.value)} />
</EditSubjectWrapper> </EditSubjectWrapper>
); );
} }

View File

@ -1,190 +1,236 @@
import { useContext, useState, useEffect } from 'react'; import { useContext, useState, useEffect, useRef } from 'react';
import { CardContext } from 'context/CardContext'; import { CardContext } from 'context/CardContext';
import { ChannelContext } from 'context/ChannelContext'; import { ConversationContext } from 'context/ConversationContext';
import { AccountContext } from 'context/AccountContext'; import { AccountContext } from 'context/AccountContext';
import { ProfileContext } from 'context/ProfileContext';
import { ViewportContext } from 'context/ViewportContext'; import { ViewportContext } from 'context/ViewportContext';
import { getCardByGuid } from 'context/cardUtil';
import { decryptChannelSubject, updateChannelSubject, getContentKey, getChannelSeals, isUnsealed } from 'context/sealUtil';
export function useDetails(cardId, channelId) { export function useDetails() {
const [state, setState] = useState({ const [state, setState] = useState({
logo: null, logo: null,
img: null, img: null,
subject: null,
server: null,
started: null, started: null,
host: null, host: false,
contacts: [], title: null,
members: new Set(), label: null,
editSubject: false, members: [],
editMembers: false,
busy: false,
subjectUpdate: null,
unknown: 0, unknown: 0,
showEditMembers: false,
editMembers: new Set(),
showEditSubject: false,
editSubject: null,
display: null, display: null,
locked: false, sealed: false,
unlocked: false, contentKey: null,
editable: false, seals: null,
}); });
const conversation = useContext(ConversationContext);
const card = useContext(CardContext); const card = useContext(CardContext);
const account = useContext(AccountContext); const account = useContext(AccountContext);
const channel = useContext(ChannelContext); const viewport = useContext(ViewportContext);
const viewport = useContext(ViewportContext); const profile = useContext(ProfileContext);
const cardId = useRef();
const channelId = useRef();
const key = useRef();
const detailRevision = useRef();
const updateState = (value) => { const updateState = (value) => {
setState((s) => ({ ...s, ...value })); setState((s) => ({ ...s, ...value }));
} }
useEffect(() => {
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 decKey = getContentKey(seals, sealKey);
updateState({ sealed: true, contentKey: decKey, seals });
}
else {
updateState({ sealed: true, contentKey: null });
}
}
catch (err) {
console.log(err);
updateState({ sealed: true, contentKey: null });
}
}
else {
updateState({ sealed: false, contentKey: null });
}
// eslint-disable-next-line
}, [account.state.sealKey, conversation.state.channel?.data?.channelDetail]);
useEffect(() => { useEffect(() => {
updateState({ display: viewport.state.display }); updateState({ display: viewport.state.display });
}, [viewport]); }, [viewport]);
useEffect(() => { useEffect(() => {
let img, subject, subjectUpdate, host, started, contacts, locked, unlocked, editable;
let chan; const cardValue = conversation.state.card;
if (cardId) { const channelValue = conversation.state.channel;
const cardChan = card.state.cards.get(cardId);
if (cardChan) { // extract channel created info
chan = cardChan.channels.get(channelId); let started;
} let host;
const date = new Date(channelValue?.data?.channelDetail?.created * 1000);
const now = new Date();
if(now.getTime() - date.getTime() < 86400000) {
started = date.toLocaleTimeString([], {hour: 'numeric', minute:'2-digit'});
} }
else { else {
chan = channel.state.channels.get(channelId); started = date.toLocaleDateString("en-US");
} }
if (cardValue) {
if (chan) { host = false;
if (chan.contacts?.length === 0) {
img = 'solution';
subject = 'Notes';
}
else if (chan.contacts?.length > 1) {
img = 'appstore'
subject = 'Group';
}
else {
img = 'team';
subject = 'Conversation'
}
if (chan.data.channelDetail.dataType === 'sealed') {
editable = false;
try {
const seals = JSON.parse(chan.data.channelDetail.data).seals;
seals.forEach(seal => {
if (account.state.sealKey?.public === seal.publicKey) {
editable = true;
}
});
}
catch (err) {
console.log(err);
}
locked = true;
unlocked = chan.data.unsealedChannel != null;
if (chan.data.unsealedChannel?.subject) {
subject = chan.data.unsealedChannel.subject;
subjectUpdate = subject;
}
}
else {
locked = false;
editable = (chan.cardId == null);
try {
const parsed = JSON.parse(chan.data.channelDetail.data);
if (parsed.subject) {
subject = parsed.subject;
subjectUpdate = subject;
}
}
catch(err) {
console.log(err);
}
}
const date = new Date(chan.data.channelDetail.created * 1000);
const now = new Date();
if(now.getTime() - date.getTime() < 86400000) {
started = date.toLocaleTimeString([], {hour: 'numeric', minute:'2-digit'});
}
else {
started = date.toLocaleDateString("en-US");
}
if (chan.cardId) {
host = false;
}
else {
host = true;
}
}
if (chan?.contacts ) {
contacts = chan.contacts.map((contact) => contact?.id);
} }
else { else {
contacts = []; host = true;
} }
let members = new Set(contacts); // extract member info
let memberCount = 0;
let names = [];
let img;
let logo;
let members = [];
let unknown = 0; let unknown = 0;
contacts.forEach(id => { if (cardValue) {
if (id == null) { members.push(cardValue.id);
unknown++; const profile = cardValue.data?.cardProfile;
if (profile?.name) {
names.push(profile.name);
} }
}); if (profile?.imageSet) {
img = null;
logo = card.actions.getCardImageUrl(cardValue.id);
}
else {
img = 'avatar';
logo = null;
}
memberCount++;
}
if (channelValue?.data?.channelDetail?.members) {
for (let guid of channelValue.data.channelDetail.members) {
if (guid !== profile.state.identity.guid) {
const contact = getCardByGuid(card.state.cards, guid);
if (contact) {
members.push(contact.id);
}
else {
unknown++;
}
const profile = contact?.data?.cardProfile;
if (profile?.name) {
names.push(profile.name);
}
if (profile?.imageSet) {
img = null;
logo = card.actions.getCardImageUrl(contact.id);
}
else {
img = 'avatar';
logo = null;
}
memberCount++;
}
}
}
updateState({ img, subject, host, started, contacts, members, unknown, subjectUpdate, locked, unlocked, editable }); let label;
}, [cardId, channelId, card, channel, account]); if (memberCount === 0) {
img = 'solution';
label = 'Notes';
}
else if (memberCount === 1) {
label = names.join(',');
}
else {
img = 'appstore';
label = names.join(',');
}
if (cardId.current !== cardValue?.id || channelId.current !== channelValue?.id ||
detailRevision.current !== channelValue?.data?.detailRevision || key.current !== state.contentKey) {
let title;
try {
const detail = channelValue?.data?.channelDetail;
if (detail?.dataType === 'sealed' && state.contentKey) {
const unsealed = decryptChannelSubject(detail.data, state.contentKey);
title = unsealed.subject;
}
else if (detail?.dataType === 'superbasic') {
const data = JSON.parse(detail.data);
title = data.subject;
}
}
catch(err) {
console.log(err);
}
cardId.current = cardValue?.id;
channelId.current = channelValue?.id;
detailRevision.current = channelValue?.data?.detailRevision;
key.current = state.contentKey;
updateState({ started, host, title, label, img, logo, unknown, members,
editSubject: title, editMembers: new Set(members) });
}
else {
updateState({ started, host, label, img, logo, unknown, members,
editMembers: new Set(members) });
}
// eslint-disable-next-line
}, [conversation.state, card.state, state.contentKey]);
const actions = { const actions = {
setEditSubject: () => { setEditSubject: () => {
updateState({ editSubject: true }); updateState({ showEditSubject: true });
}, },
clearEditSubject: () => { clearEditSubject: () => {
updateState({ editSubject: false }); updateState({ showEditSubject: false });
}, },
setSubjectUpdate: (subjectUpdate) => { setSubjectUpdate: (editSubject) => {
updateState({ subjectUpdate }); updateState({ editSubject });
}, },
setSubject: async () => { setSubject: async () => {
if (!state.busy) { if (state.sealed) {
try { if (state.contentKey) {
updateState({ busy: true }); const updated = updateChannelSubject(state.editSubject, state.contentKey);
if (state.locked) { updated.seals = state.seals;
channel.actions.setChannelSealedSubject(channelId, state.subjectUpdate, account.state.sealKey); await conversation.actions.setChannelSubject('sealed', updated);
}
else {
channel.actions.setChannelSubject(channelId, state.subjectUpdate);
}
updateState({ busy: false });
}
catch(err) {
console.log(err);
updateState({ busy: false });
throw new Error("set channel subject failed");
} }
} }
else { else {
throw new Error('operation in progress'); const subject = { subject: state.editSubject };
await conversation.actions.setChannelSubject('superbasic', subject);
} }
}, },
setEditMembers: () => { setEditMembers: () => {
updateState({ editMembers: true }); updateState({ editMembers: new Set(state.members), showEditMembers: true });
}, },
clearEditMembers: () => { clearEditMembers: () => {
updateState({ editMembers: false }); updateState({ showEditMembers: false });
}, },
onMember: async (card) => { setMember: async (id) => {
if (state.members.has(card)) { await conversation.actions.setChannelCard(id);
channel.actions.clearChannelCard(channelId, card);
}
else {
channel.actions.setChannelCard(channelId, card);
}
}, },
deleteChannel: async () => { clearMember: async (id) => {
await channel.actions.removeChannel(channelId); await conversation.actions.clearChannelCard(id);
},
removeChannel: async () => {
await conversation.actions.removeChannel();
}, },
leaveChannel: async () => {
await card.actions.removeChannel(cardId, channelId);
}
}; };
return { state, actions }; return { state, actions };