Browse Source

/LIsts page

master
Silberengel 1 month ago
parent
commit
73f240af67
  1. 42
      src/lib/components/content/MarkdownRenderer.svelte
  2. 31
      src/lib/components/content/MediaAttachments.svelte
  3. 13
      src/lib/components/content/MetadataCard.svelte
  4. 11
      src/lib/modules/comments/Comment.svelte
  5. 13
      src/lib/modules/discussions/DiscussionCard.svelte
  6. 105
      src/lib/modules/feed/FeedPost.svelte
  7. 20
      src/lib/modules/rss/RSSCommentForm.svelte
  8. 258
      src/lib/utils/url-cleaner.ts
  9. 21
      src/routes/lists/+page.svelte

42
src/lib/components/content/MarkdownRenderer.svelte

@ -20,9 +20,10 @@
interface Props { interface Props {
content: string; content: string;
event?: NostrEvent; // Optional event for emoji resolution event?: NostrEvent; // Optional event for emoji resolution
excludeMediaUrls?: string[]; // URLs already displayed by MediaAttachments - skip rendering these
} }
let { content, event }: Props = $props(); let { content, event, excludeMediaUrls = [] }: Props = $props();
let containerRef = $state<HTMLElement | null>(null); let containerRef = $state<HTMLElement | null>(null);
let emojiUrls = $state<Map<string, string>>(new Map()); let emojiUrls = $state<Map<string, string>>(new Map());
let highlights = $state<Highlight[]>([]); let highlights = $state<Highlight[]>([]);
@ -102,6 +103,21 @@
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
// Normalize URL for comparison (same logic as MediaAttachments)
function normalizeUrl(url: string): string {
if (!url || typeof url !== 'string') return url;
try {
const parsed = new URL(url);
// Remove query params and fragments for comparison
// Normalize trailing slashes and convert to lowercase for comparison
const normalized = `${parsed.protocol}//${parsed.host.toLowerCase()}${parsed.pathname}`.replace(/\/$/, '');
return normalized;
} catch {
// If URL parsing fails, try basic normalization
return url.trim().replace(/\/$/, '').toLowerCase();
}
}
// Convert plain media URLs (images, videos, audio) to HTML tags // Convert plain media URLs (images, videos, audio) to HTML tags
function convertMediaUrls(text: string): string { function convertMediaUrls(text: string): string {
// Match media URLs (http/https URLs ending in media extensions) // Match media URLs (http/https URLs ending in media extensions)
@ -110,6 +126,9 @@
const audioExtensions = /\.(mp3|wav|ogg|flac|aac|m4a)(\?[^\s<>"']*)?$/i; const audioExtensions = /\.(mp3|wav|ogg|flac|aac|m4a)(\?[^\s<>"']*)?$/i;
const urlPattern = /https?:\/\/[^\s<>"']+/g; const urlPattern = /https?:\/\/[^\s<>"']+/g;
// Normalize exclude URLs for comparison
const normalizedExcludeUrls = new Set(excludeMediaUrls.map(url => normalizeUrl(url)));
let result = text; let result = text;
const matches: Array<{ url: string; index: number; endIndex: number; type: 'image' | 'video' | 'audio' }> = []; const matches: Array<{ url: string; index: number; endIndex: number; type: 'image' | 'video' | 'audio' }> = [];
@ -120,6 +139,11 @@
const index = match.index; const index = match.index;
const endIndex = index + url.length; const endIndex = index + url.length;
// Skip if this URL is already displayed by MediaAttachments
if (normalizedExcludeUrls.has(normalizeUrl(url))) {
continue;
}
// Check if this URL is already in markdown or HTML // Check if this URL is already in markdown or HTML
const before = text.substring(Math.max(0, index - 10), index); const before = text.substring(Math.max(0, index - 10), index);
const after = text.substring(endIndex, Math.min(text.length, endIndex + 10)); const after = text.substring(endIndex, Math.min(text.length, endIndex + 10));
@ -618,13 +642,23 @@
return match.replace(/<img[^>]*class="emoji-inline"[^>]*alt="([^"]*)"[^>]*>/gi, '$1'); return match.replace(/<img[^>]*class="emoji-inline"[^>]*alt="([^"]*)"[^>]*>/gi, '$1');
}); });
// Normalize exclude URLs for comparison
const normalizedExcludeUrls = new Set(excludeMediaUrls.map(url => normalizeUrl(url)));
// Fix malformed image tags - ensure all img src attributes are absolute URLs // Fix malformed image tags - ensure all img src attributes are absolute URLs
// This prevents the browser from trying to fetch markdown syntax or malformed tags as relative URLs // This prevents the browser from trying to fetch markdown syntax or malformed tags as relative URLs
// Also filter out images that are already displayed by MediaAttachments
html = html.replace(/<img\s+([^>]*?)>/gi, (match, attributes) => { html = html.replace(/<img\s+([^>]*?)>/gi, (match, attributes) => {
// Extract src attribute // Extract src attribute
const srcMatch = attributes.match(/src=["']([^"']+)["']/i); const srcMatch = attributes.match(/src=["']([^"']+)["']/i);
if (srcMatch) { if (srcMatch) {
const src = srcMatch[1]; const src = srcMatch[1];
// Skip if this image is already displayed by MediaAttachments
if (normalizedExcludeUrls.has(normalizeUrl(src))) {
return ''; // Remove the img tag
}
// If src doesn't start with http:// or https://, it might be malformed // If src doesn't start with http:// or https://, it might be malformed
// Check if it looks like it should be a URL but isn't properly formatted // Check if it looks like it should be a URL but isn't properly formatted
if (!src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('/')) { if (!src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('/')) {
@ -647,7 +681,13 @@
// Fix markdown image syntax that wasn't properly converted (e.g., ![image](url) as text) // Fix markdown image syntax that wasn't properly converted (e.g., ![image](url) as text)
// This should be rare, but if marked didn't convert it, we need to handle it // This should be rare, but if marked didn't convert it, we need to handle it
// Also filter out images that are already displayed by MediaAttachments
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => { html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => {
// Skip if this image is already displayed by MediaAttachments
if (normalizedExcludeUrls.has(normalizeUrl(url))) {
return ''; // Remove the markdown image syntax
}
// Only convert if it's a valid URL // Only convert if it's a valid URL
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) { if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
const escapedUrl = escapeHtml(url); const escapedUrl = escapeHtml(url);

31
src/lib/components/content/MediaAttachments.svelte

@ -3,9 +3,10 @@
interface Props { interface Props {
event: NostrEvent; event: NostrEvent;
forceRender?: boolean; // If true, always render media even if URL is in content (for media kinds)
} }
let { event }: Props = $props(); let { event, forceRender = false }: Props = $props();
interface MediaItem { interface MediaItem {
url: string; url: string;
@ -18,12 +19,17 @@
} }
function normalizeUrl(url: string): string { function normalizeUrl(url: string): string {
if (!url || typeof url !== 'string') return url;
try { try {
const parsed = new URL(url); const parsed = new URL(url);
// Remove query params and fragments for comparison // Remove query params and fragments for comparison
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/$/, ''); // Normalize trailing slashes and convert to lowercase for comparison
// Keep protocol and host case-sensitive for proper domain matching
const normalized = `${parsed.protocol}//${parsed.host.toLowerCase()}${parsed.pathname}`.replace(/\/$/, '');
return normalized;
} catch { } catch {
return url; // If URL parsing fails, try basic normalization
return url.trim().replace(/\/$/, '').toLowerCase();
} }
} }
@ -34,7 +40,7 @@
// Check if URL appears as plain text (with or without protocol) // Check if URL appears as plain text (with or without protocol)
const urlWithoutProtocol = normalized.replace(/^https?:\/\//i, ''); const urlWithoutProtocol = normalized.replace(/^https?:\/\//i, '');
if (content.includes(normalized.toLowerCase()) || content.includes(urlWithoutProtocol.toLowerCase())) { if (content.includes(normalized) || content.includes(urlWithoutProtocol)) {
return true; return true;
} }
@ -102,7 +108,8 @@
if (url) { if (url) {
const normalized = normalizeUrl(url); const normalized = normalizeUrl(url);
// Skip if already displayed in content (imeta is just metadata reference) // Skip if already displayed in content (imeta is just metadata reference)
if (isUrlInContent(url)) { // UNLESS forceRender is true (for media kinds where media is the primary content)
if (!forceRender && isUrlInContent(url)) {
continue; continue;
} }
@ -176,7 +183,19 @@
// This ensures images appear where the URL is in the content, not at the top // This ensures images appear where the URL is in the content, not at the top
// Only extract images from tags (image, imeta, file) which are handled above // Only extract images from tags (image, imeta, file) which are handled above
return media; // Final deduplication pass: ensure no duplicates by normalized URL
const deduplicated: MediaItem[] = [];
const finalSeen = new Set<string>();
for (const item of media) {
const normalized = normalizeUrl(item.url);
if (!finalSeen.has(normalized)) {
deduplicated.push(item);
finalSeen.add(normalized);
}
}
return deduplicated;
} }
const mediaItems = $derived(extractMedia()); const mediaItems = $derived(extractMedia());

13
src/lib/components/content/MetadataCard.svelte

@ -5,6 +5,9 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import IconButton from '../ui/IconButton.svelte'; import IconButton from '../ui/IconButton.svelte';
import { sessionManager } from '../../services/auth/session-manager.js'; import { sessionManager } from '../../services/auth/session-manager.js';
import MediaAttachments from './MediaAttachments.svelte';
import { KIND } from '../../types/kind-lookup.js';
import { page } from '$app/stores';
interface Props { interface Props {
event: NostrEvent; event: NostrEvent;
@ -71,6 +74,12 @@
const hasMetadata = $derived(image || description || summary || author || title); const hasMetadata = $derived(image || description || summary || author || title);
const hasContent = $derived(event.content && event.content.trim().length > 0); const hasContent = $derived(event.content && event.content.trim().length > 0);
const shouldShowMetadata = $derived(hasMetadata || !hasContent); // Show metadata if it exists OR if there's no content const shouldShowMetadata = $derived(hasMetadata || !hasContent); // Show metadata if it exists OR if there's no content
// Media kinds that should auto-render media (except on /feed)
const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY];
const isMediaKind = $derived(MEDIA_KINDS.includes(event.kind));
const isOnFeedPage = $derived($page.url.pathname === '/feed');
const shouldAutoRenderMedia = $derived(isMediaKind && !isOnFeedPage);
</script> </script>
{#if shouldShowMetadata} {#if shouldShowMetadata}
@ -109,6 +118,10 @@
</div> </div>
{/if} {/if}
{#if shouldAutoRenderMedia}
<MediaAttachments event={event} forceRender={isMediaKind} />
{/if}
<div class="metadata-content"> <div class="metadata-content">
{#if description} {#if description}
<p class="metadata-description">{description}</p> <p class="metadata-description">{description}</p>

11
src/lib/modules/comments/Comment.svelte

@ -16,6 +16,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import IconButton from '../../components/ui/IconButton.svelte'; import IconButton from '../../components/ui/IconButton.svelte';
import { sessionManager } from '../../services/auth/session-manager.js'; import { sessionManager } from '../../services/auth/session-manager.js';
import { page } from '$app/stores';
interface Props { interface Props {
comment: NostrEvent; comment: NostrEvent;
@ -34,6 +35,12 @@
let needsExpansion = $state(false); let needsExpansion = $state(false);
let showReplyForm = $state(false); let showReplyForm = $state(false);
// Media kinds that should auto-render media (except on /feed)
const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY];
const isMediaKind = $derived(MEDIA_KINDS.includes(comment.kind));
const isOnFeedPage = $derived($page.url.pathname === '/feed');
const shouldAutoRenderMedia = $derived(isMediaKind && !isOnFeedPage);
// DiscussionVoteButtons handles all vote counting internally // DiscussionVoteButtons handles all vote counting internally
function getRelativeTime(): string { function getRelativeTime(): string {
@ -135,7 +142,9 @@
</div> </div>
<div class="comment-content mb-2"> <div class="comment-content mb-2">
<MediaAttachments event={comment} /> {#if shouldAutoRenderMedia}
<MediaAttachments event={comment} forceRender={isMediaKind} />
{/if}
<MarkdownRenderer content={comment.content} event={comment} /> <MarkdownRenderer content={comment.content} event={comment} />
</div> </div>
</div> </div>

13
src/lib/modules/discussions/DiscussionCard.svelte

@ -18,6 +18,7 @@
import { getEventLink } from '../../services/event-links.js'; import { getEventLink } from '../../services/event-links.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import IconButton from '../../components/ui/IconButton.svelte'; import IconButton from '../../components/ui/IconButton.svelte';
import { page } from '$app/stores';
interface Props { interface Props {
thread: NostrEvent; thread: NostrEvent;
@ -30,6 +31,12 @@
let { thread, commentCount: providedCommentCount = 0, upvotes: providedUpvotes = 0, downvotes: providedDownvotes = 0, votesCalculated: providedVotesCalculated = false, fullView = false }: Props = $props(); let { thread, commentCount: providedCommentCount = 0, upvotes: providedUpvotes = 0, downvotes: providedDownvotes = 0, votesCalculated: providedVotesCalculated = false, fullView = false }: Props = $props();
// Media kinds that should auto-render media (except on /feed)
const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY];
const isMediaKind = $derived(MEDIA_KINDS.includes(thread.kind));
const isOnFeedPage = $derived($page.url.pathname === '/feed');
const shouldAutoRenderMedia = $derived(isMediaKind && !isOnFeedPage);
let commentCount = $state(0); let commentCount = $state(0);
// Update comment count when provided value changes // Update comment count when provided value changes
@ -263,6 +270,10 @@
<!-- Display metadata (title, author, summary, description, image) --> <!-- Display metadata (title, author, summary, description, image) -->
<MetadataCard event={thread} hideTitle={true} hideImageIfInMedia={true} /> <MetadataCard event={thread} hideTitle={true} hideImageIfInMedia={true} />
{#if shouldAutoRenderMedia}
<MediaAttachments event={thread} forceRender={isMediaKind} />
{/if}
<p class="mb-2 text-fog-text dark:text-fog-dark-text">{getPreview()}</p> <p class="mb-2 text-fog-text dark:text-fog-dark-text">{getPreview()}</p>
{#if getTopics().length > 0} {#if getTopics().length > 0}
@ -311,7 +322,7 @@
<MetadataCard event={thread} hideTitle={true} hideImageIfInMedia={true} /> <MetadataCard event={thread} hideTitle={true} hideImageIfInMedia={true} />
<div class="post-content mb-2"> <div class="post-content mb-2">
<MediaAttachments event={thread} /> <MediaAttachments event={thread} forceRender={isMediaKind} />
<MarkdownRenderer content={thread.content} event={thread} /> <MarkdownRenderer content={thread.content} event={thread} />
</div> </div>

105
src/lib/modules/feed/FeedPost.svelte

@ -10,6 +10,7 @@
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte'; import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import MediaViewer from '../../components/content/MediaViewer.svelte'; import MediaViewer from '../../components/content/MediaViewer.svelte';
import CommentForm from '../comments/CommentForm.svelte'; import CommentForm from '../comments/CommentForm.svelte';
import PollCard from '../../components/content/PollCard.svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo, KIND } from '../../types/kind-lookup.js'; import { getKindInfo, KIND } from '../../types/kind-lookup.js';
import { stripMarkdown } from '../../services/text-utils.js'; import { stripMarkdown } from '../../services/text-utils.js';
@ -21,6 +22,7 @@
import { getEventLink } from '../../services/event-links.js'; import { getEventLink } from '../../services/event-links.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import IconButton from '../../components/ui/IconButton.svelte'; import IconButton from '../../components/ui/IconButton.svelte';
import { page } from '$app/stores';
interface Props { interface Props {
post: NostrEvent; post: NostrEvent;
@ -48,11 +50,20 @@
// Reply state // Reply state
let showReplyForm = $state(false); let showReplyForm = $state(false);
// Media kinds that should auto-render media (except on /feed)
const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY];
const isMediaKind = $derived(MEDIA_KINDS.includes(post.kind));
const isOnFeedPage = $derived($page.url.pathname === '/feed');
const shouldAutoRenderMedia = $derived(isMediaKind && !isOnFeedPage);
// Check if card should be collapsed (only in feed view) // Check if card should be collapsed (only in feed view)
let isMounted = $state(true); let isMounted = $state(true);
// Don't collapse media kinds when they should auto-render (they need full space for images/videos)
const shouldNeverCollapse = $derived(fullView || shouldAutoRenderMedia);
$effect(() => { $effect(() => {
if (fullView) { if (shouldNeverCollapse) {
shouldCollapse = false; shouldCollapse = false;
return; return;
} }
@ -505,14 +516,88 @@
} }
function normalizeUrl(url: string): string { function normalizeUrl(url: string): string {
if (!url || typeof url !== 'string') return url;
try { try {
const parsed = new URL(url); const parsed = new URL(url);
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/$/, ''); // Normalize for comparison (same as MediaAttachments)
const normalized = `${parsed.protocol}//${parsed.host.toLowerCase()}${parsed.pathname}`.replace(/\/$/, '');
return normalized;
} catch { } catch {
return url; return url.trim().replace(/\/$/, '').toLowerCase();
}
}
// Get media URLs that MediaAttachments will display (for fullView)
// This matches the logic in MediaAttachments.extractMedia()
function getMediaAttachmentUrls(): string[] {
const urls: string[] = [];
const seen = new Set<string>();
const forceRender = isMediaKind; // Same as what we pass to MediaAttachments
// 1. Image tag (NIP-23)
const imageTag = post.tags.find((t) => t[0] === 'image');
if (imageTag && imageTag[1]) {
const normalized = normalizeUrl(imageTag[1]);
if (!seen.has(normalized)) {
urls.push(imageTag[1]);
seen.add(normalized);
}
}
// 2. imeta tags (NIP-92)
for (const tag of post.tags) {
if (tag[0] === 'imeta') {
let url: string | undefined;
for (let i = 1; i < tag.length; i++) {
const item = tag[i];
if (item.startsWith('url ')) {
url = item.substring(4).trim();
break;
}
}
if (url) {
const normalized = normalizeUrl(url);
// Skip if already displayed in content (imeta is just metadata reference)
// UNLESS forceRender is true (for media kinds where media is the primary content)
if (!forceRender && isUrlInContent(url)) {
continue;
}
if (!seen.has(normalized)) {
urls.push(url);
seen.add(normalized);
}
}
}
}
// 3. file tags (NIP-94)
for (const tag of post.tags) {
if (tag[0] === 'file' && tag[1]) {
const normalized = normalizeUrl(tag[1]);
if (!seen.has(normalized)) {
urls.push(tag[1]);
seen.add(normalized);
}
}
}
// 4. Extract from markdown content (images in markdown syntax)
const imageRegex = /!\[.*?\]\((.*?)\)/g;
let match;
while ((match = imageRegex.exec(post.content)) !== null) {
const url = match[1];
const normalized = normalizeUrl(url);
if (!seen.has(normalized)) {
urls.push(url);
seen.add(normalized);
} }
} }
return urls;
}
// Extract media URLs from event tags (image, imeta, file) - for feed view only // Extract media URLs from event tags (image, imeta, file) - for feed view only
// Excludes URLs that are already in the content // Excludes URLs that are already in the content
function getMediaUrls(): string[] { function getMediaUrls(): string[] {
@ -659,8 +744,15 @@
</div> </div>
<div class="post-content mb-2"> <div class="post-content mb-2">
<MediaAttachments event={post} /> {#if (shouldAutoRenderMedia || fullView) && (post.content && post.content.trim())}
<MarkdownRenderer content={post.content} event={post} /> <MediaAttachments event={post} forceRender={isMediaKind} />
{/if}
{#if post.kind === KIND.POLL && fullView}
<PollCard pollEvent={post} />
{:else}
{@const mediaAttachmentUrls = getMediaAttachmentUrls()}
<MarkdownRenderer content={post.content} event={post} excludeMediaUrls={mediaAttachmentUrls} />
{/if}
</div> </div>
<div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4"> <div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4">
@ -707,6 +799,9 @@
{/if} {/if}
<div bind:this={contentElement} class="post-content mb-2" class:collapsed-content={!fullView && shouldCollapse && !isExpanded}> <div bind:this={contentElement} class="post-content mb-2" class:collapsed-content={!fullView && shouldCollapse && !isExpanded}>
{#if shouldAutoRenderMedia}
<MediaAttachments event={post} forceRender={isMediaKind} />
{/if}
<p class="text-fog-text dark:text-fog-dark-text whitespace-pre-wrap word-wrap"> <p class="text-fog-text dark:text-fog-dark-text whitespace-pre-wrap word-wrap">
{#each parseContentWithNIP21Links() as segment} {#each parseContentWithNIP21Links() as segment}
{@const highlightContent = getHighlightContent()} {@const highlightContent = getHighlightContent()}

20
src/lib/modules/rss/RSSCommentForm.svelte

@ -14,6 +14,7 @@
import { cacheEvent } from '../../services/cache/event-cache.js'; import { cacheEvent } from '../../services/cache/event-cache.js';
import { autoExtractTags } from '../../services/auto-tagging.js'; import { autoExtractTags } from '../../services/auto-tagging.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { cleanTrackingParams } from '../../utils/url-cleaner.js';
interface Props { interface Props {
url: string; // The RSS item URL url: string; // The RSS item URL
@ -23,6 +24,9 @@
let { url, onPublished, onCancel }: Props = $props(); let { url, onPublished, onCancel }: Props = $props();
// Clean the URL to remove tracking parameters
const cleanedUrl = $derived(cleanTrackingParams(url));
// Create unique draft ID based on URL // Create unique draft ID based on URL
const DRAFT_ID = $derived(`rss_comment_${url}`); const DRAFT_ID = $derived(`rss_comment_${url}`);
@ -89,11 +93,11 @@
const tags: string[][] = []; const tags: string[][] = [];
// For RSS items, we use kind 1111 comments // For RSS items, we use kind 1111 comments
// Add "i" tag with the URL // Add "i" tag with the cleaned URL (tracking parameters removed)
tags.push(['i', url]); tags.push(['i', cleanedUrl]);
// Add "r" tag with the URL for relay hints // Add "r" tag with the cleaned URL for relay hints
tags.push(['r', url]); tags.push(['r', cleanedUrl]);
if (shouldIncludeClientTag()) { if (shouldIncludeClientTag()) {
tags.push(['client', 'aitherboard']); tags.push(['client', 'aitherboard']);
@ -193,8 +197,8 @@
} }
const tags: string[][] = []; const tags: string[][] = [];
tags.push(['i', url]); tags.push(['i', cleanedUrl]);
tags.push(['r', url]); tags.push(['r', cleanedUrl]);
if (shouldIncludeClientTag()) { if (shouldIncludeClientTag()) {
tags.push(['client', 'aitherboard']); tags.push(['client', 'aitherboard']);
@ -249,8 +253,8 @@
// Build preview event with all tags // Build preview event with all tags
const previewTags: string[][] = []; const previewTags: string[][] = [];
previewTags.push(['i', url]); previewTags.push(['i', cleanedUrl]);
previewTags.push(['r', url]); previewTags.push(['r', cleanedUrl]);
for (const file of uploadedFiles) { for (const file of uploadedFiles) {
previewTags.push(file.imetaTag); previewTags.push(file.imetaTag);

258
src/lib/utils/url-cleaner.ts

@ -0,0 +1,258 @@
/**
* Utility to clean tracking parameters from URLs
* Removes common tracking query parameters like utm_*, ref, fbclid, gclid, etc.
*/
/**
* List of common tracking parameters to remove from URLs
*/
const TRACKING_PARAMS = new Set([
// UTM parameters
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'utm_id',
'utm_cid',
// Generic tracking
'ref',
'source',
'campaign',
'referrer',
'referer',
// Social media tracking
'fbclid', // Facebook
'gclid', // Google Ads
'msclkid', // Microsoft
'twclid', // Twitter
'li_fat_id', // LinkedIn
'igshid', // Instagram
// Analytics
'_ga', // Google Analytics
'_gid', // Google Analytics
'_gl', // Google Analytics Linker
'mc_cid', // MailChimp
'mc_eid', // MailChimp
'icid', // Various
'ncid', // Various
// Other common trackers
'affiliate_id',
'affid',
'affiliate',
'partner_id',
'partner',
'click_id',
'clickid',
'clickId',
'click',
'tracking_id',
'trackingId',
'tracking',
'track',
'tid',
'trk',
'trkid',
// E-commerce
'promo',
'promocode',
'promo_code',
'discount',
'coupon',
'voucher',
// Email marketing
'email_source',
'email_campaign',
'email_medium',
// Content
'content_id',
'contentId',
'content',
// A/B testing
'ab_test',
'abtest',
'variant',
// Time-based
'timestamp',
'ts',
'time',
// Miscellaneous
'hash',
'anchor',
'position',
'pos',
'placement',
'placement_id',
'placementId',
'widget_id',
'widgetId',
'widget',
'context',
'ctx',
'origin',
'orig',
'return',
'return_to',
'returnTo',
'redirect',
'redirect_to',
'redirectTo',
'next',
'continue',
'callback',
'cb',
'state',
'session_id',
'sessionId',
'sid',
'token',
'key',
'api_key',
'apikey',
'apiKey',
'auth',
'auth_token',
'authToken',
'access_token',
'accessToken',
'refresh_token',
'refreshToken',
]);
/**
* Check if a URL fragment (hash) contains tracking parameters
*
* @param fragment - The URL fragment (without the #)
* @returns true if the fragment contains tracking parameters
*/
function isTrackingFragment(fragment: string): boolean {
if (!fragment) return false;
// Check if fragment is in key=value format (e.g., "ref=rss")
const equalIndex = fragment.indexOf('=');
if (equalIndex > 0) {
const key = fragment.substring(0, equalIndex).toLowerCase();
// Check if it's a known tracking parameter
if (TRACKING_PARAMS.has(key)) {
return true;
}
// Check for tracking patterns
if (
key.startsWith('utm_') ||
key.startsWith('tracking_') ||
key.startsWith('track_') ||
key.startsWith('click_') ||
key.startsWith('affiliate_') ||
key.startsWith('partner_') ||
key.startsWith('ref_') ||
key.startsWith('source_')
) {
return true;
}
} else {
// Fragment is just a key (e.g., "ref")
const keyLower = fragment.toLowerCase();
if (TRACKING_PARAMS.has(keyLower)) {
return true;
}
// Check for tracking patterns
if (
keyLower.startsWith('utm_') ||
keyLower.startsWith('tracking_') ||
keyLower.startsWith('track_') ||
keyLower.startsWith('click_') ||
keyLower.startsWith('affiliate_') ||
keyLower.startsWith('partner_') ||
keyLower.startsWith('ref_') ||
keyLower.startsWith('source_')
) {
return true;
}
}
return false;
}
/**
* Clean tracking parameters from a URL
* Removes tracking parameters from both query string and URL fragment (hash)
*
* @param url - The URL to clean
* @returns The cleaned URL with tracking parameters removed
*/
export function cleanTrackingParams(url: string): string {
if (!url || typeof url !== 'string') {
return url;
}
try {
const urlObj = new URL(url);
// Get all search parameters
const params = urlObj.searchParams;
const keysToDelete: string[] = [];
// Find all tracking parameters (case-insensitive)
for (const [key, value] of params.entries()) {
const keyLower = key.toLowerCase();
// Check if this is a known tracking parameter
if (TRACKING_PARAMS.has(keyLower)) {
keysToDelete.push(key);
continue;
}
// Also check for common patterns (e.g., keys starting with tracking prefixes)
if (
keyLower.startsWith('utm_') ||
keyLower.startsWith('tracking_') ||
keyLower.startsWith('track_') ||
keyLower.startsWith('click_') ||
keyLower.startsWith('affiliate_') ||
keyLower.startsWith('partner_') ||
keyLower.startsWith('ref_') ||
keyLower.startsWith('source_') ||
keyLower.endsWith('_id') && (
keyLower.includes('track') ||
keyLower.includes('click') ||
keyLower.includes('affiliate') ||
keyLower.includes('partner') ||
keyLower.includes('campaign') ||
keyLower.includes('source')
)
) {
keysToDelete.push(key);
}
}
// Remove all identified tracking parameters
for (const key of keysToDelete) {
params.delete(key);
}
// Reconstruct the URL
urlObj.search = params.toString();
// Check and remove tracking parameters from URL fragment (hash)
const fragment = urlObj.hash.substring(1); // Remove the # character
if (fragment && isTrackingFragment(fragment)) {
urlObj.hash = ''; // Remove the fragment
}
return urlObj.toString();
} catch (error) {
// If URL parsing fails, return the original URL
console.warn('Failed to parse URL for cleaning:', url, error);
return url;
}
}

21
src/routes/lists/+page.svelte

@ -24,8 +24,19 @@
let loading = $state(true); let loading = $state(true);
let loadingEvents = $state(false); let loadingEvents = $state(false);
let hasLists = $derived(lists.length > 0); let hasLists = $derived(lists.length > 0);
const isLoggedIn = $derived(sessionManager.isLoggedIn());
const currentPubkey = $derived(sessionManager.getCurrentPubkey()); // Subscribe to session changes to reactively update login status
let currentSession = $state(sessionManager.session.value);
const isLoggedIn = $derived(currentSession !== null);
const currentPubkey = $derived(currentSession?.pubkey || null);
// Subscribe to session changes
$effect(() => {
const unsubscribe = sessionManager.session.subscribe((session) => {
currentSession = session;
});
return unsubscribe;
});
// Get all relays (default + profile + user inbox) // Get all relays (default + profile + user inbox)
function getAllRelays(): string[] { function getAllRelays(): string[] {
@ -166,10 +177,14 @@
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
if (!isLoggedIn) { // Restore session if not already logged in (in case of page refresh)
if (!sessionManager.isLoggedIn()) {
const restored = await sessionManager.restoreSession();
if (!restored || !sessionManager.isLoggedIn()) {
goto('/login'); goto('/login');
return; return;
} }
}
await loadLists(); await loadLists();
}); });

Loading…
Cancel
Save