New Feature: Direct Messages

This feature involved a lot of refactoring in order to get working
correctly. I wanted to continue using the timeline view for chats thus I
used alternative styling & structure for DM event kinds. This worked
create since the elements map does not care.

There is some queing that has to be done to decrypt message content thus
I allow viewing messages even if they haven't been decrypted yet. I
think this is good for transparency since you understand what is and is
not decrypted. I do think that the UX could improve, because even tho it
is fast, it's flashes on new messages.

I did not implement saving of latest messages. I will do this later, but
this feature is big enough to merge as is: an alpha state that works.

I further abstracted profile & name updating to work in a more global
manner. Additionally I rewrote code that had attribute scripts to use
addEventListener instead. This is needed to happen anyways for security
and made the codebase easier to manage.
This commit is contained in:
Thomas Mathews 2023-01-05 10:36:04 -08:00
parent 9badc35bf3
commit 077bf49fdb
17 changed files with 798 additions and 292 deletions

13
TODO Normal file
View file

@ -0,0 +1,13 @@
Here lie goals:
[ ] Fix UI/X issues on mobile (modal, reply, etc.)
[ ] Direct messaging
[ ] Store profiles of friends, friends of friends, & the user
[ ] Export/import contacts list
[ ] Render lighting invoices (BOLT11) Do this without a library
[ ] Render tagged users e.g. #[0]
[ ] Autocomplete for usernames in composition
[ ] Redesign embeds
[ ] Store latest 500 STANDARD_KINDS & their profiles kick out as needed
[ ] Display contacts list
[ ] Replace local storage usage

View file

