forked from nostr/nostrweb
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: - updated following and unfollwing wording - added proper primary and secondary button styles.
parent
208ea6363a
commit
f92cfbbb31
208
src/contacts.ts
208
src/contacts.ts
|
@ -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,26 +88,8 @@ const findChanges = (current: Event, previous: Event) => {
|
||||||
return [addedContacts, removedContacts];
|
return [addedContacts, removedContacts];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateContactList = (evt: Event) => {
|
export const resetContactList = (pubkey: string) => {
|
||||||
const contactHistory = contactHistoryMap[evt.pubkey];
|
delete contactHistoryMap[pubkey];
|
||||||
if (contactHistory.length === 1) {
|
|
||||||
return [contactHistory[0].tags.filter(isPTag)];
|
|
||||||
}
|
|
||||||
const pos = contactHistory.findIndex(({id}) => id === evt.id);
|
|
||||||
if (evt.id === contactHistory.at(-1)?.id) { // oldest known contact-list update
|
|
||||||
// update existing contact entries
|
|
||||||
contactHistory
|
|
||||||
.slice(0, -1)
|
|
||||||
.forEach((entry, i) => {
|
|
||||||
const previous = contactHistory[i + 1];
|
|
||||||
const [added, removed] = findChanges(entry, previous);
|
|
||||||
const contactNote = getViewContent().querySelector(`[data-contacts="${entry.id}"]`);
|
|
||||||
const updated = getContactUpdateMessage(added, removed);
|
|
||||||
contactNote?.replaceChildren(...updated);
|
|
||||||
});
|
|
||||||
return [evt.tags.filter(isPTag)];
|
|
||||||
}
|
|
||||||
return findChanges(evt, contactHistory[pos + 1]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getContactUpdateMessage = (
|
export const getContactUpdateMessage = (
|
||||||
|
@ -76,7 +97,6 @@ export const getContactUpdateMessage = (
|
||||||
removedList: string[][],
|
removedList: string[][],
|
||||||
) => {
|
) => {
|
||||||
const content = [];
|
const content = [];
|
||||||
// console.log(addedContacts)
|
|
||||||
if (addedList.length && addedList[0]) {
|
if (addedList.length && addedList[0]) {
|
||||||
const pubkey = addedList[0][1];
|
const pubkey = addedList[0][1];
|
||||||
const {userName} = getMetadata(pubkey);
|
const {userName} = getMetadata(pubkey);
|
||||||
|
@ -90,7 +110,129 @@ export const getContactUpdateMessage = (
|
||||||
content.push(` (+ ${addedList.length - 1} others)`);
|
content.push(` (+ ${addedList.length - 1} others)`);
|
||||||
}
|
}
|
||||||
if (removedList?.length > 0) {
|
if (removedList?.length > 0) {
|
||||||
content.push(elem('small', {}, ` and unfollowed ${removedList.length}`));
|
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;
|
return content;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateContactList = (evt: Event) => {
|
||||||
|
const contactHistory = contactHistoryMap[evt.pubkey];
|
||||||
|
if (contactHistory.length === 1) {
|
||||||
|
return [contactHistory[0].tags.filter(isPTag)];
|
||||||
|
}
|
||||||
|
const pos = contactHistory.findIndex(({id}) => id === evt.id);
|
||||||
|
if (evt.id !== contactHistory.at(-1)?.id) { // not oldest known contact-list update
|
||||||
|
return findChanges(evt, contactHistory[pos + 1]);
|
||||||
|
}
|
||||||
|
// update existing contact entries
|
||||||
|
contactHistory
|
||||||
|
.slice(0, -1)
|
||||||
|
.forEach((entry, i) => {
|
||||||
|
const previous = contactHistory[i + 1];
|
||||||
|
const [added, removed] = findChanges(entry, previous);
|
||||||
|
const contactNote = getViewContent().querySelector(`[data-contacts="${entry.id}"]`);
|
||||||
|
const updated = getContactUpdateMessage(added, removed);
|
||||||
|
contactNote?.replaceChildren(...updated);
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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 [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateContactTags = (
|
||||||
|
followeeID: string,
|
||||||
|
currentContactList: Event | undefined,
|
||||||
|
) => {
|
||||||
|
if (!currentContactList?.tags) {
|
||||||
|
return [['p', followeeID], ['p', config.pubkey]];
|
||||||
|
}
|
||||||
|
if (currentContactList.tags.some(([tag, id]) => tag === 'p' && id === followeeID)) {
|
||||||
|
return currentContactList.tags
|
||||||
|
.filter(([tag, id]) => tag === 'p' && id !== followeeID);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
['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;
|
||||||
|
}
|
||||||
|
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…
Reference in New Issue