Browse Source

Merges pull request #55

Issue#296
master
silberengel 7 months ago
parent
commit
b7605a6ded
No known key found for this signature in database
GPG Key ID: 962BEC8725790894
  1. 180
      package-lock.json
  2. 16
      src/lib/components/CommentBox.svelte
  3. 2
      src/lib/components/EventDetails.svelte
  4. 113
      src/lib/components/Notifications.svelte
  5. 2
      src/lib/components/RelayActions.svelte
  6. 2
      src/lib/components/ZettelEditor.svelte
  7. 2
      src/lib/components/publications/Publication.svelte
  8. 282
      src/lib/components/publications/PublicationFeed.svelte
  9. 2
      src/lib/components/publications/PublicationHeader.svelte
  10. 2
      src/lib/components/util/Details.svelte
  11. 19
      src/lib/components/util/Profile.svelte
  12. 138
      src/lib/data_structures/publication_tree.ts
  13. 36
      src/lib/stores/userStore.ts
  14. 13
      src/lib/utils/markup/basicMarkupParser.ts
  15. 27
      src/lib/utils/nostrUtils.ts
  16. 86
      src/lib/utils/notification_utils.ts
  17. 10
      src/lib/utils/websocket_utils.ts
  18. 85
      src/routes/+page.svelte
  19. 4
      src/routes/[...catchall]/+page.svelte
  20. 8
      src/routes/my-notes/+page.svelte
  21. 2
      src/routes/new/compose/+page.svelte
  22. 8
      src/routes/new/edit/+page.svelte
  23. 214
      src/styles/notifications.css

180
package-lock.json generated