@ -101,13 +101,15 @@ th, td {
#nav > div[data-active="home"] [role="home"] img.inactive,
#nav > div[data-active="explore"] [role="explore"] img.inactive,
#nav > div[data-active="notifications"] [role="notifications"] img.inactive,
#nav > div[data-active="settings"] [role="settings"] img.inactive {
#nav > div[data-active="settings"] [role="settings"] img.inactive,
#nav > div[data-active="messages"] [role="dm"] img.inactive {
display: none;
}
#nav > div[data-active="home"] [role="home"] img.active,
#nav > div[data-active="explore"] [role="explore"] img.active,
#nav > div[data-active="notifications"] [role="notifications"] img.active,
#nav > div[data-active="settings"] [role="settings"] img.active {
#nav > div[data-active="settings"] [role="settings"] img.active,
#nav > div[data-active="messages"] [role="dm"] img.active {
display: block;
}
#app-icon-logo > img {
@ -148,9 +150,12 @@ button.nav > img.icon {
padding: 15px;
}
#gnav.open button[role="home"] {
top: -300px;
top: -375px;
}
#gnav.open button[role="explore"] {
top: -300px;
}
#gnav.open button[role="dm"] {
top: -225px;
}
#gnav.open button[role="notifications"] {
@ -168,7 +173,7 @@ button.nav > img.icon {
top: 10px;
right: 13px;
border-radius: 13px;
background: #20ff00;
background: var(--clrNotification);
color: white;
font-weight: 800;
width: 5px;
@ -199,6 +204,13 @@ button.nav > img.icon {
font-weight: 800;
display: block;
}
#view > div > header > .pfp {
width: 32px;
height: 32px;
position: absolute;
top: 15px;
right: 15px;
}
/* Events & Content */
.events {
@ -422,17 +434,8 @@ details.cw summary {
/* Post & Reply */
#newpost {
padding: 0px 15px 15px;
display: flex;
flex-direction: row;
border-bottom: solid 1px var(--clrBorder);
}
#newpost > :first-child {
width: 64px;
}
#newpost > :last-child {
padding-left: 15px;
flex: 1;
}
textarea.post-input {
display: block;
width: 100%;
@ -454,6 +457,7 @@ input[type="text"].cw {
font-size: var(--fsReduced);
background: transparent;
color: var(--clrText);
padding: 5px;
}
/* Profile */
@ -528,6 +532,82 @@ code {
font-size: var(--fsEnlarged);
}
/* Messaging */
#dms-not-available {
background: #ffd559;
display: inline-block;
color: #090909;
padding: 15px;
}
.dm-group {
display: flex;
padding: 15px;
}
.dm-group .content {
position: relative;
padding: 0 15px;
flex: 1;
}
.dm-group .message {
word-break: break-word;
}
.dm-group .time {
font-size: var(--fsReduced);
color: var(--clrTextLight);
}
.dm-group .count {
position: absolute;
top: 0;
right: 0;
background: var(--clrBorder);
border-radius: 20px;
padding: 1px 8px;
font-size: var(--fsSmall);
}
.dm-group .count.active {
border: var(--clrNotification) solid 2px;
font-weight: bold;
background: transparent;
}
.event.dm {
padding-bottom: 0;
}
.event.dm:hover {
background: transparent;
}
.event.dm:last-child {
padding-bottom: 15px;
}
.event.dm .wrap{
border-radius: 20px;
background: var(--clrPanel);
padding: 10px;
}
.event.dm.mine .wrap {
background: #0058ff;
margin-left: auto;
}
.event.dm.mine .timestamp {
color: white;
display: block;
text-align: right;
}
.event.dm .body p {
display: inline-block;
margin: 0;
}
.event.dm .timestamp {
display: block;
margin: 0;
}
.event.dm .reactions {
margin: 0;
}
.event.dm .body {
word-break: break-word;
}
/* Inputs */
.block {

View file

@ -2,17 +2,18 @@
:root {
/* Colors */
--clrBg: #fff;
--clrPanel: #f9f9f9;
--clrBorder: #f2f2f2;
--clrBtn: #202020;
--clrText: #202020;
--clrTextLight: #868686;
--clrTextLighter: #abb4ca;
--clrHeart: #ff5050;
--clrWarn: #fbc560;
--clrLink: blue;
--clrLinkVisited: purple;
--clrBg: #fff;
--clrPanel: #f9f9f9;
--clrBorder: #f2f2f2;
--clrBtn: #202020;
--clrText: #202020;
--clrTextLight: #868686;
--clrTextLighter: #abb4ca;
--clrHeart: #ff5050;
--clrWarn: #fbc560;
--clrLink: blue;
--clrLinkVisited: purple;
--clrNotification: #20ff00;
/* TODO I don't like these and simply did it for dark mode. To rename. */
--clrEvMsg: #f4f4f4;

1
icon/messages-active.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M256 352c-16.53 0-33.06-5.422-47.16-16.41L0 173.2V400C0 426.5 21.49 448 48 448h416c26.51 0 48-21.49 48-48V173.2l-208.8 162.5C289.1 346.6 272.5 352 256 352zM16.29 145.3l212.2 165.1c16.19 12.6 38.87 12.6 55.06 0l212.2-165.1C505.1 137.3 512 125 512 112C512 85.49 490.5 64 464 64h-416C21.49 64 0 85.49 0 112C0 125 6.01 137.3 16.29 145.3z"/></svg>

After

Width:  |  Height:  |  Size: 581 B

1
icon/messages.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M448 64H64C28.65 64 0 92.65 0 128v256c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V128C512 92.65 483.3 64 448 64zM64 112h384c8.822 0 16 7.178 16 16v22.16l-166.8 138.1c-23.19 19.28-59.34 19.27-82.47 .0156L48 150.2V128C48 119.2 55.18 112 64 112zM448 400H64c-8.822 0-16-7.178-16-16V212.7l136.1 113.4C204.3 342.8 229.8 352 256 352s51.75-9.188 71.97-25.98L464 212.7V384C464 392.8 456.8 400 448 400z"/></svg>

After

Width:  |  Height:  |  Size: 646 B

View file

@ -20,7 +20,9 @@
<script defer src="js/ui/render.js?v=15"></script>
<script defer src="js/ui/state.js?v=1"></script>
<script defer src="js/ui/fmt.js?v=1"></script>
<script defer src="js/ui/profile.js?v=1"></script>
<script defer src="js/ui/settings.js?v=1"></script>
<script defer src="js/ui/dm.js?v=1"></script>
<script defer src="js/noble-secp256k1.js?v=1"></script>
<script defer src="js/bech32.js?v=1"></script>
<script defer src="js/nostr.js?v=7"></script>
@ -69,6 +71,9 @@
<button class="icon" role="explore" title="Explore" onclick="switch_view('explore')">
<img class="icon svg invert" src="icon/explore.svg"/>
</button>
<button class="icon" role="dm" title="Direct Messages" onclick="switch_view('dm')">
<img class="icon svg invert" src="icon/messages.svg"/>
</button>
<button class="icon" role="notifications" title="Notifications" onclick="switch_view('notifications')">
<img class="icon svg invert" src="icon/notifications.svg"/>
<div class="new-notifications hide"></div>
@ -90,10 +95,14 @@
<img class="icon svg active" src="icon/home-active.svg"/>
</button>
<button role="explore" class="nav icon"
title="Explore" onclick="switch_view('explore')">
<img class="icon svg inactive" src="icon/explore.svg"/>
title="Explore" onclick="switch_view('explore')"> <img class="icon svg inactive" src="icon/explore.svg"/>
<img class="icon svg active" src="icon/explore-active.svg"/>
</button>
<button role="dm" class="nav icon"
title="Direct Messages" onclick="switch_view('dm')">
<img class="icon svg inactive" src="icon/messages.svg"/>
<img class="icon svg active" src="icon/messages-active.svg"/>
</button>
<button role="notifications" class="nav icon"
title="Notifications" onclick="switch_view('notifications')">
<img class="icon svg inactive" src="icon/notifications.svg"/>
@ -112,44 +121,42 @@
<div>
<header>
<label>Home</label>
<img class="pfp" role="their-pfp" data-pubkey=""
src="icon/no-user.svg"/>
<img class="pfp" role="my-pfp" data-pubkey=""
src="icon/no-user.svg"/>
</header>
<div id="newpost">
<div class="my-userpic vertical-hide">
<!-- To be loaded dynamically. -->
</div>
<div>
<textarea placeholder="What's up?"
oninput="post_input_changed(this)"
class="post-input" id="post-input"></textarea>
<div class="post-tools">
<input id="content-warning-input" class="cw hide" type="text" placeholder="Reason"/>
<button title="Mark this message as sensitive."
onclick="toggle_cw(this)" class="cw icon">
<img class="icon svg small" src="icon/content-warning.svg"/>
</button>
<button onclick="send_post(this)" class="action"
role="send" id="post-button" disabled>Send</button>
</div>
<textarea placeholder="What's up?"
class="post-input" id="post-input"></textarea>
<div class="post-tools">
<input id="content-warning-input" class="cw hide"
type="text" placeholder="Reason"/>
<button class="cw icon" role="toggle-cw"
title="Mark this message as sensitive.">
<img class="icon svg small"
src="icon/content-warning.svg"/>
</button>
<button class="action" role="send"
id="post-button" disabled>Send</button>
</div>
</div>
<div role="profile-info" class="bottom-border hide">
<div id="profile-info" role="profile-info" class="bottom-border hide">
<div class="flex">
<img role="profile-image" class="pfp jumbo"
src="./icon/no-user.svg"
onerror="this.src='./icon/no-user.svg';"/>
<img role="profile-image" class="pfp jumbo"/>
<div class="profile-tools">
<!--
<button class="icon" title="Message User" role="message-user">
<img class="icon" src="icon/message-user.svg"/></button>
-->
<button class="icon" role="message-user"
title="Directly Message">
<img class="icon svg" src="icon/message-user.svg"/>
</button>
<button class="icon hide" role="edit-profile"
onclick="show_profile_editor()" title="Edit Profile">
title="Edit Profile">
<img class="icon svg" src="icon/edit-profile.svg"/></button>
<button class="icon" role="copy-pk"
data-pk="" onclick="click_copy_pk(this)" title="Copy Public Key">
data-pk="" title="Copy Public Key">
<img class="icon svg" src="icon/pubkey.svg"/></button>
<button class="action" role="follow-user" data-pk=""
onclick="click_toggle_follow_user(this)">Follow</button>
<button class="action" role="follow-user"
data-pk="">Follow</button>
</div>
</div>
<div>
@ -166,6 +173,13 @@
<button onclick="show_new()">
Show New (<span role="count">0</span>)</button>
</div>
<div id="dms-not-available" class="hide">
DMs could not be decrypted due to lack of nip04
integration. Please use an extension such as nos2x or
Alby.
</div>
<div id="dms" class="hide">
</div>
<div id="timeline" class="events"></div>
<div id="settings" class="hide">
<section>

View file

@ -15,6 +15,7 @@ const R_HEART = "❤️";
const STANDARD_KINDS = [
KIND_NOTE,
KIND_DM,
KIND_DELETE,
KIND_REACTION,
KIND_SHARE,
@ -126,26 +127,6 @@ async function sign_event(ev) {
return ev
}
async function send_post() {
const input_el = document.querySelector("#post-input")
const cw_el = document.querySelector("#content-warning-input")
const cw = cw_el.value
const content = input_el.value
const created_at = new_creation_time()
const kind = 1
const tags = cw ? [["content-warning", cw]] : []
const pubkey = await get_pubkey()
let post = { pubkey, tags, content, created_at, kind }
post.id = await nostrjs.calculate_id(post)
post = await sign_event(post)
broadcast_event(post)
input_el.value = ""
cw_el.value = ""
post_input_changed(input_el)
}
function new_reply_tags(ev) {
const tags = [["e", ev.id, "", "reply"]];
if (ev.refs.root) {

View file

@ -8,21 +8,21 @@ function event_refs_pubkey(ev, pubkey) {
return false
if (ev.pubkey === pubkey)
return false
for (const tag of ev.tags) {
if (tag.length >= 2 && tag[0] === "p" && tag[1] === pubkey)
return true
}
return false
return event_tags_pubkey(ev, pubkey)
}
function event_contains_pubkey(ev, pubkey) {
if (ev.pubkey == pubkey)
return true;
return event_tags_pubkey(ev, pubkey)
}
function event_tags_pubkey(ev, pubkey) {
for (const tag of ev.tags) {
if (tag.length >= 2 && tag[0] == "p" && tag[1] == pubkey)
return true;
}
return false;
return false
}
function event_get_pubkeys(ev) {
@ -140,4 +140,3 @@ function event_parse_reaction(ev) {
}
}

View file

@ -8,6 +8,8 @@ const IMG_NO_USER = "icon/no-user.svg";
const SID_META = "meta";
const SID_HISTORY = "history";
const SID_NOTIFICATIONS = "notifications";
const SID_DMS_OUT = "dms_out";
const SID_DMS_IN = "dms_in";
const SID_EXPLORE = "explore";
const SID_PROFILES = "profiles";
const SID_THREAD = "thread";
@ -70,8 +72,10 @@ async function webapp_init() {
// WARNING Order Matters!
init_message_textareas();
init_my_pfp(model);
init_postbox(model);
init_profile();
view_show_spinner(true);
redraw_my_pfp(model);
// Load data from storage
await model_load_settings(model);
@ -80,7 +84,6 @@ async function webapp_init() {
if (err) {
window.alert("Unable to load contacts.");
}
init_settings(model);
// Create our pool so that event processing functions can work
@ -135,6 +138,7 @@ function on_timer_save() {
}
function on_timer_tick() {
//return;
setTimeout(() => {
DAMUS.relay_que.forEach((que, relay) => {
model_fetch_next_profile(DAMUS, relay);
@ -154,13 +158,23 @@ function on_pool_open(relay) {
// Get all our info & history, well close this after we get it
fetch_profile(pubkey, model.pool, relay);
// Get our notifications. We will never close this.
// Get our notifications
relay.subscribe(SID_NOTIFICATIONS, [{
kinds: STANDARD_KINDS,
"#p": [pubkey],
limit: 5000,
}]);
// Get our dms. You have to do 2 separate queries: ours out and others in
relay.subscribe(SID_DMS_IN, [{
kinds: [KIND_DM],
"#p": [pubkey],
}]);
relay.subscribe(SID_DMS_OUT, [{
kinds: [KIND_DM],
authors: [pubkey],
}]);
// Subscribe to the world as it will serve our friends, notifications, and
// explore views
relay.subscribe(SID_EXPLORE, [{
@ -185,7 +199,6 @@ async function on_pool_eose(relay, sub_id) {
log_info(`EOSE(${relay.url}): ${sub_id}`);
const model = DAMUS;
const { pool } = model;
const index = sub_id.indexOf(":");
const sid = sub_id.slice(0, index >= 0 ? index : sub_id.length);
const identifier = sub_id.slice(index+1);
@ -207,6 +220,8 @@ async function on_pool_eose(relay, sub_id) {
}
case SID_NOTIFICATIONS:
case SID_PROFILES:
case SID_DMS_OUT:
case SID_DMS_IN:
pool.unsubscribe(sub_id, relay);
break;
}

View file

@ -27,6 +27,9 @@ function model_process_event(model, relay, ev) {
case KIND_REACTION:
fn = model_process_event_reaction;
break;
case KIND_DM:
fn = model_process_event_dm;
break;
}
if (fn)
fn(model, ev, !!relay);
@ -45,9 +48,6 @@ function model_process_event(model, relay, ev) {
model_que_profile(model, relay, pubkey);
}
});
// TODO fetch unknown referenced events & pubkeys from this event
// TODO notify user of new events aimed at them!
}
function model_get_relay_que(model, relay) {
@ -123,9 +123,9 @@ function model_process_event_metadata(model, ev, update_view) {
// If it's my pubkey let's redraw my pfp that is not located in the view
// This has to happen regardless of update_view because of the it's not
// related to events
if (profile.pubkey == model.pubkey) {
/*if (profile.pubkey == model.pubkey) {
redraw_my_pfp(model);
}
}*/
}
function model_has_profile(model, pk) {
@ -153,6 +153,76 @@ function model_process_event_following(model, ev, update_view) {
// load_our_relays(model.pubkey, model.pool, ev)
}
function event_is_dm(ev, mykey) {
if (ev.kind != KIND_DM)
return false;
if (ev.pubkey != mykey && event_tags_pubkey(ev, mykey))
return true;
return ev.pubkey == mykey;
}
/* model_process_event_dm updates the internal dms hash map based on dms
* targeted at the user.
*/
function model_process_event_dm(model, ev, update_view) {
if (!event_is_dm(ev, model.pubkey))
return;
// We have to identify who the target DM is for since we are also in the
// chat. We simply use the first non-us key we find as the target. I am not
// sure that multi-sig chats are possible at this time in the spec. If no
// target, it's a bad DM.
let target;
const keys = event_get_pubkeys(ev);
for (let key of keys) {
target = key;
if (key == model.pubkey)
continue;
break;
}
if (!target)
return;
let dm = model_get_dm(model, target);
dm.needs_decryption = true;
dm.needs_redraw = true;
// It may be faster to not use binary search due to the newest always being
// at the front - but I could be totally wrong. Potentially it COULD be
// slower during history if history is imported ASCENDINGLY. But anything
// after this will always be faster and is insurance (race conditions).
let i = 0;
for (; i < dm.events.length; i++) {
const b = dm.events[i];
if (ev.created_at > b.created_at)
break;
}
dm.events.splice(i, 0, ev);
// Check if DM is new
const b = model.all_events[dm.last_seen];
if (!b || b.created_at < ev.created_at) {
// TODO update notification UI
dm.new_count++;
}
}
function model_get_dm(model, target) {
if (!model.dms.has(target)) {
// TODO think about using "pubkey:subject" so we have threads
model.dms.set(target, {
pubkey: target,
// events is an ordered list (new to old) of events referenced from
// all_events. It should not be a copy to reduce memory.
events: [],
// Last read event by the client/user
last_seen: "",
new_count: 0,
// Notifies the renderer that this dm is out of date
needs_redraw: false,
needs_decryption: false,
});
}
return model.dms.get(target);
}
/* model_process_event_reaction updates the reactions dictionary
*/
function model_process_event_reaction(model, ev, update_view) {
@ -388,6 +458,7 @@ function new_model() {
friends: new Set(),
friend_of_friends: new Set(),
},
dms: new Map(), // pubkey => event list
invalidated: [], // event ids which should be added/removed
elements: {}, // map of evid > rendered element
relay_que: new Map(),

81
js/ui/dm.js Normal file
View file

@ -0,0 +1,81 @@
function view_dm_update(model) {
const el = find_node("#dms");
const order = [];
model.dms.forEach((dm, pubkey, m) => {
if (!dm.events.length)
return;
const i = arr_bsearch_insert(order, dm, dm_cmp);
order.splice(i, 0, dm);
if (!dm.needs_redraw)
return;
let gel = find_node(`[data-pubkey='${pubkey}']`, el);
if (!gel) {
gel = new_el_dmgroup(model, dm);
gel.addEventListener("click", onclick_dm);
el.appendChild(gel);
}
update_el_dmgroup(model, dm, gel);
dm.needs_redraw = false;
});
// I'm not sure what is faster, doing a frag update all at once OR just
// updating individual positions. If they all update it's slower, but the
// chances of them all updating is is small and only garuenteed when it
// draws the first time.
//const frag = new DocumentFragment();
for (let i = 0; i < order.length; i++) {
let dm = order[i];
let xel = el.children[i];
if (dm.pubkey == xel.dataset.pubkey)
continue;
let gel = find_node(`[data-pubkey='${order[i].pubkey}']`, el);
el.insertBefore(gel, xel);
//frag.appendChild(gel);
}
//el.appendChild(frag);
}
function dm_cmp(a, b) {
const x = a.events[0].created_at;
const y = b.events[0].created_at;
if (x > y)
return -1;
if (x < y)
return 1;
return 0;
}
function update_el_dmgroup(model, dm, el) {
const ev = dm.events[0];
const profile = model_get_profile(model, dm.pubkey);
const message = ev.decrypted || ev.content || "No Message.";
const time = fmt_datetime(new Date(ev.created_at * 1000));
const cel = find_node(".count", el)
cel.innerText = dm.new_count;
cel.classList.toggle("active", dm.new_count > 0);
find_node(".time", el).innerText = time;
find_node(".message", el).innerText = message;
find_node(".username", el).innerText = fmt_name(profile);
}
function new_el_dmgroup(model, dm) {
const profile = model_get_profile(model, dm.pubkey);
return html2el(`<div class="dm-group bottom-border clickable" data-pubkey="${dm.pubkey}">
<div>${render_profile_img(profile, true)}</div>
<div class="content">
<div class="count"></div>
<label class="username" data-pubkey="${dm.pubkey}"></label>
<p class="message"></p>
<label class="time"></label>
</div>
</div>`);
}
function onclick_dm(ev) {
const el = find_parent(ev.target, "[data-pubkey]");
if (!el || !el.dataset.pubkey) {
log_error("did not find dm pubkey");
return;
}
view_timeline_apply_mode(DAMUS, VM_DM_THREAD, {pubkey: el.dataset.pubkey});
}

View file

@ -25,12 +25,13 @@ function linkify(text="", show_media=false) {
}
function format_content(ev, show_media) {
if (ev.kind === 7) {
if (ev.kind === KIND_REACTION) {
if (ev.content === "" || ev.content === "+")
return "❤️"
return html`${ev.content.trim()}`;
}
const content = ev.content.trim();
const content = (ev.kind == KIND_DM ? ev.decrypted || ev.content : ev.content)
.trim();
const body = fmt_body(content, show_media);
let cw = get_content_warning(ev.tags)
if (cw !== null) {
@ -72,8 +73,9 @@ function fmt_body(content, show_media) {
}, "")
}
/* format_profile_name takes in a profile and tries it's best to return a string
* that is best suited for the profile.
/* DEPRECATED: use fmt_name
* format_profile_name takes in a profile and tries it's best to
* return a string that is best suited for the profile.
*/
function fmt_profile_name(profile={}, fallback="Anonymous") {
const name = profile.display_name || profile.user || profile.name ||
@ -81,7 +83,23 @@ function fmt_profile_name(profile={}, fallback="Anonymous") {
return html`${name}`;
}
function fmt_name(profile={data:{}}) {
const { data } = profile;
const name = data.display_name || data.user || data.name ||
fmt_pubkey(profile.pubkey);
return html`${name}`;
}
function fmt_pubkey(pk) {
if (!pk)
return "Unknown";
return pk.slice(-8)
}
function fmt_datetime(d) {
return d.getFullYear() +
"/" + ("0" + (d.getMonth()+1)).slice(-2) +
"/" + ("0" + d.getDate()).slice(-2) +
" " + ("0" + d.getHours()).slice(-2) +
":" + ("0" + d.getMinutes()).slice(-2);
}

115
js/ui/profile.js Normal file
View file

@ -0,0 +1,115 @@
const PROFILE_FIELDS = [
'name',
'picture',
'nip05',
'about',
];
function open_profile(pubkey) {
view_timeline_apply_mode(DAMUS, VM_USER, { pubkey });
view_update_profile(DAMUS, pubkey);
}
function init_profile() {
const el = find_node("#profile-info");
const el_pfp = find_node("[role='profile-image']", el);
el_pfp.addEventListener("error", onerror_pfp);
el_pfp.src = IMG_NO_USER;
find_node("[role='message-user']", el)
.addEventListener("click", onclick_message_user);
find_node("[role='edit-profile']", el)
.addEventListener("click", onclick_edit_profile);
find_node("[role='copy-pk']", el)
.addEventListener("click", onclick_copy_pubkey);
find_node("[role='follow-user']", el)
.addEventListener("click", onclick_follow_user);
}
function onclick_message_user(ev) {
const pubkey = ev.target.dataset.pubkey;
view_timeline_apply_mode(DAMUS, VM_DM_THREAD, { pubkey });
}
/* onclick_copy_pubkey writes the element's dataset.pk field to the users OS'
* clipboard. No we don't use fallback APIs, use a recent browser.
*/
function onclick_copy_pubkey(ev) {
const el = ev.target;
navigator.clipboard.writeText(el.dataset.pk);
// TODO show toast
}
/* onclick_follow_user sends the event to the relay to subscribe the active user
* to the target public key of the element's dataset.pk field.
*/
function onclick_follow_user(ev) {
const el = ev.target;
const { contacts } = DAMUS;
const pubkey = el.dataset.pk;
const is_friend = contacts.friends.has(pubkey);
if (is_friend) {
contacts.friends.delete(pubkey);
} else {
contacts.friends.add(pubkey);
}
el.innerText = is_friend ? "Follow" : "Unfollow";
contacts_save(contacts);
if (window.confirm("Contacts are saved locally. Do you want to sync you contacts with all relays?")) {
update_contacts();
}
}
function view_update_profile(model, pubkey) {
const profile = model_get_profile(model, pubkey);
const el = find_node("#profile-info");
const name = fmt_name(profile);
find_node("[role='profile-image']", el).src = get_profile_pic(profile);
find_nodes("[role='profile-name']", el).forEach(el => {
el.innerText = name;
});
const el_nip5 = find_node("[role='profile-nip5']", el)
el_nip5.innerText = profile.data.nip05;
el_nip5.classList.toggle("hide", !profile.data.nip05);
const el_desc = find_node("[role='profile-desc']", el)
el_desc.innerHTML = newlines_to_br(linkify(profile.data.about));
el_desc.classList.toggle("hide", !profile.data.about);
find_node("button[role='copy-pk']", el).dataset.pk = pubkey;
find_node("button[role='edit-profile']", el)
.classList.toggle("hide", pubkey != model.pubkey);
const btn_follow = find_node("button[role='follow-user']", el);
btn_follow.dataset.pk = pubkey;
btn_follow.innerText = contact_is_friend(model.contacts, pubkey) ? "Unfollow" : "Follow";
btn_follow.classList.toggle("hide", pubkey == model.pubkey);
const btn_message = find_node("button[role='message-user']", el);
btn_message.dataset.pubkey = pubkey;
}
function onclick_edit_profile() {
const p = model_get_profile(DAMUS, DAMUS.pubkey);
const el = find_node("#profile-editor");
el.classList.remove("closed");
for (const key of PROFILE_FIELDS) {
find_node(`[name='${key}']`, el).value = p.data[key];
}
}
function click_update_profile() {
const el = find_node("#profile-editor");
const btn = find_node("button.action", el);
const p = Object.assign({}, model_get_profile(DAMUS, DAMUS.pubkey).data, {
name: find_node("input[name='name']", el).value,
picture: find_node("input[name='picture']", el).value,
nip05: find_node("input[name='nip05']", el).value,
about: find_node("textarea[name='about']", el).value,
});
update_profile(p);
close_modal(el);
// TODO show toast that say's "broadcasted!"
}

View file

@ -54,18 +54,21 @@ function render_shared_by(ev, opts) {
}
function render_event(model, ev, opts={}) {
if (ev.kind == KIND_SHARE) {
return render_share(model, ev, opts);
switch(ev.kind) {
case KIND_SHARE:
return render_share(model, ev, opts);
case KIND_DM:
return render_dm(model, ev, opts);
}
const profile = model_get_profile(model, ev.pubkey);
const delta = fmt_since_str(new Date().getTime(), ev.created_at*1000)
const border_bottom = opts.is_composing ? "" : "bottom-border";
let thread_btn = "";
return html`<div id="ev${ev.id}" class="event ${border_bottom}">
let classes = "event"
if (!opts.is_composing)
classes += " bottom-border";
return html`<div id="ev${ev.id}" class="${classes}">
<div class="userpic">
$${render_pfp(ev.pubkey, profile.data)}
</div>
$${render_profile_img(profile)}</div>
<div class="event-content">
<div class="info">
$${render_name(ev.pubkey, profile.data)}
@ -78,12 +81,43 @@ function render_event(model, ev, opts={}) {
</div>`
}
function render_dm(model, ev, opts) {
let classes = "event"
if (ev.kind == KIND_DM) {
classes += " dm";
if (ev.pubkey == model.pubkey)
classes += " mine";
}
const profile = model_get_profile(model, ev.pubkey);
const delta = fmt_since_str(new Date().getTime(), ev.created_at*1000)
let show_media = event_shows_media(model, ev, model.embeds);
return html`<div id="ev${ev.id}" class="${classes}">
<div class="wrap">
<div class="body">
<p>$${format_content(ev, show_media)}</p>
</div>
<div class="timestamp" data-timestamp="${ev.created_at}">${delta}</div>
</div>
</div>`
}
function event_shows_media(model, ev, mode) {
if (mode == "friends")
return model.contacts.friends.has(ev.pubkey);
return true;
}
function rerender_dm(model, ev, el) {
let show_media = event_shows_media(model, ev, model.embeds);
find_node(".body > p", el).innerHTML = format_content(ev, show_media);
}
function render_event_nointeract(model, ev, opts={}) {
const profile = model_get_profile(model, ev.pubkey);
const delta = fmt_since_str(new Date().getTime(), ev.created_at*1000)
return html`<div class="event border-bottom">
<div class="userpic">
$${render_pfp(ev.pubkey, profile.data)}
$${render_profile_img(profile)}
</div>
<div class="event-content">
<div class="info">
@ -114,7 +148,7 @@ function render_event_body(model, ev, opts) {
${format_content(ev, show_media)}
</p>`;
str += render_reactions(model, ev);
str += opts.nobar ? "" :
str += opts.nobar || ev.kind == KIND_DM ? "" :
render_action_bar(model, ev, {can_delete, shared});
return str;
}
@ -237,7 +271,20 @@ function render_name(pk, profile, prefix="") {
// Beware of whitespace.
return html`<span>${prefix}<span class="username clickable" data-pubkey="${pk}"
onclick="open_profile('${pk}')"
>${fmt_profile_name(profile, fmt_pubkey(pk))}</span></span>`
> ${fmt_profile_name(profile, fmt_pubkey(pk))}</span></span>`
}
function render_profile_img(profile, noclick=false) {
const name = fmt_name(profile);
let str = html`class="pfp clickable" onclick="open_profile('${profile.pubkey}')"`;
if (noclick)
str = "class='pfp'";
return html`<img
$${str}
data-pubkey="${profile.pubkey}"
title="${name}"
onerror="this.onerror=null;this.src='${IMG_NO_USER}';"
src="${get_profile_pic(profile)}"/>`
}
function render_pfp(pk, profile, opts={}) {

View file

@ -1,6 +1,8 @@
const VM_FRIENDS = "friends"; // mine + only events that are from my contacts
const VM_EXPLORE = "explore"; // all events
const VM_NOTIFICATIONS = "notifications"; // reactions & replys
const VM_DM = "dm"; // all events of KIND_DM aimmed at user
const VM_DM_THREAD = "dmthread"; // all events from a user of KIND_DM
const VM_THREAD = "thread"; // all events in response to target event
const VM_USER = "user"; // all events by pubkey
const VM_SETTINGS = "settings";
@ -27,6 +29,7 @@ function view_timeline_apply_mode(model, mode, opts={}, push_state=true) {
// Don't do anything if we are already here
if (el.dataset.mode == mode) {
switch (mode) {
case VM_DM_THREAD:
case VM_USER:
if (el.dataset.pubkey == opts.pubkey)
return;
@ -39,7 +42,7 @@ function view_timeline_apply_mode(model, mode, opts={}, push_state=true) {
return;
}
}
// Push a new state to the browser history stack
if (push_state)
history.pushState({mode, opts}, '');
@ -49,7 +52,7 @@ function view_timeline_apply_mode(model, mode, opts={}, push_state=true) {
view_show_spinner(true);
fetch_thread_history(thread_id, model.pool);
}
if (pubkey && pubkey != model.pubkey) {
if (mode == VM_USER && pubkey && pubkey != model.pubkey) {
view_show_spinner(true);
fetch_profile(pubkey, model.pool);
}
@ -57,11 +60,26 @@ function view_timeline_apply_mode(model, mode, opts={}, push_state=true) {
reset_notifications(model);
}
const names = {};
names[VM_FRIENDS] = "Home";
names[VM_EXPLORE] = "Explore";
names[VM_NOTIFICATIONS] = "Notifications";
names[VM_DM] = "Messages (Alpha)";
names[VM_DM_THREAD] = "Messages";
names[VM_USER] = "Profile";
names[VM_THREAD] = "Thread";
names[VM_SETTINGS] = "Settings";
let name = names[mode];
let profile;
el.dataset.mode = mode;
switch(mode) {
case VM_THREAD:
el.dataset.threadId = thread_id;
case VM_USER:
case VM_DM_THREAD:
profile = model_get_profile(model, pubkey);
name = fmt_name(profile);
el.dataset.pubkey = pubkey;
break;
default:
@ -70,29 +88,42 @@ function view_timeline_apply_mode(model, mode, opts={}, push_state=true) {
break;
}
const names = {};
names[VM_FRIENDS] = "Home";
names[VM_EXPLORE] = "Explore";
names[VM_NOTIFICATIONS] = "Notifications";
names[VM_USER] = "Profile";
names[VM_THREAD] = "Thread";
names[VM_SETTINGS] = "Settings";
// Do some visual updates
find_node("#view header > label").innerText = names[mode];
find_node("#view header > label").innerText = name;
find_node("#nav > div[data-active]").dataset.active = names[mode].toLowerCase();
find_node("#view [role='profile-info']").classList.toggle("hide", mode != VM_USER);
find_node("#newpost").classList.toggle("hide", mode != VM_FRIENDS);
find_node("#newpost").classList.toggle("hide", mode != VM_FRIENDS && mode != VM_DM_THREAD);
const timeline_el = find_node("#timeline");
timeline_el.classList.toggle("reverse", mode == VM_THREAD);
timeline_el.classList.toggle("hide", mode == VM_SETTINGS);
timeline_el.classList.toggle("hide", mode == VM_SETTINGS || mode == VM_DM);
find_node("#settings").classList.toggle("hide", mode != VM_SETTINGS);
find_node("#dms").classList.toggle("hide", mode != VM_DM);
find_node("#dms-not-available")
.classList.toggle("hide", mode == VM_DM_THREAD || mode == VM_DM ?
dms_available() : true);
// Show/hide different profile image in header
el_their_pfp = find_node("#view header > img.pfp[role='their-pfp']");
el_their_pfp.classList.toggle("hide", mode != VM_DM_THREAD);
find_node("#view header > img.pfp[role='my-pfp']")
.classList.toggle("hide", mode == VM_DM_THREAD);
view_timeline_refresh(model, mode, opts);
if (mode == VM_SETTINGS) {
view_show_spinner(false);
view_set_show_count(0, true, true);
switch (mode) {
case VM_DM_THREAD:
decrypt_dms(model);
el_their_pfp.src = get_profile_pic(profile);
el_their_pfp.dataset.pubkey = pubkey;
break;
case VM_DM:
decrypt_dms(model);
view_dm_update(model);
break;
case VM_SETTINGS:
view_show_spinner(false);
view_set_show_count(0, true, true);
break;
}
return mode;
@ -112,7 +143,9 @@ function view_timeline_refresh(model, mode, opts={}) {
el.innerHTML = "";
// Build DOM fragment and render it
let count = 0;
const evs = model_events_arr(model)
const evs = mode == VM_DM_THREAD ?
model_get_dm(model, opts.pubkey).events.concat().reverse() :
model_events_arr(model);
const fragment = new DocumentFragment();
for (let i = evs.length - 1; i >= 0 && count < 1000; i--) {
const ev = evs[i];
@ -149,6 +182,7 @@ function view_timeline_update(model) {
let count = 0;
let ncount = 0;
let decrypted = false;
const latest_ev = el.firstChild ?
model.all_events[el.firstChild.id.slice(2)] : undefined;
const left_overs = [];
@ -165,9 +199,16 @@ function view_timeline_update(model) {
continue;
}
// Skip non-renderables and already created
// Skip non-renderables
var ev = model.all_events[evid];
if (!event_is_renderable(ev) || model.elements[evid]) {
if (!event_is_renderable(ev)) {
continue;
}
// Re-render content of a decrypted dm
if (ev.kind == KIND_DM && model.elements[evid]) {
rerender_dm(model, ev, model.elements[evid]);
decrypted = true;
continue;
}
@ -205,6 +246,10 @@ function view_timeline_update(model) {
model.notifications.count += ncount;
update_notifications(model);
}
// Update the dms list view
if (decrypted) {
view_dm_update(model);
}
}
function view_set_show_count(count, add=false, hide=false) {
@ -251,6 +296,7 @@ function view_timeline_show_new(model) {
}
view_set_show_count(-count, true);
view_timeline_update_timestamps();
if (mode == VM_DM_THREAD) decrypt_dms(model);
}
function view_render_event(model, ev, force=false) {
@ -269,29 +315,42 @@ function view_render_event(model, ev, force=false) {
}
function view_timeline_update_profiles(model, pubkey) {
let xs, html;
const el = view_get_timeline_el();
const p = model_get_profile(model, pubkey);
const name = fmt_profile_name(p.data, fmt_pubkey(pubkey));
const pic = get_picture(pubkey, p.data)
const name = fmt_name(p);
const pic = get_profile_pic(p);
for (const evid in model.elements) {
if (!event_contains_pubkey(model.all_events[evid], pubkey))
continue;
const el = model.elements[evid];
let xs;
xs = find_nodes(`.username[data-pubkey='${pubkey}']`, el)
xs.forEach((el)=> {
el.innerText = name;
});
xs = find_nodes(`img[data-pubkey='${pubkey}']`, el)
xs.forEach((el)=> {
el.src = pic;
});
update_el_profile(model.elements[evid], pubkey, name, pic);
}
// Update the profile view if it's active
if (el.dataset.mode == VM_USER && el.dataset.pubkey == pubkey) {
view_update_profile(model, pubkey);
if (el.dataset.pubkey == pubkey) {
const mode = el.dataset.mode;
switch (mode) {
case VM_USER:
view_update_profile(model, pubkey);
case VM_DM_THREAD:
find_node("#view header > label").innerText = name;
}
}
// Update dm's section since they are not in our view, dm's themselves will
// be caught by the process above.
update_el_profile(find_node("#dms"), pubkey, name, pic);
update_el_profile(find_node("#view header"), pubkey, name, pic);
update_el_profile(find_node("#newpost"), pubkey, name, pic);
}
function update_el_profile(el, pubkey, name, pic) {
if (!el)
return;
find_nodes(`.username[data-pubkey='${pubkey}']`, el).forEach((el)=> {
el.innerText = name;
});
find_nodes(`img[data-pubkey='${pubkey}']`, el).forEach((el)=> {
el.src = pic;
el.title = name;
});
}
function view_timeline_update_timestamps() {
@ -336,6 +395,9 @@ function view_timeline_update_reaction(model, ev) {
}
function view_mode_contains_event(model, ev, mode, opts={}) {
if (mode != VM_DM_THREAD && ev.kind == KIND_DM) {
return false;
}
switch(mode) {
case VM_EXPLORE:
return ev.kind != KIND_REACTION;
@ -349,13 +411,19 @@ function view_mode_contains_event(model, ev, mode, opts={}) {
ev.refs.root == opts.thread_id ||
ev.refs.reply == opts.thread_id));
case VM_NOTIFICATIONS:
return event_refs_pubkey(ev, model.pubkey);
return event_tags_pubkey(ev, model.pubkey);
case VM_DM_THREAD:
if (ev.kind != KIND_DM) return false;
return (ev.pubkey == opts.pubkey &&
event_tags_pubkey(ev, model.pubkey)) ||
(ev.pubkey == model.pubkey &&
event_tags_pubkey(ev, opts.pubkey));
}
return false;
}
function event_is_renderable(ev={}) {
return ev.kind == KIND_NOTE || ev.kind == KIND_SHARE;
return ev.kind == KIND_NOTE || ev.kind == KIND_SHARE || ev.kind == KIND_DM;
}
function get_default_max_depth(damus, view) {
@ -364,18 +432,17 @@ function get_default_max_depth(damus, view) {
function get_thread_max_depth(damus, view, root_id) {
if (!view.depths[root_id])
return get_default_max_depth(damus, view)
return view.depths[root_id]
return get_default_max_depth(damus, view);
return view.depths[root_id];
}
function get_thread_root_id(damus, id) {
const ev = damus.all_events[id]
const ev = damus.all_events[id];
if (!ev) {
log_debug("expand_thread: no event found?", id)
return null
return null;
}
return ev.refs && ev.refs.root
return ev.refs && ev.refs.root;
}
function switch_view(mode, opts) {
@ -389,3 +456,89 @@ function reset_notifications(model) {
update_notifications(model);
}
function html2el(html) {
const div = document.createElement("div");
div.innerHTML = html;
return div.firstChild;
}
function init_my_pfp(model) {
find_nodes(`img[role='my-pfp']`).forEach((el)=> {
el.dataset.pubkey = model.pubkey;
el.addEventListener("error", onerror_pfp);
el.addEventListener("click", onclick_pfp);
el.classList.add("clickable");
});
find_nodes(`img[role='their-pfp']`).forEach((el)=> {
el.addEventListener("error", onerror_pfp);
el.addEventListener("click", onclick_pfp);
el.classList.add("clickable");
});
}
function init_postbox(model) {
const el = find_node("#newpost");
find_node("textarea", el).addEventListener("input", oninput_post);
find_node("button[role='send']").addEventListener("click", onclick_send);
find_node("button[role='toggle-cw']")
.addEventListener("click", onclick_toggle_cw);
}
async function onclick_send(ev) {
const el = view_get_timeline_el();
const mode = el.dataset.mode;
const pubkey = await get_pubkey();
const el_input = document.querySelector("#post-input");
const el_cw = document.querySelector("#content-warning-input");
let post = {
pubkey,
kind: KIND_NOTE,
created_at: new_creation_time(),
content: el_input.value,
tags: el_cw.value ? [["content-warning", el_cw.value]] : [],
}
// Handle DM type post
if (mode == VM_DM_THREAD) {
if (!dms_available()) {
window.alert("DMing not available.");
return;
}
post.kind = KIND_DM;
const target = el.dataset.pubkey;
post.tags.splice(0, 0, ["p", target]);
post.content = await window.nostr.nip04.encrypt(target, post.content);
}
// Send it
post.id = await nostrjs.calculate_id(post)
post = await sign_event(post)
broadcast_event(post);
// Reset UI
el_input.value = "";
el_cw.value = "";
trigger_postbox_assess(el_input);
}
/* oninput_post checks the content of the textarea and updates the size
* of it's element. Additionally I will toggle the enabled state of the sending
* button.
*/
function oninput_post(ev) {
trigger_postbox_assess(ev.target);
}
function trigger_postbox_assess(el) {
el.style.height = `0px`;
el.style.height = `${el.scrollHeight}px`;
let btn = el.parentElement.querySelector("button[role=send]");
if (btn) btn.disabled = el.value === "";
}
/* toggle_cw changes the active stage of the Content Warning for a post. It is
* relative to the element that is pressed.
*/
function onclick_toggle_cw(ev) {
const el = ev.target;
el.classList.toggle("active");
const isOn = el.classList.contains("active");
const input = el.parentElement.querySelector("input.cw");
input.classList.toggle("hide", !isOn);
}

View file

@ -3,16 +3,6 @@
* this file grows specific UI area code should be migrated to its own file.
*/
/* toggle_cw changes the active stage of the Content Warning for a post. It is
* relative to the element that is pressed.
*/
function toggle_cw(el) {
el.classList.toggle("active");
const isOn = el.classList.contains("active");
const input = el.parentElement.querySelector("input.cw");
input.classList.toggle("hide", !isOn);
}
/* toggle_gnav hides or shows the global navigation's additional buttons based
* on its opened state.
*/
@ -24,17 +14,6 @@ function close_gnav() {
find_node("#gnav").classList.remove("open");
}
/* post_input_changed checks the content of the textarea and updates the size
* of it's element. Additionally I will toggle the enabled state of the sending
* button.
*/
function post_input_changed(el) {
el.style.height = `0px`;
el.style.height = `${el.scrollHeight}px`;
let btn = el.parentElement.querySelector("button[role=send]");
if (btn) btn.disabled = el.value === "";
}
/* init_message_textareas finds all message textareas and updates their initial
* height based on their content (0). This is so there is no jaring affect when
* the page loads.
@ -42,7 +21,7 @@ function post_input_changed(el) {
function init_message_textareas() {
const els = document.querySelectorAll(".post-input");
for (const el of els) {
post_input_changed(el);
trigger_postbox_assess(el);
}
}
@ -56,39 +35,6 @@ function update_notification_markers(active) {
}
}
/* show_profile updates the current view to the profile display and updates the
* information to the relevant profile based on the public key passed.
* TODO handle async waiting for relay not yet connected
*/
function show_profile(pk) {
switch_view("profile");
const model = DAMUS;
const profile = model_get_profile(model, pk).data;
const el = find_node("#profile-view");
// TODO show loading indicator then render
find_node("[role='profile-image']", el).src = get_picture(pk, profile);
find_nodes("[role='profile-name']", el).forEach(el => {
el.innerText = fmt_profile_name(profile, fmt_pubkey(pk));
});
const el_nip5 = find_node("[role='profile-nip5']", el)
el_nip5.innerText = profile.nip05;
el_nip5.classList.toggle("hide", !profile.nip05);
const el_desc = find_node("[role='profile-desc']", el)
el_desc.innerHTML = newlines_to_br(profile.about);
el_desc.classList.toggle("hide", !profile.about);
find_node("button[role='copy-pk']", el).dataset.pk = pk;
const btn_follow = find_node("button[role='follow-user']", el)
btn_follow.dataset.pk = pk;
// TODO check follow status
btn_follow.innerText = 1 == 1 ? "Follow" : "Unfollow";
btn_follow.classList.toggle("hide", pk == model.pubkey);
}
/* newlines_to_br takes a string and converts all newlines to HTML 'br' tags.
*/
function newlines_to_br(str="") {
@ -97,33 +43,6 @@ function newlines_to_br(str="") {
}, "");
}
/* click_copy_pk writes the element's dataset.pk field to the users OS'
* clipboard. No we don't use fallback APIs, use a recent browser.
*/
function click_copy_pk(el) {
// TODO show toast
navigator.clipboard.writeText(el.dataset.pk);
}
/* click_follow_user sends the event to the relay to subscribe the active user
* to the target public key of the element's dataset.pk field.
*/
function click_toggle_follow_user(el) {
const { contacts } = DAMUS;
const pubkey = el.dataset.pk;
const is_friend = contacts.friends.has(pubkey);
if (is_friend) {
contacts.friends.delete(pubkey);
} else {
contacts.friends.add(pubkey);
}
el.innerText = is_friend ? "Follow" : "Unfollow";
contacts_save(contacts);
if (window.confirm("Contacts are saved locally. Do you want to sync you contacts with all relays?")) {
update_contacts();
}
}
function show_new() {
view_timeline_show_new(DAMUS);
}
@ -207,12 +126,12 @@ function reply_all(evid) {
reply(evid, true);
}
function redraw_my_pfp(model) {
/*function redraw_my_pfp(model) {
const p = model_get_profile(model, model.pubkey).data;
const html = render_pfp(model.pubkey, p || {});
const el = document.querySelector(".my-userpic");
el.innerHTML = html;
}
}*/
function update_favicon(path) {
let link = document.querySelector("link[rel~='icon']");
@ -279,11 +198,6 @@ function open_thread(thread_id) {
view_timeline_apply_mode(DAMUS, VM_THREAD, { thread_id });
}
function open_profile(pubkey) {
view_timeline_apply_mode(DAMUS, VM_USER, { pubkey });
view_update_profile(DAMUS, pubkey);
}
function open_faqs() {
find_node("#faqs").classList.remove("closed");
}
@ -298,61 +212,6 @@ function close_modal(el) {
}
}
function view_update_profile(model, pubkey) {
const profile = model_get_profile(model, pubkey);
const el = find_node("[role='profile-info']");
const name = fmt_profile_name(profile.data, fmt_pubkey(pubkey));
find_node("#view header > label").innerText = name;
find_node("[role='profile-image']", el).src = get_picture(pubkey, profile.data);
find_nodes("[role='profile-name']", el).forEach(el => {
el.innerText = name;
});
const el_nip5 = find_node("[role='profile-nip5']", el)
el_nip5.innerText = profile.data.nip05;
el_nip5.classList.toggle("hide", !profile.data.nip05);
const el_desc = find_node("[role='profile-desc']", el)
el_desc.innerHTML = newlines_to_br(linkify(profile.data.about));
el_desc.classList.toggle("hide", !profile.data.about);
find_node("button[role='copy-pk']", el).dataset.pk = pubkey;
find_node("button[role='edit-profile']", el)
.classList.toggle("hide", pubkey != model.pubkey);
const btn_follow = find_node("button[role='follow-user']", el)
btn_follow.dataset.pk = pubkey;
// TODO check follow status
btn_follow.innerText = contact_is_friend(model.contacts, pubkey) ? "Unfollow" : "Follow";
btn_follow.classList.toggle("hide", pubkey == model.pubkey);
}
const PROFILE_FIELDS = ['name', 'picture', 'nip05', 'about'];
function show_profile_editor() {
const p = model_get_profile(DAMUS, DAMUS.pubkey);
const el = find_node("#profile-editor");
el.classList.remove("closed");
for (const key of PROFILE_FIELDS) {
find_node(`[name='${key}']`, el).value = p.data[key];
}
}
function click_update_profile() {
const el = find_node("#profile-editor");
const btn = find_node("button.action", el);
const p = Object.assign({}, model_get_profile(DAMUS, DAMUS.pubkey).data, {
name: find_node("input[name='name']", el).value,
picture: find_node("input[name='picture']", el).value,
nip05: find_node("input[name='nip05']", el).value,
about: find_node("textarea[name='about']", el).value,
});
update_profile(p);
close_modal(el);
// TODO show toast that say's "broadcasted!"
}
function on_click_show_event_details(evid) {
const model = DAMUS;
const ev = model.all_events[evid];
@ -362,3 +221,11 @@ function on_click_show_event_details(evid) {
el.classList.remove("closed");
find_node("code", el).innerText = JSON.stringify(ev, null, "\t");
}
function onclick_pfp(ev) {
open_profile(ev.target.dataset.pubkey);
}
function onerror_pfp(ev) {
ev.target.src = IMG_NO_USER;
}

View file

@ -56,7 +56,8 @@ function shuffle(arr) {
}
/* arr_bsearch_insert finds the point in the array that an item should be
* inserted at based on the 'cmp' function used.
* inserted at based on the 'cmp' function used. cmp function is same as sort
* cmp function.
*/
function arr_bsearch_insert(arr, item, cmp) {
let start = 0;
@ -113,6 +114,13 @@ function find_nodes(selector, parentEl) {
return (parentEl || document).querySelectorAll(selector);
}
function find_parent(el, selector) {
while (el && !el.matches(selector)) {
el = el.parentNode;
}
return el;
}
/* uuidv4 returns a new uuid v4
*/
function uuidv4() {
@ -225,7 +233,15 @@ function get_qs(loc=location.href) {
return new URL(loc).searchParams
}
function get_picture(pk, profile) {
function get_profile_pic(profile) {
if (profile && profile.data && profile.data.picture)
return html`${profile.data.picture}`;
return IMG_NO_USER;
}
/* DEPRECATED use get_profile_picture
*/
function get_picture(profile) {
if (!profile || !profile.picture)
return IMG_NO_USER;
return html`${profile.picture}`;
@ -247,3 +263,36 @@ function process_json_content(ev) {
ev.json_content = safe_parse_json(ev.content, "event json_content");
}
function dms_available() {
return window.nostr && window.nostr.nip04;
}
async function decrypt_dms(model) {
if (!dms_available()) {
log_warn("could not decrypt messages because nostr.nip04 is not available");
return false;
}
for (const item of model.dms) {
let dm = item[1];
if (!dm.needs_decryption)
continue;
for (const ev of dm.events) {
if (ev.decrypted != undefined)
continue;
let str;
try {
str = await window.nostr.nip04.decrypt(dm.pubkey, ev.content);
} catch (err) {
log_error("unable to decrypt dm", ev.id, err);
}
if (!str)
continue;
ev.decrypted = str;
model.invalidated.push(ev.id);
}
dm.needs_decryption = false;
dm.needs_redraw = true;
}
return true;
}