aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdrien Hopkins <[email protected]>2025-12-23 19:55:53 -0500
committerAdrien Hopkins <[email protected]>2025-12-23 19:55:53 -0500
commit80b8eb62fb9d0fdbc63e0f71b8516d3c2c9a2b45 (patch)
treecc935e28cb4b5e6e282546bfc7b1e78f45eb8709 /src
parentdc5e28b08b701de05139476c001b25c500a505ce (diff)
downloadcoinos-ui-80b8eb62fb9d0fdbc63e0f71b8516d3c2c9a2b45.tar.gz
coinos-ui-80b8eb62fb9d0fdbc63e0f71b8516d3c2c9a2b45.zip
Add individual DMs to DM page
Diffstat (limited to 'src')
-rw-r--r--src/routes/(app)/dm/+page.svelte201
1 files changed, 195 insertions, 6 deletions
diff --git a/src/routes/(app)/dm/+page.svelte b/src/routes/(app)/dm/+page.svelte
index 280b63d3..cffc611e 100644
--- a/src/routes/(app)/dm/+page.svelte
+++ b/src/routes/(app)/dm/+page.svelte
@@ -1,14 +1,22 @@
<script lang="ts">
+ import { tick } from "svelte";
+ import Icon from "$comp/Icon.svelte";
+
import { isValid } from "nostr-tools/nip05";
- import { t } from "$lib/translations";
- import { pTagKeys } from '$lib/nostr';
+ import * as toolsnip17 from 'nostr-tools/nip17';
+
import { getNostrUserInfo } from '$lib/nip01';
+ import { relaysSupporting } from '$lib/nip11';
import { getMessageRumours } from '$lib/nip17';
+ import * as libnip17 from '$lib/nip17';
+ import { sign, getPrivateKey, pTagKeys } from '$lib/nostr';
+ import { t } from "$lib/translations";
import { theme } from "$lib/store";
let { data } = $props();
const { user } = data;
+ /// chat selection
const usernameFromPubkey = async (pubkey: string): string => {
const response = await fetch(`/api/users/${pubkey}`);
if (response.ok) {
@@ -85,9 +93,127 @@
getMessageRumours(user).then(updateSendersRecipients);
let selectedChat = $state(null);
+
+ /// individual chat
+ let text = $state("");
+ let allRumours = $state([]);
+ let messageRumours = $state([]);
+ let dates = $state([]);
+ let relayWarningShown = $state(false);
+ let expiryEnabled = $state(false);
+ let expiryDays = $state(7);
+ let canSend = $state(false);
+ let canSendExpiring = $state(false);
+ let nostrUserInfo = $state({});
+
+ const selectChat = (c: object) => {
+ selectedChat = c;
+ userInfo(selectedChat.pubkey).then(info => nostrUserInfo = info);
+ libnip17.getPreferredRelays(selectedChat.pubkey).then((relays) => {
+ canSend = relays && relays.length > 0;
+ relaysSupporting(relays, [40])
+ .then((supporting) => canSendExpiring = supporting.length > 0);
+ });
+ updateEvents();
+ }
+
+ const appendMultimap = (map: Map, key: string, value: object) => {
+ if (map.has(key)) {
+ map.get(key).push(value);
+ } else {
+ map.set(key, [value]);
+ }
+ }
+
+ const dateMap = (events: object[]): Map<number, object> => {
+ let eventsMap = new Map();
+ for (const event of events) {
+ const created = Math.floor(event.created_at / 86400);
+ appendMultimap(eventsMap, created, event);
+ }
+ for (const [date, dateEvents] of eventsMap) {
+ dateEvents.sort((ev1, ev2) => ev1.created_at - ev2.created_at);
+ }
+ return eventsMap;
+ }
+
+ libnip17.getMessageRumours(user).then((rumours) => allRumours = rumours);
+ const updateEvents = async () => {
+ const rumours = allRumours.filter((r) =>
+ (r.pubkey === user.pubkey && pTagKeys(r).includes(selectedChat.pubkey)) ||
+ (r.pubkey === selectedChat.pubkey && pTagKeys(r).includes(user.pubkey)));
+ messageRumours = dateMap(rumours);
+
+ dates = Array.from(messageRumours.keys());
+ dates.sort((d1, d2) => d1 - d2);
+
+ await tick();
+ const messages = document.getElementById("messages");
+ if (messages) {
+ messages.scrollTop = messages.scrollHeight;
+ }
+ }
+
+ const TIME = new Intl.DateTimeFormat(
+ undefined, { hour: "numeric", hour12: false, minute: "numeric" });
+ const NEAR_DATE = new Intl.DateTimeFormat(
+ undefined, { month: "short", day: "numeric", weekday: "short" });
+ const FAR_DATE = new Intl.DateTimeFormat(
+ undefined, { year: "numeric", month: "short", day: "numeric", weekday: "short" });
+ const NEAR_DISTANCE = 182 * 24 * 60 * 60 * 1000; // around 6 months
+ const formatDate = (date: Date): string => {
+ if (date.getTime() >= Date.now() - NEAR_DISTANCE) {
+ return NEAR_DATE.format(date);
+ } else {
+ return FAR_DATE.format(date);
+ }
+ }
+
+ const expirationClose = (expiration: number) => {
+ return expiration <= (Date.now() / 1000 + 2 * 86400);
+ }
+
+ const sendMessage = async (message: string) => {
+ const expiry = expiryEnabled ? expiryDays : null;
+ await libnip17.send(message, user, selectedChat, expiry);
+ updateEvents();
+ }
+
+ libnip17.getPreferredRelays(user.pubkey).then((relays) => {
+ if (!relays || relays.length == 0) {
+ relayWarningShown = true;
+ return;
+ }
+ relayWarningShown = false;
+ });
</script>
<style>
+ .warning {
+ color: #ff7f00;
+ }
+
+ .secondary {
+ color: #7f7f7f;
+ }
+
+ .link {
+ text-decoration-line: underline;
+ }
+
+ .inline {
+ white-space: nowrap;
+ }
+
+ .short {
+ width: 6em;
+ height: 1.5em;
+ }
+
+ .tiny {
+ width: 2em;
+ }
+
.invalid {
text-decoration: line-through;
}
@@ -144,8 +270,41 @@
margin-left: 30px;
}
- .secondary {
- color: #7f7f7f;
+ .date-header {
+ text-align: center;
+ margin: 1.5em 0em;
+ }
+
+ .timestamp {
+ margin-left: 0.5em;
+ text-align: right;
+ }
+
+ .message {
+ padding: 0.25em 0.75em;
+ width: fit-content;
+ max-width: 75%;
+ border-radius: 10px;
+ }
+
+ .light-message {
+ background-color: #DDD;
+ }
+
+ .dark-message {
+ background-color: #222;
+ }
+
+ .by-user {
+ margin: 3px 0px 3px auto;
+ }
+
+ .by-other {
+ margin: 3px auto 3px 0px;
+ }
+
+ .vcenter {
+ margin: auto 0px;
}
</style>
@@ -153,7 +312,7 @@
<div class={"sidebar " + ($theme === "light" ? "light-sidebar" : "dark-sidebar")}>
<h2 class="text-xl chat-header">Chats</h2>
{#each chats as c}
- <button class={"chat-btn " + ($theme === "light" ? "light-chat-btn" : "dark-chat-btn") + (selectedChat && selectedChat.pubkey == c.pubkey ? ($theme === "light" ? " light-selected" : " dark-selected") : "")} on:click={() => selectedChat = c}>
+ <button class={"chat-btn " + ($theme === "light" ? "light-chat-btn" : "dark-chat-btn") + (selectedChat && selectedChat.pubkey == c.pubkey ? ($theme === "light" ? " light-selected" : " dark-selected") : "")} on:click={() => selectChat(c)}>
<span class="text-xl">{c.name}</span> <span class={(!c.nip05 || c.valid ? "" : "invalid ") + "secondary text-xs"}>{c.nip05 || c.pubkey.substring(0, 16)}</span>{#if c.valid} &#x2713;{/if}
</button>
{/each}
@@ -164,7 +323,37 @@
>{$t("dm.header")}</h1>
{#if selectedChat}
- <p>You are talking to <a href="/dm/{selectedChat.name}">{selectedChat.name}</a>.</p>
+ <h1><a class="text-3xl" href="/{selectedChat.username}">{selectedChat.username || nostrUserInfo.name}</a>
+ <span class={"secondary timestamp text-small" + ((!nostrUserInfo.nip05 || nostrUserInfo.valid) ? "" : " invalid")} title="Pubkey: {selectedChat.pubkey}">{nostrUserInfo.nip05 || selectedChat.pubkey.substring(0, 16)}</span>
+ </h1>
+
+ <div id="messages" style="overflow-y: scroll; max-height: 600px;">
+ {#each dates as day}
+ <p class="date-header secondary">{formatDate(new Date(day * 86400 * 1000))}</p>
+ <ul>
+ {#each messageRumours.get(day) as rumour}
+ {#if rumour.pubkey === user.pubkey && pTagKeys(rumour).includes(selectedChat.pubkey)}
+ <li class={(rumour.expiration && expirationClose(rumour.expiration) ? "secondary " : "") + ($theme === "light" ? "light-message " : "dark-message ") + "message by-user"}>{rumour.content} <span class="timestamp secondary text-xs">{TIME.format(new Date(rumour.created_at * 1000))}</span></li>
+ {:else if rumour.pubkey === selectedChat.pubkey && pTagKeys(rumour).includes(user.pubkey)}
+ <li class={(rumour.expiration && expirationClose(rumour.expiration) ? "secondary " : "") + ($theme === "light" ? "light-message " : "dark-message ") + "message by-other"}>{rumour.content} <span class="timestamp secondary text-xs">{TIME.format(new Date(rumour.created_at * 1000))}</span></li>
+ {/if}
+ {/each}
+ </ul>
+ {/each}
+ </div>
+
+ <textarea id="message-contents" bind:value={text}></textarea>
+ <input id="send-message" type="button" class="btn btn-accent" disabled={!canSend} value={canSend ? $t("dm.sendMessage") : $t("dm.relaysNotFound").replace("[R]", selectedChat.username)} on:click={async () => sendMessage(text)}>
+ {#if relayWarningShown}
+ <p class="warning"><em>{$t("dm.noRelaysSet")} <a class="link" href="/settings/nostr#dm-relays">{$t("dm.nostrSettingsLink")}</a></em></p>
+ {/if}
+
+ <input type="checkbox" id="expiryCheckbox" class="tiny" bind:checked={expiryEnabled} disabled={!canSendExpiring}>
+ <label for="expiryCheckbox">{$t("dm.expiry")}</label>: <input type="number" class="short vcenter" bind:value={expiryDays} disabled={!expiryEnabled} min="1" step="1" max="99999"> <label for="expiryCheckbox">{$t("dm.days")}</label>.
+ <p class="secondary">{$t("dm.fadedWarning")}</p>
+ {#if canSend && !canSendExpiring}
+ <p class="warning"><em>{$t("dm.cantSendExpiring").replace("[R]", selectedChat.username)}</em></p>
+ {/if}
{/if}
</div>
</div>