@ -2783,19 +2783,39 @@ @@ -2783,19 +2783,39 @@
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true,
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 14.16.0"
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/cliui": {
@ -3711,6 +3731,16 @@ @@ -3711,6 +3731,16 @@
}
}
},
"node_modules/eslint-plugin-svelte/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/eslint-scope": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
@ -5953,17 +5983,27 @@ @@ -5953,17 +5983,27 @@
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"devOptional": true,
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">= 14.18.0"
"node": ">=8.10.0"
}
},
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/require-directory": {
@ -6471,6 +6511,36 @@ @@ -6471,6 +6511,36 @@
"typescript": ">=5.0.0"
}
},
"node_modules/svelte-check/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/svelte-check/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/svelte-eslint-parser": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.3.1.tgz",
@ -6671,54 +6741,6 @@ @@ -6671,54 +6741,6 @@
"node": ">=14.0.0"
}
},
"node_modules/tailwindcss/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/tailwindcss/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tailwindcss/node_modules/postcss-load-config": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
@ -6767,30 +6789,6 @@ @@ -6767,30 +6789,6 @@
"node": ">=4"
}
},
"node_modules/tailwindcss/node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/tailwindcss/node_modules/yaml": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@ -7376,13 +7374,15 @@ @@ -7376,13 +7374,15 @@
}
},
"node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true,
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 6"
"node": ">= 14.6"
}
},
"node_modules/yargs": {

16
src/lib/components/CommentBox.svelte

@ -364,12 +364,12 @@ @@ -364,12 +364,12 @@
<div class="w-full space-y-4">
<div class="flex flex-wrap gap-2">
{#each markupButtons as button}
<Button size="xs" on:click={button.action}>{button.label}</Button>
<Button size="xs" onclick={button.action}>{button.label}</Button>
{/each}
<Button size="xs" color="alternative" on:click={removeFormatting}
<Button size="xs" color="alternative" onclick={removeFormatting}
>Remove Formatting</Button
>
<Button size="xs" color="alternative" on:click={clearForm}>Clear</Button>
<Button size="xs" color="alternative" onclick={clearForm}>Clear</Button>
</div>
<!-- Mention Modal -->
@ -519,12 +519,12 @@ @@ -519,12 +519,12 @@
class="mb-4"
/>
<div class="flex justify-end gap-2">
<Button size="xs" color="primary" on:click={insertWikilink}>Insert</Button
<Button size="xs" color="primary" onclick={insertWikilink}>Insert</Button
>
<Button
size="xs"
color="alternative"
on:click={() => {
onclick={() => {
showWikilinkModal = false;
}}>Cancel</Button
>
@ -552,7 +552,7 @@ @@ -552,7 +552,7 @@
<Alert color="red" dismissable>
{error}
{#if showOtherRelays}
<Button size="xs" class="mt-2" on:click={() => handleSubmit(true)}
<Button size="xs" class="mt-2" onclick={() => handleSubmit(true)}
>Try Other Relays</Button
>
{/if}
@ -560,7 +560,7 @@ @@ -560,7 +560,7 @@
<Button
size="xs"
class="mt-2"
on:click={() => handleSubmit(false, true)}>Try Fallback Relays</Button
onclick={() => handleSubmit(false, true)}>Try Fallback Relays</Button
>
{/if}
</Alert>
@ -604,7 +604,7 @@ @@ -604,7 +604,7 @@
</div>
{/if}
<Button
on:click={() => handleSubmit()}
onclick={() => handleSubmit()}
disabled={isSubmitting || !content.trim() || !$userPubkey}
class="w-full md:w-auto"
>

2
src/lib/components/EventDetails.svelte

@ -411,7 +411,7 @@ @@ -411,7 +411,7 @@
<span class="text-gray-600 dark:text-gray-400"
>Author: {@render userBadge(
toNpub(event.pubkey) as string,
profile?.display_name || event.pubkey,
profile?.display_name || undefined,
)}</span
>
{:else}

113
src/lib/components/Notifications.svelte

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
<script lang="ts">
import "../../styles/notifications.css";
import { onMount } from "svelte";
import { Heading, P } from "flowbite-svelte";
import type { NDKEvent } from "$lib/utils/nostrUtils";
@ -21,6 +22,7 @@ @@ -21,6 +22,7 @@
truncateContent,
truncateRenderedContent,
parseContent,
parseRepostContent,
renderQuotedContent,
getNotificationType,
fetchAuthorProfiles
@ -147,9 +149,9 @@ @@ -147,9 +149,9 @@
}
// ALWAYS highlight the message in blue
element.classList.add('ring-2', 'ring-blue-500');
element.classList.add('message-highlight', 'ring-2', 'ring-blue-500');
setTimeout(() => {
element.classList.remove('ring-2', 'ring-blue-500');
element.classList.remove('message-highlight', 'ring-2', 'ring-blue-500');
}, 2000);
}
}
@ -691,7 +693,7 @@ @@ -691,7 +693,7 @@
</script>
{#if isOwnProfile && $userStore.signedIn}
<div class="mb-6">
<div class="mb-6 w-full overflow-x-hidden">
<div class="flex items-center justify-between mb-4">
<Heading tag="h3" class="h-leather">Notifications</Heading>
@ -712,7 +714,7 @@ @@ -712,7 +714,7 @@
{#each ["to-me", "from-me", "public-messages"] as mode}
{@const modeLabel = mode === "to-me" ? "To Me" : mode === "from-me" ? "From Me" : "Public Messages"}
<button
class="px-3 py-1 text-sm font-medium rounded-md transition-colors {notificationMode === mode ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-700 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'}"
class="mode-toggle-button px-3 py-1 text-sm font-medium rounded-md {notificationMode === mode ? 'active' : 'inactive'}"
onclick={() => notificationMode = mode as "to-me" | "from-me" | "public-messages"}
>
{modeLabel}
@ -724,7 +726,7 @@ @@ -724,7 +726,7 @@
{#if loading}
<div class="flex items-center justify-center py-8 min-h-96">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<div class="notifications-loading-spinner rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<span class="ml-2 text-gray-600 dark:text-gray-400">
Loading {notificationMode === "public-messages" ? "public messages" : "notifications"}...
</span>
@ -739,12 +741,12 @@ @@ -739,12 +741,12 @@
<P>No public messages found.</P>
</div>
{:else}
<div class="max-h-[72rem] overflow-y-auto">
<div class="max-h-[72rem] overflow-y-auto overflow-x-hidden">
{#if filteredByUser}
<div class="mb-4 p-3 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg">
<div class="filter-indicator mb-4 p-3 rounded-lg">
<div class="flex items-center justify-between">
<span class="text-sm text-blue-700 dark:text-blue-300">
Filtered by user: {@render userBadge(filteredByUser, authorProfiles.get(filteredByUser)?.displayName || authorProfiles.get(filteredByUser)?.name)}
Filtered by user: @{authorProfiles.get(filteredByUser)?.displayName || authorProfiles.get(filteredByUser)?.name || filteredByUser?.slice(0, 8) + "..." + filteredByUser?.slice(-4) || "Unknown"}
</span>
<button
class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 underline font-medium"
@ -759,11 +761,11 @@ @@ -759,11 +761,11 @@
{#each filteredMessages.slice(0, 100) as message}
{@const authorProfile = authorProfiles.get(message.pubkey)}
{@const isFromUser = message.pubkey === $userStore.pubkey}
<div class="p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:shadow-md transition-all" data-event-id="{message.id}">
<div class="message-container p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm" data-event-id="{message.id}">
<div class="flex items-start gap-3 {isFromUser ? 'flex-row-reverse' : ''}">
<!-- Author Profile Picture and Name -->
<div class="flex-shrink-0 relative">
<div class="flex items-center gap-2 {isFromUser ? 'flex-row-reverse' : ''}">
<div class="flex flex-col items-center gap-2 {isFromUser ? 'items-end' : 'items-start'}">
{#if authorProfile?.picture}
<img
src={authorProfile.picture}
@ -772,23 +774,25 @@ @@ -772,23 +774,25 @@
onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'}
/>
{:else}
<div class="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600">
<div class="profile-picture-fallback w-10 h-10 rounded-full flex items-center justify-center border border-gray-200 dark:border-gray-600">
<span class="text-sm font-medium text-gray-600 dark:text-gray-300">
{(authorProfile?.displayName || authorProfile?.name || message.pubkey.slice(0, 1)).toUpperCase()}
</span>
</div>
{/if}
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{@render userBadge(message.pubkey, authorProfile?.displayName || authorProfile?.name)}
</span>
<div class="w-24 text-center">
<span class="text-xs font-medium text-gray-900 dark:text-gray-100 break-words">
@{authorProfile?.displayName || authorProfile?.name || message.pubkey.slice(0, 8) + "..." + message.pubkey.slice(-4)}
</span>
</div>
</div>
<!-- Filter button for non-user messages -->
{#if !isFromUser}
<div class="mt-2 flex flex-col gap-1">
<div class="mt-2 flex justify-center gap-1">
<!-- Reply button -->
<button
class="w-6 h-6 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-full flex items-center justify-center text-xs shadow-sm transition-colors"
class="reply-button w-6 h-6 border border-gray-400 dark:border-gray-500 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full flex items-center justify-center text-xs transition-colors"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -801,7 +805,7 @@ @@ -801,7 +805,7 @@
</button>
<!-- Filter button -->
<button
class="w-6 h-6 bg-gray-400 hover:bg-gray-500 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-300 rounded-full flex items-center justify-center text-xs shadow-sm transition-colors {filteredByUser === message.pubkey ? 'ring-2 ring-gray-300 dark:ring-gray-400 bg-gray-500 dark:bg-gray-500' : ''}"
class="filter-button w-6 h-6 border border-gray-400 dark:border-gray-500 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full flex items-center justify-center text-xs transition-colors {filteredByUser === message.pubkey ? 'filter-button-active bg-gray-200 dark:bg-gray-600 border-gray-500 dark:border-gray-400' : ''}"
onclick={() => filterByUser(message.pubkey)}
title="Filter by this user"
aria-label="Filter by this user"
@ -815,7 +819,7 @@ @@ -815,7 +819,7 @@
</div>
<!-- Message Content -->
<div class="flex-1 min-w-0 {isFromUser ? 'text-right' : ''}">
<div class="message-content flex-1 min-w-0 {isFromUser ? 'text-right' : ''}">
<div class="flex items-center gap-2 mb-2 {isFromUser ? 'justify-end' : ''}">
<span class="text-xs font-medium text-primary-600 dark:text-primary-400 bg-primary-100 dark:bg-primary-900 px-2 py-1 rounded">
{isFromUser ? 'Your Message' : 'Public Message'}
@ -845,11 +849,13 @@ @@ -845,11 +849,13 @@
{/if}
{#if message.content}
<div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed">
{#await parseContent(message.content) then parsedContent}
{@html parsedContent}
{:catch}
{@html message.content}
{/await}
<div class="px-2">
{#await ((message.kind === 6 || message.kind === 16) ? parseRepostContent(message.content) : parseContent(message.content)) then parsedContent}
{@html parsedContent}
{:catch}
{@html message.content}
{/await}
</div>
</div>
{/if}
@ -874,14 +880,14 @@ @@ -874,14 +880,14 @@
<P>No notifications {notificationMode === "to-me" ? "received" : "sent"} found.</P>
</div>
{:else}
<div class="max-h-[72rem] overflow-y-auto space-y-4">
<div class="max-h-[72rem] overflow-y-auto overflow-x-hidden space-y-4">
{#each notifications.slice(0, 100) as notification}
{@const authorProfile = authorProfiles.get(notification.pubkey)}
<div class="p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:shadow-md transition-all">
<div class="message-container p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm">
<div class="flex items-start gap-3">
<!-- Author Profile Picture and Name -->
<div class="flex-shrink-0">
<div class="flex items-center gap-2">
<div class="flex flex-col items-center gap-2">
{#if authorProfile?.picture}
<img
src={authorProfile.picture}
@ -890,15 +896,17 @@ @@ -890,15 +896,17 @@
onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'}
/>
{:else}
<div class="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600">
<div class="profile-picture-fallback w-10 h-10 rounded-full flex items-center justify-center border border-gray-200 dark:border-gray-600">
<span class="text-sm font-medium text-gray-600 dark:text-gray-300">
{(authorProfile?.displayName || authorProfile?.name || notification.pubkey.slice(0, 1)).toUpperCase()}
</span>
</div>
{/if}
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{@render userBadge(notification.pubkey, authorProfile?.displayName || authorProfile?.name)}
</span>
<div class="w-24 text-center">
<span class="text-xs font-medium text-gray-900 dark:text-gray-100 break-words">
@{authorProfile?.displayName || authorProfile?.name || notification.pubkey.slice(0, 8) + "..." + notification.pubkey.slice(-4)}
</span>
</div>
</div>
</div>
@ -924,11 +932,13 @@ @@ -924,11 +932,13 @@
{#if notification.content}
<div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed">
{#await parseContent(notification.content) then parsedContent}
{@html parsedContent}
{:catch}
{@html truncateContent(notification.content)}
{/await}
<div class="px-2">
{#await ((notification.kind === 6 || notification.kind === 16) ? parseRepostContent(notification.content) : parseContent(notification.content)) then parsedContent}
{@html parsedContent}
{:catch}
{@html truncateContent(notification.content)}
{/await}
</div>
</div>
{/if}
@ -950,7 +960,7 @@ @@ -950,7 +960,7 @@
<!-- New Message Modal -->
<Modal bind:open={showNewMessageModal} size="lg" class="w-full">
<div class="p-6">
<div class="modal-content p-6">
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{replyToMessage ? 'Reply to Message' : 'New Public Message'}
@ -959,7 +969,7 @@ @@ -959,7 +969,7 @@
<!-- Quoted Content Display -->
{#if quotedContent}
<div class="mb-4 p-3 bg-gray-100 dark:bg-gray-800 border-l-4 border-gray-400 dark:border-gray-500 rounded-r-lg">
<div class="quoted-content mb-4 p-3 rounded-r-lg">
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Replying to:</div>
<div class="text-sm text-gray-800 dark:text-gray-200">
{#await parseContent(quotedContent) then parsedContent}
@ -990,7 +1000,7 @@ @@ -990,7 +1000,7 @@
</div>
{#if selectedRecipients.length === 0}
<div class="p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-lg">
<div class="recipient-selection p-3 rounded-lg">
<p class="text-sm text-yellow-700 dark:text-yellow-300">
No recipients selected. Click "Edit Recipients" to add recipients.
</p>
@ -999,7 +1009,7 @@ @@ -999,7 +1009,7 @@
<div class="flex flex-wrap gap-2">
{#each selectedRecipients as recipient}
<span class="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm">
{@render userBadge(recipient.pubkey!, recipient.displayName || recipient.name)}
@{recipient.displayName || recipient.name || recipient.pubkey?.slice(0, 8) + "..." + recipient.pubkey?.slice(-4) || "Unknown"}
<button
onclick={() => {
selectedRecipients = selectedRecipients.filter(r => r.pubkey !== recipient.pubkey);
@ -1041,10 +1051,11 @@ @@ -1041,10 +1051,11 @@
id="new-message-content"
bind:value={newMessageContent}
placeholder="Type your message here..."
class="w-full p-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
class="message-textarea w-full p-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 resize-none"
rows="6"
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !isComposingMessage && selectedRecipients.length > 0 && newMessageContent.trim()) {
// Allow Enter for new lines, Ctrl+Enter to send
if (e.key === 'Enter' && e.ctrlKey && !isComposingMessage && selectedRecipients.length > 0 && newMessageContent.trim()) {
e.preventDefault();
sendNewMessage();
}
@ -1065,7 +1076,7 @@ @@ -1065,7 +1076,7 @@
color="primary"
onclick={sendNewMessage}
disabled={isComposingMessage || selectedRecipients.length === 0 || !newMessageContent.trim()}
class="flex items-center gap-2"
class="flex items-center gap-2 {isComposingMessage || selectedRecipients.length === 0 || !newMessageContent.trim() ? 'button-disabled' : ''}"
>
{#if isComposingMessage}
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
@ -1078,7 +1089,7 @@ @@ -1078,7 +1089,7 @@
<!-- Recipient Selection Modal -->
<Modal bind:open={showRecipientModal} size="lg" class="w-full">
<div class="p-6">
<div class="modal-content p-6">
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Select Recipients</h3>
</div>
@ -1090,7 +1101,7 @@ @@ -1090,7 +1101,7 @@
placeholder="Search display name, name, NIP-05, or npub..."
bind:value={recipientSearch}
bind:this={recipientSearchInput}
class="w-full rounded-lg border border-gray-300 bg-gray-50 text-gray-900 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500 p-2.5 {recipientLoading ? 'pr-10' : ''}"
class="search-input w-full rounded-lg border border-gray-300 bg-gray-50 text-gray-900 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 p-2.5 {recipientLoading ? 'pr-10' : ''}"
/>
{#if recipientLoading}
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
@ -1100,14 +1111,18 @@ @@ -1100,14 +1111,18 @@
</div>
{#if recipientResults.length > 0}
<div class="max-h-64 overflow-y-auto">
<div class="recipient-results">
<ul class="space-y-2">
{#each recipientResults as profile}
{@const isAlreadySelected = selectedRecipients.some(r => r.pubkey === profile.pubkey)}
<button
onclick={() => selectRecipient(profile)}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
selectRecipient(profile);
}}
disabled={isAlreadySelected}
class="w-full flex items-center gap-3 p-3 text-left bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors {isAlreadySelected ? 'opacity-50 cursor-not-allowed' : ''}"
class="recipient-selection-button w-full flex items-center gap-3 p-3 text-left bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 {isAlreadySelected ? 'opacity-50 cursor-not-allowed' : ''}"
>
{#if profile.picture}
<img
@ -1125,7 +1140,7 @@ @@ -1125,7 +1140,7 @@
{/if}
<div class="flex flex-col text-left min-w-0 flex-1">
<span class="font-semibold truncate">
{@render userBadge(profile.pubkey!, profile.displayName || profile.name)}
@{profile.displayName || profile.name || profile.pubkey?.slice(0, 8) + "..." + profile.pubkey?.slice(-4) || "Unknown"}
</span>
{#if profile.nip05}
<span class="text-xs text-gray-500 flex items-center gap-1">
@ -1150,7 +1165,7 @@ @@ -1150,7 +1165,7 @@
</div>
{#if recipientCommunityStatus[profile.pubkey || ""]}
<div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
class="community-status-indicator flex-shrink-0 w-4 h-4 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<svg

2
src/lib/components/RelayActions.svelte

@ -73,7 +73,7 @@ @@ -73,7 +73,7 @@
</script>
<div class="mt-4 flex flex-wrap gap-2">
<Button on:click={openRelayModal} class="flex items-center">
<Button onclick={openRelayModal} class="flex items-center">
{@html searchIcon}
Where can I find this event?
</Button>

2
src/lib/components/ZettelEditor.svelte

@ -187,7 +187,7 @@ Note content here... @@ -187,7 +187,7 @@ Note content here...
<Button
color="light"
size="sm"
on:click={togglePreview}
onclick={togglePreview}
class="flex items-center space-x-1"
disabled={hasPublicationHeader}
>

2
src/lib/components/publications/Publication.svelte

@ -249,7 +249,7 @@ @@ -249,7 +249,7 @@
{#if isLoading}
<Button disabled color="primary">Loading...</Button>
{:else if !isDone}
<Button color="primary" on:click={() => loadMore(1)}>Show More</Button>
<Button color="primary" onclick={() => loadMore(1)}>Show More</Button>
{:else}
<p class="text-gray-500 dark:text-gray-400">
You've reached the end of the publication.

282
src/lib/components/publications/PublicationFeed.svelte

@ -7,15 +7,19 @@ @@ -7,15 +7,19 @@
import { onMount, onDestroy } from "svelte";
import {
getMatchingTags,
toNpub,
} from "$lib/utils/nostrUtils";
import { WebSocketPool } from "$lib/data_structures/websocket_pool";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { searchCache } from "$lib/utils/searchCache";
import { indexEventCache } from "$lib/utils/indexEventCache";
import { isValidNip05Address } from "$lib/utils/search_utility";
import { userStore } from "$lib/stores/userStore.ts";
import { nip19 } from "nostr-tools";
const props = $props<{
searchQuery?: string;
showOnlyMyPublications?: boolean;
onEventCountUpdate?: (counts: { displayed: number; total: number }) => void;
}>();
@ -27,6 +31,7 @@ @@ -27,6 +31,7 @@
let loading: boolean = $state(true);
let hasInitialized = $state(false);
let fallbackTimeout: ReturnType<typeof setTimeout> | null = null;
let gridContainer: HTMLElement;
// Relay management
let allRelays: string[] = $state([]);
@ -35,6 +40,42 @@ @@ -35,6 +40,42 @@
// Event management
let allIndexEvents: NDKEvent[] = $state([]);
// Calculate the number of columns based on window width
let columnCount = $state(1);
let publicationsToDisplay = $state(10);
// Update column count and publications when window resizes
$effect(() => {
if (typeof window !== 'undefined') {
const width = window.innerWidth;
let newColumnCount = 1;
if (width >= 1280) newColumnCount = 4; // xl:grid-cols-4
else if (width >= 1024) newColumnCount = 3; // lg:grid-cols-3
else if (width >= 768) newColumnCount = 2; // md:grid-cols-2
if (columnCount !== newColumnCount) {
columnCount = newColumnCount;
publicationsToDisplay = newColumnCount * 10;
// Update the view immediately when column count changes
if (allIndexEvents.length > 0) {
let source = allIndexEvents;
// Apply user filter first
source = filterEventsByUser(source);
// Then apply search filter if query exists
if (props.searchQuery?.trim()) {
source = filterEventsBySearch(source);
}
eventsInView = source.slice(0, publicationsToDisplay);
endOfFeed = eventsInView.length >= source.length;
}
}
}
});
// Initialize relays and fetch events
async function initializeAndFetch() {
if (!ndk) {
@ -56,6 +97,17 @@ @@ -56,6 +97,17 @@
if (newRelays.length === 0) {
console.debug('[PublicationFeed] No relays available, waiting...');
// Set up a retry mechanism when relays become available
const unsubscribe = activeInboxRelays.subscribe((relays) => {
if (relays.length > 0 && !hasInitialized) {
console.debug('[PublicationFeed] Relays now available, retrying initialization');
unsubscribe();
setTimeout(() => {
hasInitialized = true;
initializeAndFetch();
}, 1000);
}
});
return;
}
@ -121,8 +173,8 @@ @@ -121,8 +173,8 @@
`[PublicationFeed] Using cached index events (${cachedEvents.length} events)`,
);
allIndexEvents = cachedEvents;
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
eventsInView = allIndexEvents.slice(0, publicationsToDisplay);
endOfFeed = allIndexEvents.length <= publicationsToDisplay;
loading = false;
return;
}
@ -210,8 +262,8 @@ @@ -210,8 +262,8 @@
allIndexEvents.sort((a, b) => b.created_at! - a.created_at!);
// Update the view immediately with new events
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
eventsInView = allIndexEvents.slice(0, publicationsToDisplay);
endOfFeed = allIndexEvents.length <= publicationsToDisplay;
console.debug(`[PublicationFeed] Updated view with ${newEvents.length} new events from ${relay}, total: ${allIndexEvents.length}`);
}
@ -236,15 +288,109 @@ @@ -236,15 +288,109 @@
indexEventCache.set(allRelays, allIndexEvents);
// Final update to ensure we have the latest view
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
eventsInView = allIndexEvents.slice(0, publicationsToDisplay);
endOfFeed = allIndexEvents.length <= publicationsToDisplay;
loading = false;
}
// Function to convert various Nostr identifiers to npub using the utility function
const convertToNpub = (input: string): string | null => {
const result = toNpub(input);
if (!result) {
console.debug("[PublicationFeed] Failed to convert to npub:", input);
}
return result;
};
// Function to filter events by npub (author or p tags)
const filterEventsByNpub = (events: NDKEvent[], npub: string): NDKEvent[] => {
try {
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
console.debug("[PublicationFeed] Invalid npub format:", npub);
return events;
}
const pubkey = decoded.data.toLowerCase();
console.debug("[PublicationFeed] Filtering events for npub:", npub, "pubkey:", pubkey);
const filtered = events.filter((event) => {
// Check if user is the author of the event
const eventPubkey = event.pubkey.toLowerCase();
const isAuthor = eventPubkey === pubkey;
// Check if user is listed in "p" tags (participants/contributors)
const pTags = getMatchingTags(event, "p");
const isInPTags = pTags.some(tag => tag[1]?.toLowerCase() === pubkey);
const matches = isAuthor || isInPTags;
if (matches) {
console.debug("[PublicationFeed] Event matches npub filter:", {
id: event.id,
eventPubkey,
searchPubkey: pubkey,
isAuthor,
isInPTags,
pTags: pTags.map(tag => tag[1])
});
}
return matches;
});
console.debug("[PublicationFeed] Events after npub filtering:", filtered.length);
return filtered;
} catch (error) {
console.debug("[PublicationFeed] Error filtering by npub:", npub, error);
return events;
}
};
// Function to filter events by current user's pubkey
const filterEventsByUser = (events: NDKEvent[]) => {
if (!props.showOnlyMyPublications) return events;
const currentUser = $userStore;
if (!currentUser.signedIn || !currentUser.pubkey) {
console.debug("[PublicationFeed] User not signed in or no pubkey, showing all events");
return events;
}
const userPubkey = currentUser.pubkey.toLowerCase();
console.debug("[PublicationFeed] Filtering events for user:", userPubkey);
const filtered = events.filter((event) => {
// Check if user is the author of the event
const eventPubkey = event.pubkey.toLowerCase();
const isAuthor = eventPubkey === userPubkey;
// Check if user is listed in "p" tags (participants/contributors)
const pTags = getMatchingTags(event, "p");
const isInPTags = pTags.some(tag => tag[1]?.toLowerCase() === userPubkey);
const matches = isAuthor || isInPTags;
if (matches) {
console.debug("[PublicationFeed] Event matches user filter:", {
id: event.id,
eventPubkey,
userPubkey,
isAuthor,
isInPTags,
pTags: pTags.map(tag => tag[1])
});
}
return matches;
});
console.debug("[PublicationFeed] Events after user filtering:", filtered.length);
return filtered;
};
// Function to filter events based on search query
const filterEventsBySearch = (events: NDKEvent[]) => {
if (!props.searchQuery) return events;
const query = props.searchQuery.toLowerCase();
const query = props.searchQuery.trim();
console.debug(
"[PublicationFeed] Filtering events with query:",
query,
@ -261,6 +407,27 @@ @@ -261,6 +407,27 @@
return cachedResult.events;
}
// AI-NOTE: Check if the query is a Nostr identifier (npub, hex, nprofile)
const npub = convertToNpub(query);
if (npub) {
console.debug("[PublicationFeed] Query is a Nostr identifier, filtering by npub:", npub);
const filtered = filterEventsByNpub(events, npub);
// Cache the filtered results
const result = {
events: filtered,
secondOrder: [],
tTagEvents: [],
eventIds: new Set<string>(),
addresses: new Set<string>(),
searchType: "publication",
searchTerm: query,
};
searchCache.set("publication", query, result);
return filtered;
}
// Check if the query is a NIP-05 address
const isNip05Query = isValidNip05Address(query);
console.debug("[PublicationFeed] Is NIP-05 query:", isNip05Query);
@ -276,7 +443,7 @@ @@ -276,7 +443,7 @@
// For NIP-05 queries, only match against NIP-05 tags
if (isNip05Query) {
const matches = nip05 === query;
const matches = nip05 === query.toLowerCase();
if (matches) {
console.debug("[PublicationFeed] Event matches NIP-05 search:", {
id: event.id,
@ -288,11 +455,12 @@ @@ -288,11 +455,12 @@
}
// For regular queries, match against all fields
const queryLower = query.toLowerCase();
const matches =
title.includes(query) ||
authorName.includes(query) ||
authorPubkey.includes(query) ||
nip05.includes(query);
title.includes(queryLower) ||
authorName.includes(queryLower) ||
authorPubkey.includes(queryLower) ||
nip05.includes(queryLower);
if (matches) {
console.debug("[PublicationFeed] Event matches search:", {
id: event.id,
@ -323,21 +491,37 @@ @@ -323,21 +491,37 @@
// Debounced search function
const debouncedSearch = debounceAsync(async (query: string) => {
console.debug("[PublicationFeed] Search query changed:", query);
console.debug("[PublicationFeed] Search query or user filter changed:", query);
let filtered = allIndexEvents;
// Apply user filter first
filtered = filterEventsByUser(filtered);
// Then apply search filter if query exists
if (query && query.trim()) {
const filtered = filterEventsBySearch(allIndexEvents);
eventsInView = filtered.slice(0, 30);
endOfFeed = filtered.length <= 30;
} else {
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
filtered = filterEventsBySearch(filtered);
}
eventsInView = filtered.slice(0, publicationsToDisplay);
endOfFeed = filtered.length <= publicationsToDisplay;
}, 300);
// AI-NOTE: Watch for changes in search query and user filter
$effect(() => {
// Trigger search when either search query or user filter changes
// Also watch for changes in user store to update filter when user logs in/out
debouncedSearch(props.searchQuery);
});
// AI-NOTE: Watch for changes in the user filter checkbox
$effect(() => {
// Trigger filtering when the user filter checkbox changes
// Access both props to ensure the effect runs when either changes
const searchQuery = props.searchQuery;
const showOnlyMyPublications = props.showOnlyMyPublications;
debouncedSearch(searchQuery);
});
// Emit event count updates
$effect(() => {
if (props.onEventCountUpdate) {
@ -351,10 +535,17 @@ @@ -351,10 +535,17 @@
async function loadMorePublications() {
loadingMore = true;
const current = eventsInView.length;
let source = props.searchQuery.trim()
? filterEventsBySearch(allIndexEvents)
: allIndexEvents;
eventsInView = source.slice(0, current + 30);
let source = allIndexEvents;
// Apply user filter first
source = filterEventsByUser(source);
// Then apply search filter if query exists
if (props.searchQuery.trim()) {
source = filterEventsBySearch(source);
}
eventsInView = source.slice(0, current + publicationsToDisplay);
endOfFeed = eventsInView.length >= source.length;
loadingMore = false;
}
@ -388,14 +579,57 @@ @@ -388,14 +579,57 @@
cleanup();
});
onMount(async () => {
onMount(() => {
console.debug('[PublicationFeed] onMount called');
// The effect will handle fetching when relays become available
// Add window resize listener for responsive updates
const handleResize = () => {
if (typeof window !== 'undefined') {
const width = window.innerWidth;
let newColumnCount = 1;
if (width >= 1280) newColumnCount = 4; // xl:grid-cols-4
else if (width >= 1024) newColumnCount = 3; // lg:grid-cols-3
else if (width >= 768) newColumnCount = 2; // md:grid-cols-2
if (columnCount !== newColumnCount) {
columnCount = newColumnCount;
publicationsToDisplay = newColumnCount * 10;
// Update the view immediately when column count changes
if (allIndexEvents.length > 0) {
let source = allIndexEvents;
// Apply user filter first
source = filterEventsByUser(source);
// Then apply search filter if query exists
if (props.searchQuery?.trim()) {
source = filterEventsBySearch(source);
}
eventsInView = source.slice(0, publicationsToDisplay);
endOfFeed = eventsInView.length >= source.length;
}
}
}
};
window.addEventListener('resize', handleResize);
// Initial calculation
handleResize();
// Cleanup function
return () => {
window.removeEventListener('resize', handleResize);
};
});
</script>
<div class="flex flex-col space-y-4">
<div
bind:this={gridContainer}
class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 w-full"
>
{#if loading && eventsInView.length === 0}

2
src/lib/components/publications/PublicationHeader.svelte

@ -35,7 +35,7 @@ @@ -35,7 +35,7 @@
let title: string = $derived(event.getMatchingTags("title")[0]?.[1]);
let author: string = $derived(
event.getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
event.getMatchingTags("author")[0]?.[1] ?? "unknown",
);
let version: string = $derived(
event.getMatchingTags("version")[0]?.[1] ?? "1",

2
src/lib/components/util/Details.svelte

@ -62,7 +62,7 @@ @@ -62,7 +62,7 @@
<div class="flex flex-row justify-between items-center">
<!-- Index author badge -->
<P class="text-base font-normal"
>{@render userBadge(event.pubkey, author)}</P
>{@render userBadge(event.pubkey, undefined)}</P
>
<CardActions {event}></CardActions>
</div>

19
src/lib/components/util/Profile.svelte

@ -21,7 +21,7 @@ @@ -21,7 +21,7 @@
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { onMount } from "svelte";
import { getUserMetadata } from "$lib/utils/nostrUtils";
import { activeInboxRelays } from "$lib/ndk";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
let { pubkey, isNav = false } = $props<{ pubkey?: string, isNav?: boolean }>();
@ -187,6 +187,23 @@ @@ -187,6 +187,23 @@
try {
console.log("Refreshing profile for npub:", userState.npub);
// Check if we have relays available
const inboxRelays = get(activeInboxRelays);
const outboxRelays = get(activeOutboxRelays);
if (inboxRelays.length === 0 && outboxRelays.length === 0) {
console.log("Profile: No relays available, will retry when relays become available");
// Set up a retry mechanism when relays become available
const unsubscribe = activeInboxRelays.subscribe((relays) => {
if (relays.length > 0 && !isRefreshingProfile) {
console.log("Profile: Relays now available, retrying profile fetch");
unsubscribe();
setTimeout(() => refreshProfile(), 1000);
}
});
return;
}
// Try using NDK's built-in profile fetching first
const ndk = get(ndkInstance);
if (ndk && userState.ndkUser) {

138
src/lib/data_structures/publication_tree.ts

@ -2,6 +2,10 @@ import { Lazy } from "./lazy.ts"; @@ -2,6 +2,10 @@ import { Lazy } from "./lazy.ts";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk";
import { fetchEventById } from "../utils/websocket_utils.ts";
import { fetchEventWithFallback, NDKRelaySetFromNDK } from "../utils/nostrUtils.ts";
import { get } from "svelte/store";
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { searchRelays, secondaryRelays } from "../consts.ts";
enum PublicationTreeNodeType {
Branch,
@ -685,23 +689,107 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -685,23 +689,107 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!event) {
const [kind, pubkey, dTag] = address.split(":");
const fetchedEvent = await this.#ndk.fetchEvent({
// AI-NOTE: 2025-01-24 - Enhanced event fetching with comprehensive fallback
// First try to fetch using the enhanced fetchEventWithFallback function
// which includes search relay fallback logic
return fetchEventWithFallback(this.#ndk, {
kinds: [parseInt(kind)],
authors: [pubkey],
"#d": [dTag],
});
}, 5000) // 5 second timeout for publication events
.then(fetchedEvent => {
if (fetchedEvent) {
// Cache the event if found
this.#eventCache.set(address, fetchedEvent);
event = fetchedEvent;
}
// Cache the event if found
if (fetchedEvent) {
this.#eventCache.set(address, fetchedEvent);
event = fetchedEvent;
}
if (!event) {
console.warn(
`[PublicationTree] Event with address ${address} not found on primary relays, trying search relays.`,
);
// If still not found, try a more aggressive search using search relays
return this.#trySearchRelayFallback(address, kind, pubkey, dTag, parentNode);
}
return this.#buildNodeFromEvent(event, address, parentNode);
})
.catch(error => {
console.warn(`[PublicationTree] Error fetching event for address ${address}:`, error);
// Try search relay fallback even on error
return this.#trySearchRelayFallback(address, kind, pubkey, dTag, parentNode);
});
}
if (!event) {
console.debug(
`[PublicationTree] Event with address ${address} not found.`,
);
return Promise.resolve(this.#buildNodeFromEvent(event, address, parentNode));
}
/**
* AI-NOTE: 2025-01-24 - Aggressive search relay fallback for publication events
* This method tries to find events on search relays when they're not found on primary relays
*/
async #trySearchRelayFallback(
address: string,
kind: string,
pubkey: string,
dTag: string,
parentNode: PublicationTreeNode
): Promise<PublicationTreeNode> {
try {
console.log(`[PublicationTree] Trying search relay fallback for address: ${address}`);
// Get current relay configuration
const inboxRelays = get(activeInboxRelays);
const outboxRelays = get(activeOutboxRelays);
// Create a comprehensive relay set including search relays
const allRelays = [...inboxRelays, ...outboxRelays, ...searchRelays, ...secondaryRelays];
const uniqueRelays = [...new Set(allRelays)]; // Remove duplicates
console.log(`[PublicationTree] Trying ${uniqueRelays.length} relays for fallback search:`, uniqueRelays);
// Try each relay individually with a shorter timeout
for (const relay of uniqueRelays) {
try {
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], this.#ndk);
const fetchedEvent = await this.#ndk.fetchEvent({
kinds: [parseInt(kind)],
authors: [pubkey],
"#d": [dTag],
}, undefined, relaySet).withTimeout(3000); // 3 second timeout per relay
if (fetchedEvent) {
console.log(`[PublicationTree] Found event ${fetchedEvent.id} on search relay: ${relay}`);
// Cache the event
this.#eventCache.set(address, fetchedEvent);
this.#events.set(address, fetchedEvent);
return this.#buildNodeFromEvent(fetchedEvent, address, parentNode);
}
} catch (error) {
console.debug(`[PublicationTree] Failed to fetch from relay ${relay}:`, error);
continue; // Try next relay
}
}
// If we get here, the event was not found on any relay
console.warn(`[PublicationTree] Event with address ${address} not found on any relay after fallback search.`);
return {
type: PublicationTreeNodeType.Leaf,
status: PublicationTreeNodeStatus.Error,
address,
parent: parentNode,
children: [],
};
} catch (error) {
console.error(`[PublicationTree] Error in search relay fallback for ${address}:`, error);
return {
type: PublicationTreeNodeType.Leaf,
@ -711,7 +799,17 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -711,7 +799,17 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
children: [],
};
}
}
/**
* AI-NOTE: 2025-01-24 - Helper method to build a node from an event
* This extracts the common logic for building nodes from events
*/
#buildNodeFromEvent(
event: NDKEvent,
address: string,
parentNode: PublicationTreeNode
): PublicationTreeNode {
this.#events.set(address, event);
const childAddresses = event.tags
@ -754,14 +852,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -754,14 +852,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
});
const resolvedAddresses = await Promise.all(eTagPromises);
const validAddresses = resolvedAddresses.filter(addr => addr !== null) as string[];
console.debug(`[PublicationTree] Resolved ${validAddresses.length} valid addresses from e-tags:`, validAddresses);
if (validAddresses.length > 0) {
childAddresses.push(...validAddresses);
}
// Note: We can't await here since this is a synchronous method
// The e-tag resolution will happen when the children are processed
// For now, we'll add the e-tags as potential child addresses
const eTagAddresses = eTags.map(tag => tag[1]);
childAddresses.push(...eTagAddresses);
}
const node: PublicationTreeNode = {
@ -772,10 +867,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -772,10 +867,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
children: [],
};
// Add children asynchronously
const childPromises = childAddresses.map(address =>
this.addEventByAddress(address, event)
);
await Promise.all(childPromises);
Promise.all(childPromises).catch(error => {
console.warn(`[PublicationTree] Error adding children for ${address}:`, error);
});
this.#nodeResolvedObservers.forEach((observer) => observer(address));

36
src/lib/stores/userStore.ts

@ -288,14 +288,20 @@ export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser) { @@ -288,14 +288,20 @@ export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser) {
*/
export async function loginWithNpub(pubkeyOrNpub: string) {
const ndk = get(ndkInstance);
if (!ndk) throw new Error("NDK not initialized");
// Only clear previous login state after successful login
if (!ndk) {
throw new Error("NDK not initialized");
}
let hexPubkey: string;
if (pubkeyOrNpub.startsWith("npub")) {
if (pubkeyOrNpub.startsWith("npub1")) {
try {
hexPubkey = nip19.decode(pubkeyOrNpub).data as string;
const decoded = nip19.decode(pubkeyOrNpub);
if (decoded.type !== "npub") {
throw new Error("Invalid npub format");
}
hexPubkey = decoded.data;
} catch (e) {
console.error("Failed to decode hex pubkey from npub:", pubkeyOrNpub, e);
console.error("Failed to decode npub:", pubkeyOrNpub, e);
throw e;
}
} else {
@ -313,6 +319,18 @@ export async function loginWithNpub(pubkeyOrNpub: string) { @@ -313,6 +319,18 @@ export async function loginWithNpub(pubkeyOrNpub: string) {
const user = ndk.getUser({ npub });
let profile: NostrProfile | null = null;
// First, update relay stores to ensure we have relays available
try {
console.debug('[userStore.ts] loginWithNpub: Updating relay stores for authenticated user');
await updateActiveRelayStores(ndk);
} catch (error) {
console.warn('[userStore.ts] loginWithNpub: Failed to update relay stores:', error);
}
// Wait a moment for relay stores to be properly initialized
await new Promise(resolve => setTimeout(resolve, 500));
try {
profile = await getUserMetadata(npub, true); // Force fresh fetch
console.log("Login with npub - fetched profile:", profile);
@ -344,14 +362,6 @@ export async function loginWithNpub(pubkeyOrNpub: string) { @@ -344,14 +362,6 @@ export async function loginWithNpub(pubkeyOrNpub: string) {
userStore.set(userState);
userPubkey.set(user.pubkey);
// Update relay stores with the new user's relays
try {
console.debug('[userStore.ts] loginWithNpub: Updating relay stores for authenticated user');
await updateActiveRelayStores(ndk);
} catch (error) {
console.warn('[userStore.ts] loginWithNpub: Failed to update relay stores:', error);
}
clearLogin();
localStorage.removeItem("alexandria/logout/flag");
persistLogin(user, "npub");

13
src/lib/utils/markup/basicMarkupParser.ts

@ -295,10 +295,19 @@ function processBasicFormatting(content: string): string { @@ -295,10 +295,19 @@ function processBasicFormatting(content: string): string {
<div class="text-gray-600 font-medium">Image</div>
</div>
</div>
<img src="${clean}" alt="Embedded media" class="absolute inset-0 w-full h-full object-cover rounded-lg shadow-lg opacity-0 transition-opacity duration-300" loading="lazy" decoding="async" onload="this.style.opacity='0';">
<button class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30 text-white font-semibold rounded-lg hover:bg-opacity-40 transition-all duration-300" onclick="this.parentElement.querySelector('img').style.opacity='1'; this.style.display='none';">
<img src="${clean}" alt="Embedded media" class="absolute inset-0 w-full h-full object-contain rounded-lg shadow-lg opacity-0 transition-opacity duration-300" loading="lazy" decoding="async" onload="this.style.opacity='0';">
<button class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30 text-white font-semibold rounded-lg hover:bg-opacity-40 transition-all duration-300" onclick="const img = this.parentElement.querySelector('img'); const expandBtn = this.parentElement.querySelector('button[title]'); img.style.opacity='1'; this.style.display='none'; expandBtn.style.display='flex'; expandBtn.style.opacity='1'; expandBtn.style.pointerEvents='auto';">
Reveal Image
</button>
<!-- Expand button - initially hidden, shown after image is revealed -->
<button class="absolute top-2 right-2 bg-black bg-opacity-50 hover:bg-opacity-70 text-white rounded-full w-8 h-8 flex items-center justify-center transition-all duration-300 opacity-0 pointer-events-none"
onclick="window.open('${clean}', '_blank')"
title="Open image in full size"
style="display: none;">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
</button>
</div>`;
}
// Otherwise, render as a clickable link

27
src/lib/utils/nostrUtils.ts

@ -5,7 +5,7 @@ import { npubCache } from "./npubCache.ts"; @@ -5,7 +5,7 @@ import { npubCache } from "./npubCache.ts";
import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type { NDKKind, NostrEvent } from "@nostr-dev-kit/ndk";
import type { Filter } from "./search_types.ts";
import { communityRelays, secondaryRelays } from "../consts.ts";
import { communityRelays, secondaryRelays, searchRelays } from "../consts.ts";
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk";
import { sha256 } from "@noble/hashes/sha2.js";
@ -446,15 +446,17 @@ export async function fetchEventWithFallback( @@ -446,15 +446,17 @@ export async function fetchEventWithFallback(
// Use both inbox and outbox relays for better event discovery
const inboxRelays = get(activeInboxRelays);
const outboxRelays = get(activeOutboxRelays);
const allRelays = [...inboxRelays, ...outboxRelays];
let allRelays = [...inboxRelays, ...outboxRelays];
console.log("fetchEventWithFallback: Using inbox relays:", inboxRelays);
console.log("fetchEventWithFallback: Using outbox relays:", outboxRelays);
// Check if we have any relays available
if (allRelays.length === 0) {
console.warn("fetchEventWithFallback: No relays available for event fetch");
return null;
console.warn("fetchEventWithFallback: No relays available for event fetch, using fallback relays");
// Use fallback relays when no relays are available
allRelays = [...secondaryRelays, ...searchRelays];
console.log("fetchEventWithFallback: Using fallback relays:", allRelays);
}
// Create relay set from all available relays
@ -517,15 +519,28 @@ export async function fetchEventWithFallback( @@ -517,15 +519,28 @@ export async function fetchEventWithFallback(
}
/**
* Converts a hex pubkey to npub, or returns npub if already encoded.
* Converts various Nostr identifiers to npub format.
* Handles hex pubkeys, npub strings, and nprofile strings.
*/
export function toNpub(pubkey: string | undefined): string | null {
if (!pubkey) return null;
try {
// If it's already an npub, return it
if (pubkey.startsWith("npub")) return pubkey;
// If it's a hex pubkey, convert to npub
if (new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, "i").test(pubkey)) {
return nip19.npubEncode(pubkey);
}
if (pubkey.startsWith("npub1")) return pubkey;
// If it's an nprofile, decode and extract npub
if (pubkey.startsWith("nprofile")) {
const decoded = nip19.decode(pubkey);
if (decoded.type === 'nprofile') {
return decoded.data.pubkey ? nip19.npubEncode(decoded.data.pubkey) : null;
}
}
return null;
} catch {
return null;

86
src/lib/utils/notification_utils.ts

@ -4,9 +4,11 @@ import { getUserMetadata, NDKRelaySetFromNDK, toNpub } from "$lib/utils/nostrUti @@ -4,9 +4,11 @@ import { getUserMetadata, NDKRelaySetFromNDK, toNpub } from "$lib/utils/nostrUti
import { get } from "svelte/store";
import { ndkInstance } from "$lib/ndk";
import { searchRelays } from "$lib/consts";
import { userStore } from "$lib/stores/userStore";
import { userStore, type UserState } from "$lib/stores/userStore";
import { buildCompleteRelaySet } from "$lib/utils/relay_management";
import { neventEncode } from "$lib/utils";
import { nip19 } from "nostr-tools";
import type NDK from "@nostr-dev-kit/ndk";
// AI-NOTE: Notification-specific utility functions that don't exist elsewhere
@ -71,6 +73,48 @@ export async function parseContent(content: string): Promise<string> { @@ -71,6 +73,48 @@ export async function parseContent(content: string): Promise<string> {
return await parseBasicmarkup(content);
}
/**
* Parses repost content and renders it as an embedded event
*/
export async function parseRepostContent(content: string): Promise<string> {
if (!content) return "";
try {
// Try to parse the content as JSON (repost events contain the original event as JSON)
const originalEvent = JSON.parse(content);
// Extract the original event's content
const originalContent = originalEvent.content || "";
const originalAuthor = originalEvent.pubkey || "";
const originalCreatedAt = originalEvent.created_at || 0;
// Parse the original content with basic markup
const parsedOriginalContent = await parseBasicmarkup(originalContent);
// Create an embedded event display
const formattedDate = originalCreatedAt ? new Date(originalCreatedAt * 1000).toLocaleDateString() : "Unknown date";
const shortAuthor = originalAuthor ? `${originalAuthor.slice(0, 8)}...${originalAuthor.slice(-4)}` : "Unknown";
return `
<div class="embedded-repost bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 my-2">
<div class="flex items-center gap-2 mb-2 text-xs text-gray-600 dark:text-gray-400">
<span class="font-medium">Reposted by:</span>
<span class="font-mono">${shortAuthor}</span>
<span></span>
<span>${formattedDate}</span>
</div>
<div class="text-sm text-gray-800 dark:text-gray-200 leading-relaxed">
${parsedOriginalContent}
</div>
</div>
`;
} catch (error) {
// If JSON parsing fails, fall back to basic markup
console.warn("Failed to parse repost content as JSON, falling back to basic markup:", error);
return await parseBasicmarkup(content);
}
}
/**
* Renders quoted content for a message
*/
@ -82,18 +126,21 @@ export async function renderQuotedContent(message: NDKEvent, publicMessages: NDK @@ -82,18 +126,21 @@ export async function renderQuotedContent(message: NDKEvent, publicMessages: NDK
const eventId = qTag[1];
if (eventId) {
// Validate eventId format (should be 64 character hex string)
const isValidEventId = /^[a-fA-F0-9]{64}$/.test(eventId);
// First try to find in local messages
let quotedMessage = publicMessages.find(msg => msg.id === eventId);
// If not found locally, fetch from relays
if (!quotedMessage) {
try {
const ndk = get(ndkInstance);
const ndk: NDK | undefined = get(ndkInstance);
if (ndk) {
const userStoreValue = get(userStore);
const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null;
const relaySet = await buildCompleteRelaySet(ndk, user);
const allRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays, ...searchRelays];
const userStoreValue: UserState = get(userStore);
const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null;
const relaySet = await buildCompleteRelaySet(ndk, user);
const allRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays, ...searchRelays];
if (allRelays.length > 0) {
const ndkRelaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
@ -111,9 +158,20 @@ export async function renderQuotedContent(message: NDKEvent, publicMessages: NDK @@ -111,9 +158,20 @@ export async function renderQuotedContent(message: NDKEvent, publicMessages: NDK
const parsedContent = await parseBasicmarkup(quotedContent);
return `<div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm text-gray-600 dark:text-gray-300" onclick="window.dispatchEvent(new CustomEvent('jump-to-message', { detail: '${eventId}' }))">${parsedContent}</div>`;
} else {
// Fallback to nevent link
const nevent = neventEncode({ id: eventId } as any, []);
return `<div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm text-gray-600 dark:text-gray-300" onclick="window.location.href='/events?id=${nevent}'">Quoted message not found. Click to view event ${eventId.slice(0, 8)}...</div>`;
// Fallback to nevent link - only if eventId is valid
if (isValidEventId) {
try {
const nevent = nip19.neventEncode({ id: eventId });
return `<div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm text-gray-600 dark:text-gray-300" onclick="window.location.href='/events?id=${nevent}'">Quoted message not found. Click to view event ${eventId.slice(0, 8)}...</div>`;
} catch (error) {
console.warn(`[renderQuotedContent] Failed to encode nevent for ${eventId}:`, error);
// Fall back to just showing the event ID without a link
return `<div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded text-sm text-gray-600 dark:text-gray-300">Quoted message not found. Event ID: ${eventId.slice(0, 8)}...</div>`;
}
} else {
// Invalid event ID format
return `<div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded text-sm text-gray-600 dark:text-gray-300">Invalid quoted message reference</div>`;
}
}
}
@ -161,7 +219,7 @@ export async function fetchAuthorProfiles(events: NDKEvent[]): Promise<Map<strin @@ -161,7 +219,7 @@ export async function fetchAuthorProfiles(events: NDKEvent[]): Promise<Map<strin
// Try search relays
for (const relay of searchRelays) {
try {
const ndk = get(ndkInstance);
const ndk: NDK | undefined = get(ndkInstance);
if (!ndk) break;
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
@ -187,10 +245,10 @@ export async function fetchAuthorProfiles(events: NDKEvent[]): Promise<Map<strin @@ -187,10 +245,10 @@ export async function fetchAuthorProfiles(events: NDKEvent[]): Promise<Map<strin
// Try all available relays as fallback
try {
const ndk = get(ndkInstance);
const ndk: NDK | undefined = get(ndkInstance);
if (!ndk) return;
const userStoreValue = get(userStore);
const userStoreValue: UserState = get(userStore);
const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null;
const relaySet = await buildCompleteRelaySet(ndk, user);
const allRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays];
@ -216,10 +274,10 @@ export async function fetchAuthorProfiles(events: NDKEvent[]): Promise<Map<strin @@ -216,10 +274,10 @@ export async function fetchAuthorProfiles(events: NDKEvent[]): Promise<Map<strin
console.warn(`[fetchAuthorProfiles] Failed to fetch profile from all relays:`, error);
}
} catch (error) {
console.warn(`[fetchAuthorProfiles] Failed to fetch profile for ${pubkey}:`, error);
console.warn(`[fetchAuthorProfiles] Error processing profile for ${pubkey}:`, error);
}
});
await Promise.allSettled(profilePromises);
await Promise.all(profilePromises);
return authorProfiles;
}

10
src/lib/utils/websocket_utils.ts

@ -93,8 +93,16 @@ export async function fetchNostrEvent(filter: NostrFilter): Promise<NostrEvent | @@ -93,8 +93,16 @@ export async function fetchNostrEvent(filter: NostrFilter): Promise<NostrEvent |
}
}
// AI-NOTE: 2025-01-24 - Enhanced relay strategy for better event discovery
// Always include search relays in the relay set for comprehensive event discovery
const { searchRelays, secondaryRelays } = await import("../consts.ts");
const allRelays = [...availableRelays, ...searchRelays, ...secondaryRelays];
const uniqueRelays = [...new Set(allRelays)]; // Remove duplicates
console.debug(`[fetchNostrEvent] Trying ${uniqueRelays.length} relays for event discovery:`, uniqueRelays);
// Try all available relays in parallel and return the first result
const relayPromises = availableRelays.map(async (relay) => {
const relayPromises = uniqueRelays.map(async (relay) => {
try {
const ws = await WebSocketPool.instance.acquire(relay);
const subId = crypto.randomUUID();

85
src/routes/+page.svelte

@ -1,13 +1,54 @@ @@ -1,13 +1,54 @@
<script lang="ts">
import { Input } from "flowbite-svelte";
import { Input, Modal, Button } from "flowbite-svelte";
import PublicationFeed from "$lib/components/publications/PublicationFeed.svelte";
import { userStore } from "$lib/stores/userStore.ts";
let searchQuery = $state("");
let showOnlyMyPublications = $state(false);
let eventCount = $state({ displayed: 0, total: 0 });
let showClearSearchModal = $state(false);
let pendingCheckboxState = $state(false);
function handleEventCountUpdate(counts: { displayed: number; total: number }) {
eventCount = counts;
}
// Debug: Log state changes
$effect(() => {
console.log('showOnlyMyPublications changed to:', showOnlyMyPublications);
});
function handleCheckboxChange(event: Event) {
const target = event.target as HTMLInputElement;
const newState = target.checked;
// If checkbox is being checked and there's a search query, show confirmation
if (newState && searchQuery.trim()) {
pendingCheckboxState = newState;
showClearSearchModal = true;
// Revert the checkbox to its previous state
target.checked = showOnlyMyPublications;
return;
} else {
// If unchecking or no search query, proceed normally
showOnlyMyPublications = newState;
}
}
function confirmClearSearch() {
searchQuery = "";
showClearSearchModal = false;
// Force the state update by reassigning
showOnlyMyPublications = false;
showOnlyMyPublications = true;
}
function cancelClearSearch() {
// Don't change showOnlyMyPublications - it should remain as it was
showClearSearchModal = false;
}
// AI-NOTE: Removed automatic search clearing - now handled with confirmation dialog
</script>
<main class="leather flex flex-col flex-grow-0 space-y-4 p-4">
@ -22,13 +63,51 @@ @@ -22,13 +63,51 @@
</div>
{#if eventCount.total > 0}
<div class="text-center text-sm text-gray-600 dark:text-gray-400">
Showing {eventCount.displayed} of {eventCount.total} events.
<div class="flex items-center justify-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<span>Showing {eventCount.displayed} of {eventCount.total} events.</span>
<!-- AI-NOTE: Show filter checkbox only when user is logged in -->
{#if $userStore.signedIn}
<div class="flex items-center gap-2">
<input
type="checkbox"
checked={showOnlyMyPublications}
onchange={handleCheckboxChange}
id="show-my-publications"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
<label for="show-my-publications" class="text-sm cursor-pointer">
Show only my publications
</label>
</div>
{/if}
</div>
{/if}
<PublicationFeed
{searchQuery}
{showOnlyMyPublications}
onEventCountUpdate={handleEventCountUpdate}
/>
</main>
<!-- Confirmation Modal -->
<Modal bind:open={showClearSearchModal} size="md">
<div class="p-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">
Clear Search?
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Switching to "Show only my publications" will clear your current search.
Are you sure you want to continue?
</p>
<div class="flex justify-end gap-3">
<Button color="light" onclick={cancelClearSearch}>
Cancel
</Button>
<Button color="primary" onclick={confirmClearSearch}>
Clear Search
</Button>
</div>
</div>
</Modal>

4
src/routes/[...catchall]/+page.svelte

@ -11,13 +11,13 @@ @@ -11,13 +11,13 @@
>The page you are looking for does not exist or has been moved.</P
>
<div class="flex space-x-4">
<Button class="btn-leather !w-fit" on:click={() => goto("/")}
<Button class="btn-leather !w-fit" onclick={() => goto("/")}
>Return to Home</Button
>
<Button
class="btn-leather !w-fit"
outline
on:click={() => window.history.back()}>Go Back</Button
onclick={() => window.history.back()}>Go Back</Button
>
</div>
</div>

8
src/routes/my-notes/+page.svelte

@ -183,7 +183,7 @@ @@ -183,7 +183,7 @@
{selectedTagTypes.has(type)
? 'border-2 border-amber-800'
: 'border border-amber-200'}"
on:click={() => toggleTagType(type)}
onclick={() => toggleTagType(type)}
>
{#if type.length === 1}
<span class="text-amber-400 font-mono">{type}</span>
@ -200,7 +200,7 @@ @@ -200,7 +200,7 @@
{#if tagsToShow.length > 0}
<button
class="ml-2 px-3 py-1 rounded-full text-xs font-medium bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600"
on:click={clearTagFilter}
onclick={clearTagFilter}
disabled={tagFilter.size === 0}
>
Clear Tag Filter
@ -215,7 +215,7 @@ @@ -215,7 +215,7 @@
{tagFilter.has(tag)
? 'border-2 border-amber-800'
: 'border border-amber-200'}"
on:click={() => toggleTag(tag)}
onclick={() => toggleTag(tag)}
>
<span>{tag}</span>
</button>
@ -240,7 +240,7 @@ @@ -240,7 +240,7 @@
<div class="font-semibold text-lg truncate flex-1 mr-2">{getTitle(event)}</div>
<button
class="flex-shrink-0 px-2 py-1 text-xs rounded bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600"
on:click={() => toggleTags(event.id)}
onclick={() => toggleTags(event.id)}
aria-label="Show tags"
>
{showTags[event.id] ? "Hide Tags" : "Show Tags"}

2
src/routes/new/compose/+page.svelte

@ -146,7 +146,7 @@ @@ -146,7 +146,7 @@
<!-- Publish Button -->
<Button
on:click={handlePublish}
onclick={handlePublish}
disabled={isPublishing || !content.trim()}
class="w-full"
>

8
src/routes/new/edit/+page.svelte

@ -71,10 +71,10 @@ @@ -71,10 +71,10 @@
bind:value={editorText}
>
<Toolbar slot="header" embedded>
<ToolbarButton name="Preview" on:click={showPreview}>
<ToolbarButton name="Preview" onclick={showPreview}>
<EyeSolid class="w-6 h-6" />
</ToolbarButton>
<ToolbarButton name="Review" slot="end" on:click={prepareReview}>
<ToolbarButton name="Review" slot="end" onclick={prepareReview}>
<PaperPlaneOutline class="w=6 h-6 rotate-90" />
</ToolbarButton>
</Toolbar>
@ -87,10 +87,10 @@ @@ -87,10 +87,10 @@
<Toolbar
class="toolbar-leather rounded-b-none bg-gray-200 dark:bg-gray-800"
>
<ToolbarButton name="Edit" on:click={hidePreview}>
<ToolbarButton name="Edit" onclick={hidePreview}>
<CodeOutline class="w-6 h-6" />
</ToolbarButton>
<ToolbarButton name="Review" slot="end" on:click={prepareReview}>
<ToolbarButton name="Review" slot="end" onclick={prepareReview}>
<PaperPlaneOutline class="w=6 h-6 rotate-90" />
</ToolbarButton>
</Toolbar>

214
src/styles/notifications.css

@ -0,0 +1,214 @@ @@ -0,0 +1,214 @@
/* Notifications Component Styles */
/* Loading spinner animation */
.notifications-loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Message highlighting for jump-to functionality */
.message-highlight {
transition: all 0.2s ease-in-out;
}
.message-highlight.ring-2 {
box-shadow: 0 0 0 2px rgb(59 130 246);
}
/* Modal content styling */
.modal-content {
max-height: 80vh;
overflow-y: auto;
}
/* Recipient search results */
.recipient-results {
max-height: 16rem;
overflow-y: auto;
}
/* Message content area */
.message-content {
min-width: 0;
word-wrap: break-word;
}
/* Profile picture fallback */
.profile-picture-fallback {
background: linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%);
}
.dark .profile-picture-fallback {
background: linear-gradient(135deg, #4b5563 0%, #374151 100%);
}
/* Filter button states */
.filter-button-active {
background-color: rgb(107 114 128);
color: rgb(243 244 246);
}
.dark .filter-button-active {
background-color: rgb(107 114 128);
color: rgb(243 244 246);
}
/* Reply button hover states */
.reply-button:hover {
background-color: rgb(37 99 235);
}
.dark .reply-button:hover {
background-color: rgb(29 78 216);
}
/* Community status indicator */
.community-status-indicator {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
}
.dark .community-status-indicator {
background: linear-gradient(135deg, #78350f 0%, #92400e 100%);
}
/* Quoted content styling */
.quoted-content {
border-left: 4px solid rgb(156 163 175);
background-color: rgb(249 250 251);
}
.dark .quoted-content {
border-left-color: rgb(107 114 128);
background-color: rgb(31 41 55);
}
/* Recipient selection styling */
.recipient-selection {
background-color: rgb(243 244 246);
border: 1px solid rgb(229 231 235);
}
.dark .recipient-selection {
background-color: rgb(55 65 81);
border-color: rgb(75 85 99);
}
/* Message container hover effects */
.message-container {
transition: all 0.2s ease-in-out;
}
.message-container:hover {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.dark .message-container:hover {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.2);
}
/* Filter indicator styling */
.filter-indicator {
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
border: 1px solid rgb(147 197 253);
}
.dark .filter-indicator {
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 100%);
border-color: rgb(59 130 246);
}
/* Textarea focus states */
.message-textarea:focus {
outline: none;
border-color: transparent;
box-shadow: 0 0 0 2px rgb(59 130 246);
}
/* Button disabled states */
.button-disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Search input focus states */
.search-input:focus {
border-color: rgb(59 130 246);
box-shadow: 0 0 0 2px rgb(59 130 246 / 0.2);
}
.dark .search-input:focus {
border-color: rgb(59 130 246);
box-shadow: 0 0 0 2px rgb(59 130 246 / 0.3);
}
/* Transition utilities */
.transition-colors {
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, text-decoration-color 0.15s ease-in-out, fill 0.15s ease-in-out, stroke 0.15s ease-in-out;
}
.transition-all {
transition: all 0.15s ease-in-out;
}
/* Mode toggle button states */
.mode-toggle-button {
transition: all 0.15s ease-in-out;
}
.mode-toggle-button.active {
background-color: rgb(255 255 255);
color: rgb(17 24 39);
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
.dark .mode-toggle-button.active {
background-color: rgb(31 41 55);
color: rgb(243 244 246);
}
.mode-toggle-button.inactive {
color: rgb(55 65 81);
}
.dark .mode-toggle-button.inactive {
color: rgb(156 163 175);
}
.mode-toggle-button.inactive:hover {
color: rgb(17 24 39);
}
.dark .mode-toggle-button.inactive:hover {
color: rgb(243 244 246);
}
/* Filter button transitions */
.filter-button {
transition: all 0.15s ease-in-out;
}
/* Recipient selection button transitions */
.recipient-selection-button {
transition: all 0.15s ease-in-out;
}
.recipient-selection-button:hover {
background-color: rgb(249 250 251);
}
.dark .recipient-selection-button:hover {
background-color: rgb(55 65 81);
}
.recipient-selection-button:focus {
outline: none;
box-shadow: 0 0 0 2px rgb(59 130 246);
}
Loading…
Cancel
Save