26 changed files with 229 additions and 647 deletions
@ -1,234 +0,0 @@ |
|||||||
<script lang="ts"> |
|
||||||
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; |
|
||||||
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; |
|
||||||
import ReplyContext from '../../components/content/ReplyContext.svelte'; |
|
||||||
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte'; |
|
||||||
import ZapButton from '../zaps/ZapButton.svelte'; |
|
||||||
import ZapReceipt from '../zaps/ZapReceipt.svelte'; |
|
||||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
||||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
|
||||||
import { onMount } from 'svelte'; |
|
||||||
import type { NostrEvent } from '../../types/nostr.js'; |
|
||||||
|
|
||||||
interface Props { |
|
||||||
post: NostrEvent; |
|
||||||
parentEvent?: NostrEvent; // Optional parent event if already loaded |
|
||||||
onReply?: (post: NostrEvent) => void; |
|
||||||
} |
|
||||||
|
|
||||||
let { post, parentEvent: providedParentEvent, onReply }: Props = $props(); |
|
||||||
|
|
||||||
let loadedParentEvent = $state<NostrEvent | null>(null); |
|
||||||
let loadingParent = $state(false); |
|
||||||
let expanded = $state(false); |
|
||||||
let contentElement: HTMLElement | null = $state(null); |
|
||||||
let needsExpansion = $state(false); |
|
||||||
|
|
||||||
// Derive the effective parent event: prefer provided, fall back to loaded |
|
||||||
let parentEvent = $derived(providedParentEvent || loadedParentEvent); |
|
||||||
|
|
||||||
// Sync provided parent event changes and load if needed |
|
||||||
$effect(() => { |
|
||||||
if (providedParentEvent) { |
|
||||||
// If provided parent event is available, use it |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
// If no provided parent and this is a reply, try to load it |
|
||||||
if (!loadedParentEvent && isReply()) { |
|
||||||
loadParentEvent(); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
onMount(async () => { |
|
||||||
// If parent not provided and this is a reply, try to load it |
|
||||||
if (!providedParentEvent && !loadedParentEvent && isReply()) { |
|
||||||
await loadParentEvent(); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
function getRelativeTime(): string { |
|
||||||
const now = Math.floor(Date.now() / 1000); |
|
||||||
const diff = now - post.created_at; |
|
||||||
const hours = Math.floor(diff / 3600); |
|
||||||
const days = Math.floor(diff / 86400); |
|
||||||
const minutes = Math.floor(diff / 60); |
|
||||||
|
|
||||||
if (days > 0) return `${days}d ago`; |
|
||||||
if (hours > 0) return `${hours}h ago`; |
|
||||||
if (minutes > 0) return `${minutes}m ago`; |
|
||||||
return 'just now'; |
|
||||||
} |
|
||||||
|
|
||||||
function getClientName(): string | null { |
|
||||||
const clientTag = post.tags.find((t) => t[0] === 'client'); |
|
||||||
return clientTag?.[1] || null; |
|
||||||
} |
|
||||||
|
|
||||||
function isReply(): boolean { |
|
||||||
// Check if this is a reply (has e tag pointing to another event) |
|
||||||
return post.tags.some((t) => t[0] === 'e' && t[1] !== post.id); |
|
||||||
} |
|
||||||
|
|
||||||
function getReplyEventId(): string | null { |
|
||||||
// Find the 'e' tag that's not the root (the direct parent) |
|
||||||
const replyTag = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply'); |
|
||||||
if (replyTag) return replyTag[1]; |
|
||||||
|
|
||||||
// Fallback: find any 'e' tag that's not the root |
|
||||||
const rootId = getRootEventId(); |
|
||||||
const eTag = post.tags.find((t) => t[0] === 'e' && t[1] !== rootId && t[1] !== post.id); |
|
||||||
return eTag?.[1] || null; |
|
||||||
} |
|
||||||
|
|
||||||
function getRootEventId(): string | null { |
|
||||||
const rootTag = post.tags.find((t) => t[0] === 'root'); |
|
||||||
return rootTag?.[1] || null; |
|
||||||
} |
|
||||||
|
|
||||||
async function loadParentEvent() { |
|
||||||
const replyEventId = getReplyEventId(); |
|
||||||
if (!replyEventId || loadingParent) return; |
|
||||||
|
|
||||||
loadingParent = true; |
|
||||||
try { |
|
||||||
const relays = relayManager.getFeedReadRelays(); |
|
||||||
const events = await nostrClient.fetchEvents( |
|
||||||
[{ kinds: [1], ids: [replyEventId] }], |
|
||||||
relays, |
|
||||||
{ useCache: true, cacheResults: true } |
|
||||||
); |
|
||||||
|
|
||||||
if (events.length > 0) { |
|
||||||
loadedParentEvent = events[0]; |
|
||||||
} |
|
||||||
} catch (error) { |
|
||||||
console.error('Error loading parent event:', error); |
|
||||||
} finally { |
|
||||||
loadingParent = false; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
$effect(() => { |
|
||||||
if (contentElement) { |
|
||||||
checkContentHeight(); |
|
||||||
// Use ResizeObserver to detect when content changes (e.g., images loading) |
|
||||||
const observer = new ResizeObserver(() => { |
|
||||||
checkContentHeight(); |
|
||||||
}); |
|
||||||
observer.observe(contentElement); |
|
||||||
return () => observer.disconnect(); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
function checkContentHeight() { |
|
||||||
if (contentElement) { |
|
||||||
// Use requestAnimationFrame to ensure DOM is fully updated |
|
||||||
requestAnimationFrame(() => { |
|
||||||
if (contentElement) { |
|
||||||
needsExpansion = contentElement.scrollHeight > 500; |
|
||||||
} |
|
||||||
}); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
function toggleExpanded() { |
|
||||||
expanded = !expanded; |
|
||||||
} |
|
||||||
|
|
||||||
</script> |
|
||||||
|
|
||||||
<article class="Feed-post" data-post-id={post.id} id="event-{post.id}" data-event-id={post.id}> |
|
||||||
<div class="card-content" class:expanded bind:this={contentElement}> |
|
||||||
{#if isReply() && parentEvent} |
|
||||||
<ReplyContext {parentEvent} targetId="event-{parentEvent.id}" /> |
|
||||||
{/if} |
|
||||||
|
|
||||||
<div class="post-header flex items-center gap-2 mb-2"> |
|
||||||
<ProfileBadge pubkey={post.pubkey} /> |
|
||||||
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span> |
|
||||||
{#if getClientName()} |
|
||||||
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span> |
|
||||||
{/if} |
|
||||||
{#if isReply()} |
|
||||||
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">↳ Reply</span> |
|
||||||
{/if} |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="post-content mb-2"> |
|
||||||
<MarkdownRenderer content={post.content} /> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="post-actions flex items-center gap-4"> |
|
||||||
<FeedReactionButtons event={post} /> |
|
||||||
<ZapButton event={post} /> |
|
||||||
<ZapReceipt eventId={post.id} pubkey={post.pubkey} /> |
|
||||||
{#if onReply} |
|
||||||
<button |
|
||||||
onclick={() => onReply(post)} |
|
||||||
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline" |
|
||||||
> |
|
||||||
Reply |
|
||||||
</button> |
|
||||||
{/if} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
{#if needsExpansion} |
|
||||||
<button |
|
||||||
onclick={toggleExpanded} |
|
||||||
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2" |
|
||||||
> |
|
||||||
{expanded ? 'Show less' : 'Show more'} |
|
||||||
</button> |
|
||||||
{/if} |
|
||||||
</article> |
|
||||||
|
|
||||||
<style> |
|
||||||
.Feed-post { |
|
||||||
padding: 1rem; |
|
||||||
margin-bottom: 1rem; |
|
||||||
background: var(--fog-post, #ffffff); |
|
||||||
border: 1px solid var(--fog-border, #e5e7eb); |
|
||||||
border-radius: 0.25rem; |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .Feed-post { |
|
||||||
background: var(--fog-dark-post, #1f2937); |
|
||||||
border-color: var(--fog-dark-border, #374151); |
|
||||||
} |
|
||||||
|
|
||||||
.post-content { |
|
||||||
line-height: 1.6; |
|
||||||
} |
|
||||||
|
|
||||||
.post-actions { |
|
||||||
padding-top: 0.5rem; |
|
||||||
border-top: 1px solid var(--fog-border, #e5e7eb); |
|
||||||
margin-top: 0.5rem; |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .post-actions { |
|
||||||
border-top-color: var(--fog-dark-border, #374151); |
|
||||||
} |
|
||||||
|
|
||||||
.card-content { |
|
||||||
max-height: 500px; |
|
||||||
overflow: hidden; |
|
||||||
transition: max-height 0.3s ease; |
|
||||||
} |
|
||||||
|
|
||||||
.card-content.expanded { |
|
||||||
max-height: none; |
|
||||||
} |
|
||||||
|
|
||||||
.show-more-button { |
|
||||||
width: 100%; |
|
||||||
text-align: center; |
|
||||||
padding: 0.5rem; |
|
||||||
background: transparent; |
|
||||||
border: none; |
|
||||||
cursor: pointer; |
|
||||||
} |
|
||||||
|
|
||||||
</style> |
|
||||||
@ -1,46 +0,0 @@ |
|||||||
/** |
|
||||||
* NIP-46 Bunker signer (remote signer) |
|
||||||
*/ |
|
||||||
|
|
||||||
import type { NostrEvent } from '../../types/nostr.js'; |
|
||||||
|
|
||||||
export interface BunkerConnection { |
|
||||||
bunkerUrl: string; |
|
||||||
pubkey: string; |
|
||||||
token?: string; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Connect to bunker signer |
|
||||||
*/ |
|
||||||
export async function connectBunker(bunkerUri: string): Promise<BunkerConnection> { |
|
||||||
// Parse bunker:// URI
|
|
||||||
// Format: bunker://<pubkey>@<relay>?token=<token>
|
|
||||||
const match = bunkerUri.match(/^bunker:\/\/([^@]+)@([^?]+)(?:\?token=([^&]+))?$/); |
|
||||||
if (!match) { |
|
||||||
throw new Error('Invalid bunker URI'); |
|
||||||
} |
|
||||||
|
|
||||||
const [, pubkey, relay, token] = match; |
|
||||||
|
|
||||||
return { |
|
||||||
bunkerUrl: relay, |
|
||||||
pubkey, |
|
||||||
token |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Sign event with bunker |
|
||||||
*/ |
|
||||||
export async function signEventWithBunker( |
|
||||||
event: Omit<NostrEvent, 'sig' | 'id'>, |
|
||||||
connection: BunkerConnection |
|
||||||
): Promise<NostrEvent> { |
|
||||||
// Placeholder - would:
|
|
||||||
// 1. Send NIP-46 request to bunker
|
|
||||||
// 2. Wait for response
|
|
||||||
// 3. Return signed event
|
|
||||||
|
|
||||||
throw new Error('Bunker signing not yet implemented'); |
|
||||||
} |
|
||||||
@ -1,94 +0,0 @@ |
|||||||
/** |
|
||||||
* Relay list fetcher (kind 10002 and 10432) |
|
||||||
*/ |
|
||||||
|
|
||||||
import { nostrClient } from '../nostr/nostr-client.js'; |
|
||||||
import { config } from '../nostr/config.js'; |
|
||||||
import type { NostrEvent } from '../../types/nostr.js'; |
|
||||||
|
|
||||||
export interface RelayInfo { |
|
||||||
url: string; |
|
||||||
read: boolean; |
|
||||||
write: boolean; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Parse relay list from event |
|
||||||
*/ |
|
||||||
export function parseRelayList(event: NostrEvent): RelayInfo[] { |
|
||||||
const relays: RelayInfo[] = []; |
|
||||||
|
|
||||||
for (const tag of event.tags) { |
|
||||||
if (tag[0] === 'r' && tag[1]) { |
|
||||||
const url = tag[1]; |
|
||||||
const markers = tag.slice(2); |
|
||||||
|
|
||||||
// If no markers, relay is both read and write
|
|
||||||
if (markers.length === 0) { |
|
||||||
relays.push({ url, read: true, write: true }); |
|
||||||
continue; |
|
||||||
} |
|
||||||
|
|
||||||
// Check for explicit markers
|
|
||||||
const hasRead = markers.includes('read'); |
|
||||||
const hasWrite = markers.includes('write'); |
|
||||||
|
|
||||||
// If only 'read' marker: read=true, write=false
|
|
||||||
// If only 'write' marker: read=false, write=true
|
|
||||||
// If both or neither explicitly: both true (default behavior)
|
|
||||||
const read = hasRead || (!hasRead && !hasWrite); |
|
||||||
const write = hasWrite || (!hasRead && !hasWrite); |
|
||||||
|
|
||||||
relays.push({ url, read, write }); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return relays; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Fetch relay lists for a pubkey (kind 10002 and 10432) |
|
||||||
*/ |
|
||||||
export async function fetchRelayLists( |
|
||||||
pubkey: string, |
|
||||||
relays?: string[] |
|
||||||
): Promise<{ |
|
||||||
inbox: string[]; |
|
||||||
outbox: string[]; |
|
||||||
}> { |
|
||||||
const relayList = relays || [ |
|
||||||
...config.defaultRelays, |
|
||||||
...config.profileRelays |
|
||||||
]; |
|
||||||
|
|
||||||
// Fetch both kind 10002 and 10432
|
|
||||||
const events = await nostrClient.fetchEvents( |
|
||||||
[ |
|
||||||
{ kinds: [10002], authors: [pubkey], limit: 1 }, |
|
||||||
{ kinds: [10432], authors: [pubkey], limit: 1 } |
|
||||||
], |
|
||||||
relayList, |
|
||||||
{ useCache: true, cacheResults: true } |
|
||||||
); |
|
||||||
|
|
||||||
const inbox: string[] = []; |
|
||||||
const outbox: string[] = []; |
|
||||||
|
|
||||||
for (const event of events) { |
|
||||||
const relayInfos = parseRelayList(event); |
|
||||||
for (const info of relayInfos) { |
|
||||||
if (info.read && !inbox.includes(info.url)) { |
|
||||||
inbox.push(info.url); |
|
||||||
} |
|
||||||
if (info.write && !outbox.includes(info.url)) { |
|
||||||
outbox.push(info.url); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Deduplicate
|
|
||||||
return { |
|
||||||
inbox: [...new Set(inbox)], |
|
||||||
outbox: [...new Set(outbox)] |
|
||||||
}; |
|
||||||
} |
|
||||||
@ -1,16 +0,0 @@ |
|||||||
/** |
|
||||||
* User preferences fetcher |
|
||||||
* Placeholder for future user preference events |
|
||||||
*/ |
|
||||||
|
|
||||||
export interface UserPreferences { |
|
||||||
// Placeholder - would be defined based on preference event kinds
|
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Fetch user preferences |
|
||||||
*/ |
|
||||||
export async function fetchUserPreferences(pubkey: string): Promise<UserPreferences | null> { |
|
||||||
// Placeholder - would fetch preference events
|
|
||||||
return null; |
|
||||||
} |
|
||||||
@ -1,50 +0,0 @@ |
|||||||
/** |
|
||||||
* User status fetcher (kind 30315, NIP-38) |
|
||||||
*/ |
|
||||||
|
|
||||||
import { nostrClient } from '../nostr/nostr-client.js'; |
|
||||||
import { config } from '../nostr/config.js'; |
|
||||||
import type { NostrEvent } from '../../types/nostr.js'; |
|
||||||
|
|
||||||
/** |
|
||||||
* Parse user status from kind 30315 event |
|
||||||
*/ |
|
||||||
export function parseUserStatus(event: NostrEvent): string | null { |
|
||||||
if (event.kind !== 30315) return null; |
|
||||||
|
|
||||||
// Check for d tag with value "general"
|
|
||||||
const dTag = event.tags.find((t) => t[0] === 'd' && t[1] === 'general'); |
|
||||||
if (!dTag) return null; |
|
||||||
|
|
||||||
return event.content || null; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Fetch user status for a pubkey |
|
||||||
*/ |
|
||||||
export async function fetchUserStatus( |
|
||||||
pubkey: string, |
|
||||||
relays?: string[] |
|
||||||
): Promise<string | null> { |
|
||||||
const relayList = relays || [ |
|
||||||
...config.defaultRelays, |
|
||||||
...config.profileRelays |
|
||||||
]; |
|
||||||
|
|
||||||
const events = await nostrClient.fetchEvents( |
|
||||||
[ |
|
||||||
{ |
|
||||||
kinds: [30315], |
|
||||||
authors: [pubkey], |
|
||||||
'#d': ['general'], |
|
||||||
limit: 1 |
|
||||||
} as any // NIP-38 uses #d tag for parameterized replaceable events
|
|
||||||
], |
|
||||||
relayList, |
|
||||||
{ useCache: true, cacheResults: true } |
|
||||||
); |
|
||||||
|
|
||||||
if (events.length === 0) return null; |
|
||||||
|
|
||||||
return parseUserStatus(events[0]); |
|
||||||
} |
|
||||||
@ -0,0 +1,34 @@ |
|||||||
|
/** |
||||||
|
* Unified event filtering service |
||||||
|
* Handles content filtering, mute lists, and NSFW detection |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
import { getMuteList } from './nostr/auth-handler.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if event should be hidden (content filtering + mute list) |
||||||
|
*/ |
||||||
|
export function shouldHideEvent(event: NostrEvent): boolean { |
||||||
|
// Check mute list
|
||||||
|
const muteList = getMuteList(); |
||||||
|
if (muteList.has(event.pubkey)) return true; |
||||||
|
|
||||||
|
// Check for content-warning or sensitive tags
|
||||||
|
const hasContentWarning = event.tags.some((t) => t[0] === 'content-warning' || t[0] === 'sensitive'); |
||||||
|
if (hasContentWarning) return true; |
||||||
|
|
||||||
|
// Check for #NSFW in content or tags
|
||||||
|
const content = event.content.toLowerCase(); |
||||||
|
const hasNSFW = content.includes('#nsfw') || event.tags.some((t) => t[1]?.toLowerCase() === 'nsfw'); |
||||||
|
if (hasNSFW) return true; |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Filter events (remove hidden content) |
||||||
|
*/ |
||||||
|
export function filterEvents(events: NostrEvent[]): NostrEvent[] { |
||||||
|
return events.filter((event) => !shouldHideEvent(event)); |
||||||
|
} |
||||||
@ -1,5 +0,0 @@ |
|||||||
/** |
|
||||||
* Nostr type definitions |
|
||||||
*/ |
|
||||||
export {}; |
|
||||||
//# sourceMappingURL=nostr.js.map
|
|
||||||
@ -1 +0,0 @@ |
|||||||
{"version":3,"file":"nostr.js","sourceRoot":"","sources":["nostr.ts"],"names":[],"mappings":"AAAA;;GAEG"} |
|
||||||
|
After Width: | Height: | Size: 64 KiB |
@ -1,18 +0,0 @@ |
|||||||
{ |
|
||||||
"name": "Aitherboard", |
|
||||||
"short_name": "Aitherboard", |
|
||||||
"description": "A decentralized messageboard built on the Nostr protocol", |
|
||||||
"start_url": "/", |
|
||||||
"display": "standalone", |
|
||||||
"background_color": "#f1f5f9", |
|
||||||
"theme_color": "#f1f5f9", |
|
||||||
"orientation": "portrait-primary", |
|
||||||
"icons": [ |
|
||||||
{ |
|
||||||
"src": "/favicon.svg", |
|
||||||
"sizes": "any", |
|
||||||
"type": "image/svg+xml", |
|
||||||
"purpose": "any maskable" |
|
||||||
} |
|
||||||
] |
|
||||||
} |
|
||||||
Loading…
Reference in new issue