contact: add follow and unfollow support

this creates a kind 3 event that includes a list of profiles that
the user is following.

the feed is still the public global feed and individual feed with
only events from followed pubkeys will be added in the next commit.

also added proper primary and secondary button styles.
OFF0 1 year ago
parent 208ea6363a
commit 36327f6151
Signed by: offbyn
GPG Key ID: 94A2F643C51F37FA

@ -1,29 +1,69 @@
import {Event, nip19} from 'nostr-tools'; import {Event, nip19, signEvent} from 'nostr-tools';
import {elem} from './utils/dom'; import {elem} from './utils/dom';
import {dateTime} from './utils/time'; import {dateTime} from './utils/time';
import {isPTag, sortByCreatedAt} from './events'; import {isNotNonceTag, isPTag} from './events';
import {getViewContent} from './view'; import {getViewContent, getViewElem, getViewOptions, setViewElem} from './view';
import {powEvent} from './system';
import {config} from './settings';
import {getMetadata} from './profiles'; import {getMetadata} from './profiles';
import {publish} from './relays';
import {parseJSON} from './media';
const contactHistoryMap: { const contactHistoryMap: {
[pubkey: string]: Event[] [pubkey: string]: Event[];
} = {}; } = {};
/**
* returns true if user is following pubkey
*/
export const isFollowing = (id: string) => {
const following = contactHistoryMap[config.pubkey]?.at(0);
if (!following) {
return false;
}
return following.tags.some(([tag, value]) => tag === 'p' && value === id);
};
export const updateFollowBtn = (pubkey: string) => {
const followBtn = getViewElem('followBtn');
if (followBtn) {
const hasContact = isFollowing(pubkey);
followBtn.textContent = hasContact ? 'unfollow' : 'follow';
followBtn.classList.remove('primary', 'secondary');
followBtn.classList.add(hasContact ? 'secondary' : 'primary');
followBtn.hidden = false;
}
};
const updateFollowing = (evt: Event) => { const updateFollowing = (evt: Event) => {
const following = getViewContent().querySelector(`[data-following="${evt.pubkey}"]`); const view = getViewOptions();
if (evt.pubkey === config.pubkey) {
localStorage.setItem('follwing', JSON.stringify(evt));
}
if (view.type === 'profile') {
updateFollowBtn(view.id);
if (view.id === evt.pubkey) {
// update following link
const following = getViewElem('following') as HTMLElement;
if (following) { if (following) {
const count = evt.tags.filter(isPTag).length; const count = evt.tags.filter(isPTag).length;
const anchor = elem('a', { const anchor = elem('a', {
data: {following: evt.pubkey}, data: {following: evt.pubkey},
href: `/${evt.id}`, href: `/${evt.id}`,
title: dateTime.format(new Date(evt.created_at * 1000)), title: dateTime.format(evt.created_at * 1000),
}, `following ${count}`); }, [
'following ',
elem('span', {className: 'highlight'}, count),
]);
following.replaceWith(anchor); following.replaceWith(anchor);
setViewElem('following', anchor);
}
}
} }
}; };
export const setContactList = (evt: Event) => { export const setContactList = (evt: Event) => {
let contactHistory = contactHistoryMap[evt.pubkey]; const contactHistory = contactHistoryMap[evt.pubkey];
if (!contactHistory) { if (!contactHistory) {
contactHistoryMap[evt.pubkey] = [evt]; contactHistoryMap[evt.pubkey] = [evt];
updateFollowing(evt); updateFollowing(evt);
@ -32,9 +72,8 @@ export const setContactList = (evt: Event) => {
if (contactHistory.find(({id}) => id === evt.id)) { if (contactHistory.find(({id}) => id === evt.id)) {
return; return;
} }
contactHistory.push(evt); contactHistory.unshift(evt);
contactHistory.sort(sortByCreatedAt); updateFollowing(contactHistory[0]); // TODO: ensure that this is newest contactlist?
updateFollowing(contactHistory[0]);
}; };
/** /**
@ -49,13 +88,53 @@ const findChanges = (current: Event, previous: Event) => {
return [addedContacts, removedContacts]; return [addedContacts, removedContacts];
}; };
export const resetContactList = (pubkey: string) => {
delete contactHistoryMap[pubkey];
};
export const getContactUpdateMessage = (
addedList: string[][],
removedList: string[][],
) => {
const content = [];
if (addedList.length && addedList[0]) {
const pubkey = addedList[0][1];
const {userName} = getMetadata(pubkey);
const npub = nip19.npubEncode(pubkey);
content.push(
'follows ',
elem('a', {href: `/${npub}`, data: {profile: pubkey}}, userName),
);
}
if (addedList.length > 1) {
content.push(` (+ ${addedList.length - 1} others)`);
}
if (removedList?.length > 0) {
if (content.length) {
content.push(' and');
}
content.push(' unfollowed ');
if (removedList.length > 1) {
content.push(`${removedList.length}`);
} else {
const removedPubkey = removedList[0][1];
const {userName: removeduserName} = getMetadata(removedPubkey);
const removedNpub = nip19.npubEncode(removedPubkey);
content.push(elem('a', {href: `/${removedNpub}`, data: {profile: removedPubkey}}, removeduserName));
}
}
return content;
};
export const updateContactList = (evt: Event) => { export const updateContactList = (evt: Event) => {
const contactHistory = contactHistoryMap[evt.pubkey]; const contactHistory = contactHistoryMap[evt.pubkey];
if (contactHistory.length === 1) { if (contactHistory.length === 1) {
return [contactHistory[0].tags.filter(isPTag)]; return [contactHistory[0].tags.filter(isPTag)];
} }
const pos = contactHistory.findIndex(({id}) => id === evt.id); const pos = contactHistory.findIndex(({id}) => id === evt.id);
if (evt.id === contactHistory.at(-1)?.id) { // oldest known contact-list update if (evt.id !== contactHistory.at(-1)?.id) { // not oldest known contact-list update
return findChanges(evt, contactHistory[pos + 1]);
}
// update existing contact entries // update existing contact entries
contactHistory contactHistory
.slice(0, -1) .slice(0, -1)
@ -67,30 +146,93 @@ export const updateContactList = (evt: Event) => {
contactNote?.replaceChildren(...updated); contactNote?.replaceChildren(...updated);
}); });
return [evt.tags.filter(isPTag)]; return [evt.tags.filter(isPTag)];
};
export const getContacts = () => {
const following = contactHistoryMap[config.pubkey]?.at(0); // TODO: ensure newest contactlist
if (following) {
return following.tags
.filter(isPTag)
.map(([, pubkey]) => pubkey);
} }
return findChanges(evt, contactHistory[pos + 1]); const followingFromStorage = localStorage.getItem('follwing');
if (followingFromStorage) {
const follwingData = parseJSON(followingFromStorage) as Event;
// TODO: ensure signature matches
if (follwingData && follwingData.pubkey === config.pubkey) {
return follwingData.tags
.filter(isPTag)
.map(([, pubkey]) => pubkey);
}
}
return [];
}; };
export const getContactUpdateMessage = ( const updateContactTags = (
addedList: string[][], followeeID: string,
removedList: string[][], currentContactList: Event | undefined,
) => { ) => {
const content = []; if (!currentContactList?.tags) {
// console.log(addedContacts) return [['p', followeeID], ['p', config.pubkey]];
if (addedList.length && addedList[0]) {
const pubkey = addedList[0][1];
const {userName} = getMetadata(pubkey);
const npub = nip19.npubEncode(pubkey);
content.push(
'follows ',
elem('a', {href: `/${npub}`, data: {profile: pubkey}}, userName),
);
} }
if (addedList.length > 1) { if (currentContactList.tags.some(([tag, id]) => tag === 'p' && id === followeeID)) {
content.push(` (+ ${addedList.length - 1} others)`); return currentContactList.tags
.filter(([tag, id]) => tag === 'p' && id !== followeeID);
} }
if (removedList?.length > 0) { return [
content.push(elem('small', {}, ` and unfollowed ${removedList.length}`)); ['p', followeeID],
...currentContactList.tags
.filter(isNotNonceTag),
];
};
export const followContact = async (id: string) => {
const followBtn = getViewElem('followBtn') as HTMLButtonElement;
const statusElem = getViewElem('followStatus') as HTMLElement;
if (!followBtn || !statusElem) {
return;
} }
return content; const following = contactHistoryMap[config.pubkey]?.at(0);
const unsignedEvent = {
kind: 3,
pubkey: config.pubkey,
content: '',
tags: updateContactTags(id, following),
created_at: Math.floor(Date.now() * 0.001),
};
followBtn.disabled = true;
const newContactListEvent = await powEvent(unsignedEvent, {
difficulty: config.difficulty,
statusElem,
timeout: config.timeout,
}).catch(console.warn);
if (!newContactListEvent) {
statusElem.textContent = '';
statusElem.hidden = false;
followBtn.disabled = false;
return;
}
const privatekey = localStorage.getItem('private_key');
if (!privatekey) {
statusElem.textContent = 'no private key to sign';
statusElem.hidden = false;
followBtn.disabled = false;
return;
}
const sig = signEvent(newContactListEvent, privatekey);
// TODO: validateEvent?
if (sig) {
statusElem.textContent = 'publishing…';
publish({...newContactListEvent, sig}, (relay, error) => {
if (error) {
return console.error(error, relay);
}
statusElem.hidden = true;
followBtn.disabled = false;
console.info(`event published by ${relay}`);
});
}
}; };

@ -1,9 +1,11 @@
import {Event} from 'nostr-tools'; import {Event} from 'nostr-tools';
import {zeroLeadingBitsCount} from './utils/crypto'; import {zeroLeadingBitsCount} from './utils/crypto';
export const isEvent = <T>(evt?: T): evt is T => evt !== undefined;
export const isMention = ([tag, , , marker]: string[]) => tag === 'e' && marker === 'mention'; export const isMention = ([tag, , , marker]: string[]) => tag === 'e' && marker === 'mention';
export const isPTag = ([tag]: string[]) => tag === 'p'; export const isPTag = ([tag]: string[]) => tag === 'p';
export const hasEventTag = (tag: string[]) => tag[0] === 'e'; export const hasEventTag = (tag: string[]) => tag[0] === 'e';
export const isNotNonceTag = ([tag]: string[]) => tag !== 'nonce';
/** /**
* validate proof-of-work of a nostr event per nip-13. * validate proof-of-work of a nostr event per nip-13.

@ -24,8 +24,8 @@
<legend>write a new note</legend> <legend>write a new note</legend>
<textarea name="message" rows="1"></textarea> <textarea name="message" rows="1"></textarea>
<div class="buttons"> <div class="buttons">
<button type="submit" id="publish" disabled>send</button> <button type="submit" id="publish" class="primary" disabled>send</button>
<button type="button" name="back">back</button> <button type="button" name="back" class="primary">back</button>
</div> </div>
<small id="sendstatus" class="form-status"></small> <small id="sendstatus" class="form-status"></small>
</fieldset> </fieldset>
@ -42,7 +42,7 @@
<input type="url" name="picture" id="profile_picture" placeholder="https://your.domain/image.png"> <input type="url" name="picture" id="profile_picture" placeholder="https://your.domain/image.png">
<div class="buttons"> <div class="buttons">
<small id="profilestatus" class="form-status" hidden></small> <small id="profilestatus" class="form-status" hidden></small>
<button type="submit" name="publish" tabindex="0" disabled>publish</button> <button type="submit" name="publish" class="primary" tabindex="0" disabled>publish</button>
</div> </div>
</form> </form>
<form action="#" name="options"> <form action="#" name="options">
@ -86,8 +86,8 @@
<input type="password" id="privatekey" autocomplete="off"> <input type="password" id="privatekey" autocomplete="off">
<div class="buttons"> <div class="buttons">
<small id="keystatus" class="form-status" hidden></small> <small id="keystatus" class="form-status" hidden></small>
<button type="button" name="generate" tabindex="0">new</button> <button type="button" name="generate" class="primary" tabindex="0">new</button>
<button type="button" name="import" tabindex="0" disabled>save</button> <button type="button" name="import" class="primary" tabindex="0" disabled>save</button>
</div> </div>
</form> </form>
<footer class="text"> <footer class="text">

@ -10,7 +10,7 @@ import {clearView, getViewContent, getViewElem, getViewOptions, setViewElem, vie
import {handleReaction, handleUpvote} from './reactions'; import {handleReaction, handleUpvote} from './reactions';
import {closePublishView, openWriteInput, togglePublishView} from './write'; import {closePublishView, openWriteInput, togglePublishView} from './write';
import {handleMetadata, renderProfile} from './profiles'; import {handleMetadata, renderProfile} from './profiles';
import {getContactUpdateMessage, setContactList, updateContactList} from './contacts'; import {followContact, getContactUpdateMessage, setContactList, updateContactList} from './contacts';
import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes'; import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes';
import {createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui'; import {createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui';
@ -317,6 +317,9 @@ const handleButton = (button: HTMLButtonElement) => {
const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id)); const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id));
note && handleUpvote(note); note && handleUpvote(note);
break; break;
case 'follow':
followContact(id);
break;
} }
} }
// const container = e.target.closest('[data-append]'); // const container = e.target.closest('[data-append]');

@ -49,9 +49,6 @@ a.mbox-img:focus {
padding-bottom: var(--gap-half); padding-bottom: var(--gap-half);
word-break: break-word; word-break: break-word;
} }
.mbox-content a {
text-decoration: underline;
}
.mbox-img + .mbox-body { .mbox-img + .mbox-body {
--max-width: calc(100% - var(--profileimg-size) - var(--gap-half)); --max-width: calc(100% - var(--profileimg-size) - var(--gap-half));
flex-basis: var(--max-width); flex-basis: var(--max-width);
@ -66,28 +63,38 @@ a.mbox-img:focus {
display: flex; display: flex;
gap: var(--gap-quarter); gap: var(--gap-quarter);
justify-content: space-between; justify-content: space-between;
margin-bottom: .2rem; margin: .1rem 0;
margin-top: 0;
min-height: 1.8rem; min-height: 1.8rem;
} }
.mbox-header a { .mbox-header a {
font-size: var(--font-small); font-size: var(--font-small);
line-height: var(--lineheight-small);
text-decoration: none;
} }
.mbox-header small { .mbox-header small {
white-space: nowrap; white-space: nowrap;
} }
.mbox-username { .mbox-username {
font-weight: 600;
} }
.mbox-kind0-name { .mbox-kind0-name {
color: var(--color-accent); color: var(--color-accent);
} }
.mbox-updated-contact,
.mbox-recommend-server {
padding-bottom: var(--gap-quarter);
}
.mbox-updated-contact .mbox-body, .mbox-updated-contact .mbox-body,
.mbox-recommend-server .mbox-body { .mbox-recommend-server .mbox-body {
display: block; display: block;
font-size: var(--font-small); font-size: var(--font-small);
padding-bottom: var(--gap-quarter);
padding-top: 0;
}
.mbox-updated-contact + .mbox-updated-contact,
.mbox-recommend-server + .mbox-updated-contact {
padding-top: 0;
} }
.mbox-updated-contact .mbox-header, .mbox-updated-contact .mbox-header,

@ -117,19 +117,35 @@ form .buttons,
.buttons img, .buttons img,
.buttons small, .buttons small,
.buttons span { .buttons span {
font-weight: normal;
vertical-align: middle; vertical-align: middle;
} }
button { button {
--bg-color: var(--bgcolor-accent); background-color: transparent;
--border-color: var(--bgcolor-accent); border: none;
background-color: var(--bg-color);
border: 0.2rem solid var(--border-color);
border-radius: .2rem;
cursor: pointer; cursor: pointer;
font-weight: bold;
outline-offset: 1px; outline-offset: 1px;
word-break: normal; word-break: normal;
} }
button:active {
--bg-color: rgb(13, 74, 139);
--border-color: rgb(13, 74, 139);
}
.primary,
.secondary {
border: 0.2rem solid var(--bgcolor-accent);
border-radius: .2rem;
padding: .9rem 2rem .7rem 2rem;
}
.primary {
background-color: var(--bgcolor-accent);
}
.secondary {
background-color: transparent;
}
button:focus { button:focus {
} }

@ -16,6 +16,7 @@
--focus-outline-width: 2px; --focus-outline-width: 2px;
--focus-outline: var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color); --focus-outline: var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color);
--font-small: 1.2rem; --font-small: 1.2rem;
--lineheight-small: 1.5;
--gap: 2.4rem; --gap: 2.4rem;
--gap-half: 1.2rem; --gap-half: 1.2rem;
--gap-quarter: .6rem; --gap-quarter: .6rem;
@ -41,12 +42,16 @@
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
html { html {
--color: rgb(93, 93, 93); --color: rgb(43, 43, 43);
--color-accent: rgb(130, 130, 130); --color-accent: rgb(118, 118, 118);
--color-accent-line: rgb(163, 163, 163);
--color-danger: #0e0e0e; --color-danger: #0e0e0e;
--color-visited: #7467c4;
--color-visited-line: #9083e3;
--color-inverse: #fff;
--bgcolor: #fff; --bgcolor: #fff;
--bgcolor-nav: gainsboro; --bgcolor-nav: gainsboro;
--bgcolor-accent: #7badfc; --bgcolor-accent: #5194ff;
--bgcolor-danger: rgb(225, 40, 40); --bgcolor-danger: rgb(225, 40, 40);
--bgcolor-danger-input: rgba(255 255 255 / .85); --bgcolor-danger-input: rgba(255 255 255 / .85);
--bgcolor-inactive: #bababa; --bgcolor-inactive: #bababa;
@ -56,15 +61,19 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
html { html {
--color: #e3e3e3; --color: #d9d9d9;
--color-accent: #828282; --color-accent: #828282;
--color-accent-line: #737373;
--color-danger: #e3e3e3; --color-danger: #e3e3e3;
--color-visited: #796ae3;
--color-visited-line: #5d4fce;
--color-inverse: #101010;
--bgcolor: #101010; --bgcolor: #101010;
--bgcolor-nav: rgb(31, 22, 51); --bgcolor-nav: rgb(31, 22, 51);
--bgcolor-accent: rgb(16, 93, 176); --bgcolor-accent: rgb(16, 77, 176);
--bgcolor-danger: rgb(169, 0, 0); --bgcolor-danger: rgb(169, 0, 0);
--bgcolor-danger-input: rgba(0 0 0 / .5); --bgcolor-danger-input: rgba(0 0 0 / .5);
--bgcolor-inactive: #202122; --bgcolor-inactive: #353638;
--bgcolor-textinput: #0e0e0e; --bgcolor-textinput: #0e0e0e;
} }
@ -92,7 +101,7 @@ body {
@media (orientation: portrait) { @media (orientation: portrait) {
body { body {
font-size: 1.4rem; font-size: 1.4rem;
line-height: 1.5; line-height: 1.428571428571429;
} }
} }
@ -133,16 +142,25 @@ img {
a { a {
color: var(--color-accent); color: var(--color-accent);
text-decoration: none; text-decoration-color: var(--color-accent-line);
text-decoration-line: underline;
text-decoration-style: solid;
text-decoration-thickness: 2px;
} }
a:focus { a .highlight {
color: var(--color);
}
a:focus,
button:focus {
border-radius: var(--focus-border-radius); border-radius: var(--focus-border-radius);
outline: var(--focus-outline); outline: var(--focus-outline);
outline-offset: 0; outline-offset: 0;
} }
a:visited { a:visited {
color: #8377ce; color: var(--color-visited);
text-decoration-color: var(--color-visited-line);
} }
nav a:visited { nav a:visited {
color: inherit; color: inherit;
@ -166,6 +184,7 @@ dl {
} }
dt { dt {
color: var(--color-accent);
grid-column-start: 1; grid-column-start: 1;
} }

@ -135,16 +135,29 @@ nav a {
--extra-space: calc(var(--profileimg-size) + var(--gap-half)); --extra-space: calc(var(--profileimg-size) + var(--gap-half));
padding: var(--gap-half); padding: var(--gap-half);
} }
.hero-title {
.hero h1 { align-items: baseline;
display: flex;
flex-wrap: wrap;
gap: var(--gap-half);
justify-content: end;
max-width: var(--content-width);
}
.hero-title h1 {
flex-grow: 1;
margin-bottom: 0;
padding-left: var(--extra-space); padding-left: var(--extra-space);
} }
.hero-title button {
line-height: 1;
}
.hero p { .hero p {
max-width: calc(var(--content-width) - var(--extra-space));
padding-left: var(--extra-space); padding-left: var(--extra-space);
} }
.hero small { .hero .hero-npub {
color: var(--color-accent); color: var(--color-accent);
font-size: 1.1rem; font-size: 1.1rem;
display: block; display: block;
@ -155,7 +168,7 @@ nav a {
white-space: nowrap; white-space: nowrap;
} }
@media (min-width: 54ch) { @media (min-width: 54ch) {
.hero small { .hero .hero-npub {
padding-left: var(--extra-space); padding-left: var(--extra-space);
text-align: left; text-align: left;
} }
@ -164,3 +177,7 @@ nav a {
.hero footer { .hero footer {
padding-left: var(--extra-space); padding-left: var(--extra-space);
} }
.hero footer a {
text-decoration: none;
}

@ -194,9 +194,9 @@ export const subProfile = (
sub({ sub({
cb: onEvent, cb: onEvent,
filter: { filter: {
authors: [pubkey], authors: [pubkey, config.pubkey],
kinds: [3], kinds: [3],
limit: 3, limit: 6,
}, },
}); });
}, 100); }, 100);

@ -26,11 +26,19 @@ export const renderViewTemplate = (options: ViewTemplateOptions) => {
case 'profile': case 'profile':
const pubkey = options.id; const pubkey = options.id;
const detail = elem('p'); const detail = elem('p');
const followStatus = elem('small');
const followBtn = elem('button', {
className: 'primary',
name: 'follow',
data: {'id': options.id}
}, 'follow');
const following = elem('span'); const following = elem('span');
const profileHeader = elem('header', {className: 'hero'}, [ const profileHeader = elem('header', {className: 'hero'}, [
elem('small', {className: 'hero-npub'}, nip19.npubEncode(pubkey)), elem('small', {className: 'hero-npub'}, nip19.npubEncode(pubkey)),
elem('div', {className: 'hero-title'}, [ elem('div', {className: 'hero-title'}, [
elem('h1', {}, pubkey), elem('h1', {}, pubkey),
followStatus,
followBtn,
]), ]),
detail, detail,
elem('footer', {}, following), elem('footer', {}, following),
@ -38,6 +46,8 @@ export const renderViewTemplate = (options: ViewTemplateOptions) => {
dom.header = profileHeader; dom.header = profileHeader;
dom.detail = detail; dom.detail = detail;
dom.following = following; dom.following = following;
dom.followStatus = followStatus;
dom.followBtn = followBtn;
content.append(profileHeader); content.append(profileHeader);
document.title = pubkey; document.title = pubkey;
break; break;

@ -100,8 +100,6 @@ export const renderUpdateContact = (
' ', ' ',
elem('span', {data: {contacts: evt.id}, title: time.toISOString()}, evt.content), elem('span', {data: {contacts: evt.id}, title: time.toISOString()}, evt.content),
]), ]),
elem('div', {className: 'mbox-content'}, [
]),
]), ]),
], { ], {
className: 'mbox-updated-contact', className: 'mbox-updated-contact',
@ -133,13 +131,13 @@ export const renderEventDetails = (evt: Event, relay: string) => {
const {img, name, userName} = getMetadata(evt.pubkey); const {img, name, userName} = getMetadata(evt.pubkey);
const npub = nip19.npubEncode(evt.pubkey); const npub = nip19.npubEncode(evt.pubkey);
let content = parseJSON(evt.content) let content = (![1, 7].includes(evt.kind) && evt.content !== '') ? parseJSON(evt.content) : (evt.content || '<empty>');
switch (typeof content) { switch (typeof content) {
case 'object': case 'object':
content = JSON.stringify(content, null, 2); content = JSON.stringify(content, null, 2);
break; break;
default: default:
content = `${evt.content}`; content = `${content}`;
} }
const body = elem('div', {className: 'mbox-body'}, [ const body = elem('div', {className: 'mbox-body'}, [
elem('header', {className: 'mbox-header'}, [ elem('header', {className: 'mbox-header'}, [

Loading…
Cancel
Save