diff options
| author | Adrien Hopkins <[email protected]> | 2025-12-23 19:55:53 -0500 |
|---|---|---|
| committer | Adrien Hopkins <[email protected]> | 2025-12-23 19:55:53 -0500 |
| commit | 80b8eb62fb9d0fdbc63e0f71b8516d3c2c9a2b45 (patch) | |
| tree | cc935e28cb4b5e6e282546bfc7b1e78f45eb8709 /src | |
| parent | dc5e28b08b701de05139476c001b25c500a505ce (diff) | |
| download | coinos-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.svelte | 201 |
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} ✓{/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> |
