29 changed files with 697 additions and 189 deletions
@ -0,0 +1,89 @@ |
|||||||
|
1. When I open a ThreadDrawer, the "Replying to:..." blurb at the top should render as full event. And, if that replied-to event is also a reply, it's OP should also be rendered as a full event. And so on, up the hierarchy, until we get to an event that isn't a reference or reply to any other (no e-tag or q-tag or a-tag). I want to see the entire discussion tree, so that the event I clicked in the Feed view is displayed in complete context. |
||||||
|
|
||||||
|
2. Fix the Threads list loading so slowly. I should immediately be seeing what is in cache, and then you update the cache and add anything missing, in a second sweep. And make sure updating doesn't cause the page the jump around or create endless loops. |
||||||
|
|
||||||
|
3. Make sure that pinning and bookmarking (from the event "..." menu) actually create/update and publish the list events. |
||||||
|
|
||||||
|
4. Add a delete event menu item to the event "..." menu, that publishes a deletion request to all available relays. |
||||||
|
|
||||||
|
5. Always render a pretty OpenGraph card, for URLs, if they provide one. Unless the URL is in the middle of a list, paragraph, or otherwise part of some larger structure. |
||||||
|
|
||||||
|
6. Make sure that highlights work, according to NIP-84. refer to ../jumble for a working version. |
||||||
|
|
||||||
|
Some example events: |
||||||
|
|
||||||
|
{ |
||||||
|
"id": "93bea17f71ed9ea7f6832e3be7e617b3387e0700193cfcebaf3ffbc2e6f48a7f", |
||||||
|
"pubkey": "17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4", |
||||||
|
"created_at": 1769023343, |
||||||
|
"kind": 9802, |
||||||
|
"tags": [ |
||||||
|
[ |
||||||
|
"e", |
||||||
|
"6f854ade40cf3f24046249e650f55b33add3ee1526c00cc93cc7dfc80b8dc121", |
||||||
|
"source" |
||||||
|
] |
||||||
|
], |
||||||
|
"content": "not real wisdom, being a pretense of knowing the unknown", |
||||||
|
"sig": "150279e733e16fa85439916f9f5b8108898a35cbf18062638dfc94e7a38f4a2faae8ce918750ef327fc16b7e7ca8739b1e8aff3b9dd238363d08eec423abba83" |
||||||
|
} |
||||||
|
|
||||||
|
{ |
||||||
|
"id": "1cd2017dd33a2efddffb9814c1993cf62e6d8a8e2e90af40973b6d4d1ea509f0", |
||||||
|
"pubkey": "a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be", |
||||||
|
"created_at": 1769288219, |
||||||
|
"kind": 9802, |
||||||
|
"tags": [ |
||||||
|
[ |
||||||
|
"p", |
||||||
|
"a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be" |
||||||
|
], |
||||||
|
[ |
||||||
|
"a", |
||||||
|
"30023:a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be:comparing-community-specs" |
||||||
|
], |
||||||
|
[ |
||||||
|
"context", |
||||||
|
"A single publication can be targeted to up to 12 communities via one Targeted Publication event. The creator's intended audience is explicit and transparent — anyone can see which communities a piece of content was meant for. This can serve as an organic disovery route for related Communities + lowers the bar for bootstrapping new ones." |
||||||
|
], |
||||||
|
[ |
||||||
|
"alt", |
||||||
|
"This highlight was made by https://primal.net web client" |
||||||
|
] |
||||||
|
], |
||||||
|
"content": " The creator's intended audience is explicit and transparent — anyone can see which communities a piece of content was meant for. This can serve as an organic disovery route for related Communities + lowers the bar for bootstrapping new ones.", |
||||||
|
"sig": "b490a12fbc1ab0063c6ddb3ae091212a4fcf76fdf9581d5f0291f24a9443b45d9f11d70e8035ea9c61b95ad47952c46ceeffa6dbb0fa5351bc51aad2e3d54add" |
||||||
|
} |
||||||
|
|
||||||
|
In the first highlight event, there is simply the content field, which should be rendered as a quote, with a link to the original source (event or URL) below it. If the URL provides OpenGraph data, display it and add the hyperlink to it. For events: display a card with "A note from: <user badge rendered>" and then the "title", "image", and "summary" tags, if available. Make the card a clickable hyperlink to the event's /event page. |
||||||
|
|
||||||
|
7. Make #hashtags and t-tag topic buttons clickable. Clicking on one should launch a /topics/nameOfHashtag page, that reveals an event list of everything on the relays that includes that topic as a hashtag or a t-tag. |
||||||
|
|
||||||
|
8. Display a metadata card, at the top of the page, when rendering any replaceable event in /event . Render tags like "image", "description", "summary", "author", "title", etc. |
||||||
|
|
||||||
|
9. Add an Asciidoctor library to the packages. Use that for rendering kinds 30818 and 30041. All other kinds use Markdown. |
||||||
|
|
||||||
|
10. If a /event page is opened for a 30040 event, make sure that you analyze and then lazy-load the entire event-index hierarchy (see ../nips-silberengel/NKBIP-01.adoc) into the cache and then into the view. The index can use a-tags or e-tags, or a mix of both. Handle both types of tags and make sure to render the events in the original order. Retry any missing events, after the first loading pass, but don't loop infinitely. |
||||||
|
|
||||||
|
11. Display a metadata card, at the top, for the OP 30040. Only display metadata for nested events, if they differ from the OP. |
||||||
|
|
||||||
|
12. Please note that kind 30040 events typically contain 30041s, but they can actually contain any type of event the creator wants. Make sure to render each one according to its kind (markdown or asciidoc). |
||||||
|
|
||||||
|
13. Both the metadata card and the section events should have their "title" displayed (if none is provided, render the d-tag without hyphens and in Title Case) and have a "..." menu. The section events should have a new menu item: "Open in a new window" that opens the section as a /event in the browser. The index OP should have a new menu item: "Label this as a book" that creates a "general" 1985 label with "booklist". |
||||||
|
|
||||||
|
14. If an event opened in /event has been highlighted, render the highlight on the displayed text. (for 30040s, this needs to run after the publication has finished loading, or it won't find the text). Hovering over the highlight should display the user-badge of the person who created the highlight, with a button "View the highlight". Clicking the button should make the highlight open to the right, in a thread panel. |
||||||
|
|
||||||
|
15. There should be a /replaceable/d-tag-placed-here url path that searches for all replaceable events that have that d-tag and lists them in a list. Clicking one should display it in thread-panel on the right. |
||||||
|
|
||||||
|
16. Add a main menu item, to the right of Feeds: Write |
||||||
|
it should open to a page offering two choicees: find an existing event to edit, create a new event |
||||||
|
|
||||||
|
Clicking find should then demand they enter an event id (hex id, nevent, naddr, note) and click "Find". |
||||||
|
The event should be searched for in cache and then the relays, (return the newest version found) and the json rendered, below a hyperlink to the related /event page. |
||||||
|
They should be able to click an "Edit" button, and then the event is displayed as a form, where they can add/edit/delete tags and change the content. Don't render id, kind, pubkey, sig, created_at as those are to be generated when they click "Publish". Publish to cache and to the standard write-relays. Publishing should reveal the standard success/failure message for the relays. If none were successful, allow them to attempt to republish from cache. If successful, wait 5 seconds and then, open the event in the /event page. |
||||||
|
|
||||||
|
Clicking create should ask them to enter a kind they would like to write: 1, 11, 9802, 1222, 20, 21, 22, 30023, 30818, 30817, 30041, 30040 (metadata-only, no sections added, they can do that manually in the edit function, add that as a help-text), 1068 |
||||||
|
|
||||||
|
17. If the user is looking at their own profile page, display a menu item "Adjust profile events" that opens a left-side panel that allows them select one of the following events to create/update: 0, 3, 30315, 10133, 10002, 10432, 10001, 10003, 10895, 10015, 10030, 30030, 10000, 30008. Selecting one should open an appropriate form and preload it with any event found in cache or on the relays. Publish to cache and to the standard write-relays. Publishing should reveal the standard success/failure message for the relays. If none were successful, allow them to attempt to republish from cache. If successful, wait 5 seconds and then, open the event in the /event page. |
||||||
|
|
||||||
|
18. Make sure the /event page can handle metadata-only (no "content") events gracefully, displaying their tag-lists. |
||||||
@ -0,0 +1,352 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||||
|
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
open?: boolean; |
||||||
|
event?: NostrEvent | null; |
||||||
|
} |
||||||
|
|
||||||
|
let { open = $bindable(false), event = $bindable(null) }: Props = $props(); |
||||||
|
|
||||||
|
let relatedEvents = $state<NostrEvent[]>([]); |
||||||
|
let loading = $state(false); |
||||||
|
let error = $state<string | null>(null); |
||||||
|
let jsonText = $derived(JSON.stringify(relatedEvents, null, 2)); |
||||||
|
let copied = $state(false); |
||||||
|
|
||||||
|
// Get current user's pubkey |
||||||
|
const currentPubkey = $derived(sessionManager.getCurrentPubkey()); |
||||||
|
|
||||||
|
// Get replaceable event address (kind:pubkey:d-tag) if event is replaceable |
||||||
|
function getReplaceableAddress(event: NostrEvent): string | null { |
||||||
|
// Replaceable events have a 'd' tag |
||||||
|
const dTag = event.tags.find(t => t[0] === 'd' || t[0] === 'D'); |
||||||
|
if (dTag && dTag[1]) { |
||||||
|
return `${event.kind}:${event.pubkey}:${dTag[1]}`; |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
async function loadRelatedEvents() { |
||||||
|
if (!event || !currentPubkey) { |
||||||
|
error = 'No event or user not logged in'; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
loading = true; |
||||||
|
error = null; |
||||||
|
relatedEvents = []; |
||||||
|
|
||||||
|
try { |
||||||
|
const relays = relayManager.getProfileReadRelays(); |
||||||
|
const filters: any[] = []; |
||||||
|
|
||||||
|
// Query for events with #e tag (event reference) |
||||||
|
filters.push({ |
||||||
|
authors: [currentPubkey], |
||||||
|
'#e': [event.id], |
||||||
|
limit: 100 |
||||||
|
}); |
||||||
|
|
||||||
|
// Query for events with #q tag (quoted event) |
||||||
|
filters.push({ |
||||||
|
authors: [currentPubkey], |
||||||
|
'#q': [event.id], |
||||||
|
limit: 100 |
||||||
|
}); |
||||||
|
|
||||||
|
// Query for events with #a tag (replaceable event address) - only if event is replaceable |
||||||
|
const replaceableAddress = getReplaceableAddress(event); |
||||||
|
if (replaceableAddress) { |
||||||
|
filters.push({ |
||||||
|
authors: [currentPubkey], |
||||||
|
'#a': [replaceableAddress], |
||||||
|
limit: 100 |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch all related events |
||||||
|
const allEvents = await nostrClient.fetchEvents( |
||||||
|
filters, |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||||
|
); |
||||||
|
|
||||||
|
// Deduplicate by event ID |
||||||
|
const uniqueEvents = Array.from( |
||||||
|
new Map(allEvents.map(e => [e.id, e])).values() |
||||||
|
); |
||||||
|
|
||||||
|
// Sort by created_at descending |
||||||
|
relatedEvents = uniqueEvents.sort((a, b) => b.created_at - a.created_at); |
||||||
|
} catch (err) { |
||||||
|
error = err instanceof Error ? err.message : 'Failed to load related events'; |
||||||
|
console.error('Error loading related events:', err); |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function close() { |
||||||
|
open = false; |
||||||
|
} |
||||||
|
|
||||||
|
async function copyJson() { |
||||||
|
if (!jsonText) return; |
||||||
|
try { |
||||||
|
await navigator.clipboard.writeText(jsonText); |
||||||
|
copied = true; |
||||||
|
setTimeout(() => { |
||||||
|
copied = false; |
||||||
|
}, 2000); |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to copy JSON:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function selectAll() { |
||||||
|
const textarea = document.querySelector('.json-textarea') as HTMLTextAreaElement; |
||||||
|
if (textarea) { |
||||||
|
textarea.select(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Load related events when modal opens |
||||||
|
$effect(() => { |
||||||
|
if (open && event && currentPubkey) { |
||||||
|
loadRelatedEvents(); |
||||||
|
} else if (!open) { |
||||||
|
// Reset state when closing |
||||||
|
relatedEvents = []; |
||||||
|
error = null; |
||||||
|
} |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if open && event} |
||||||
|
<div |
||||||
|
class="modal-overlay" |
||||||
|
onclick={(e) => { |
||||||
|
// Only close if clicking directly on the overlay, not on modal content |
||||||
|
if (e.target === e.currentTarget) { |
||||||
|
close(); |
||||||
|
} |
||||||
|
}} |
||||||
|
onkeydown={(e) => { |
||||||
|
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') { |
||||||
|
e.preventDefault(); |
||||||
|
close(); |
||||||
|
} |
||||||
|
}} |
||||||
|
role="dialog" |
||||||
|
aria-modal="true" |
||||||
|
aria-label="Related events modal" |
||||||
|
tabindex="-1" |
||||||
|
> |
||||||
|
<div class="modal-content"> |
||||||
|
<div class="modal-header"> |
||||||
|
<h2>Your Related Events</h2> |
||||||
|
<button onclick={close} class="close-button">×</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="modal-body"> |
||||||
|
{#if loading} |
||||||
|
<div class="loading-state"> |
||||||
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading related events...</p> |
||||||
|
</div> |
||||||
|
{:else if error} |
||||||
|
<div class="error-state"> |
||||||
|
<p class="text-red-600 dark:text-red-400">Error: {error}</p> |
||||||
|
</div> |
||||||
|
{:else if relatedEvents.length === 0} |
||||||
|
<div class="empty-state"> |
||||||
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">No related events found.</p> |
||||||
|
<p class="text-fog-text-light dark:text-fog-dark-text-light text-sm mt-2"> |
||||||
|
This shows events you've signed that reference this event via 'e', 'q', or 'a' tags. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<div class="events-info"> |
||||||
|
<p class="text-fog-text-light dark:text-fog-dark-text-light text-sm mb-2"> |
||||||
|
Found {relatedEvents.length} related event{relatedEvents.length !== 1 ? 's' : ''}: |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
<textarea |
||||||
|
class="json-textarea" |
||||||
|
readonly |
||||||
|
value={jsonText} |
||||||
|
onclick={selectAll} |
||||||
|
></textarea> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="modal-footer"> |
||||||
|
{#if relatedEvents.length > 0} |
||||||
|
<button onclick={copyJson} class="copy-button"> |
||||||
|
{copied ? 'Copied!' : 'Copy'} |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
<button onclick={close}>Close</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<style> |
||||||
|
.modal-overlay { |
||||||
|
position: fixed; |
||||||
|
top: 0; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
bottom: 0; |
||||||
|
background: rgba(15, 23, 42, 0.4); |
||||||
|
backdrop-filter: blur(4px); |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
z-index: 1000; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-content { |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 8px; |
||||||
|
max-width: 900px; |
||||||
|
width: 90%; |
||||||
|
max-height: 80vh; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .modal-content { |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
padding: 1rem; |
||||||
|
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .modal-header { |
||||||
|
border-bottom-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-header h2 { |
||||||
|
margin: 0; |
||||||
|
font-size: 1.25rem; |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .modal-header h2 { |
||||||
|
color: var(--fog-dark-text, #f1f5f9); |
||||||
|
} |
||||||
|
|
||||||
|
.close-button { |
||||||
|
background: none; |
||||||
|
border: none; |
||||||
|
font-size: 1.5rem; |
||||||
|
cursor: pointer; |
||||||
|
padding: 0; |
||||||
|
width: 2rem; |
||||||
|
height: 2rem; |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .close-button { |
||||||
|
color: var(--fog-dark-text, #f1f5f9); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-body { |
||||||
|
padding: 1rem; |
||||||
|
flex: 1; |
||||||
|
overflow: auto; |
||||||
|
} |
||||||
|
|
||||||
|
.loading-state, |
||||||
|
.empty-state, |
||||||
|
.error-state { |
||||||
|
padding: 2rem; |
||||||
|
text-align: center; |
||||||
|
} |
||||||
|
|
||||||
|
.events-info { |
||||||
|
margin-bottom: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.json-textarea { |
||||||
|
width: 100%; |
||||||
|
min-height: 400px; |
||||||
|
font-family: 'Courier New', monospace; |
||||||
|
font-size: 0.875rem; |
||||||
|
padding: 0.75rem; |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 4px; |
||||||
|
background: var(--fog-bg, #ffffff); |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
resize: vertical; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .json-textarea { |
||||||
|
background: var(--fog-dark-bg, #0f172a); |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
color: var(--fog-dark-text, #f1f5f9); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-footer { |
||||||
|
padding: 1rem; |
||||||
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
display: flex; |
||||||
|
justify-content: flex-end; |
||||||
|
gap: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .modal-footer { |
||||||
|
border-top-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-footer button { |
||||||
|
padding: 0.5rem 1rem; |
||||||
|
border: none; |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
transition: background-color 0.2s; |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
.copy-button { |
||||||
|
background: var(--fog-accent, #64748b); |
||||||
|
color: white; |
||||||
|
} |
||||||
|
|
||||||
|
.copy-button:hover { |
||||||
|
background: var(--fog-accent-dark, #475569); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-footer button:not(.copy-button) { |
||||||
|
background: var(--fog-border, #e5e7eb); |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-footer button:not(.copy-button):hover { |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .modal-footer button:not(.copy-button) { |
||||||
|
background: var(--fog-dark-border, #374151); |
||||||
|
color: var(--fog-dark-text, #f1f5f9); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .modal-footer button:not(.copy-button):hover { |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
} |
||||||
|
</style> |
||||||
Loading…
Reference in new issue