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. 140
      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. 88
      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 @@
} }
}, },
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "4.0.3", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "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": { "engines": {
"node": ">= 14.16.0" "node": ">= 8.10.0"
}, },
"funding": { "funding": {
"url": "https://paulmillr.com/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": { "node_modules/cliui": {
@ -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": { "node_modules/eslint-scope": {
"version": "8.4.0", "version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
@ -5953,17 +5983,27 @@
} }
}, },
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "4.1.2", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": { "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": { "funding": {
"type": "individual", "url": "https://github.com/sponsors/jonschlinkert"
"url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/require-directory": { "node_modules/require-directory": {
@ -6471,6 +6511,36 @@
"typescript": ">=5.0.0" "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": { "node_modules/svelte-eslint-parser": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.3.1.tgz", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.3.1.tgz",
@ -6671,54 +6741,6 @@
"node": ">=14.0.0" "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": { "node_modules/tailwindcss/node_modules/postcss-load-config": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
@ -6767,30 +6789,6 @@
"node": ">=4" "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": { "node_modules/thenify": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@ -7376,13 +7374,15 @@
} }
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "1.10.2", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": { "engines": {
"node": ">= 6" "node": ">= 14.6"
} }
}, },
"node_modules/yargs": { "node_modules/yargs": {

16
src/lib/components/CommentBox.svelte

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

2
src/lib/components/EventDetails.svelte

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

113
src/lib/components/Notifications.svelte

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

2
src/lib/components/RelayActions.svelte

@ -73,7 +73,7 @@
</script> </script>
<div class="mt-4 flex flex-wrap gap-2"> <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} {@html searchIcon}
Where can I find this event? Where can I find this event?
</Button> </Button>

2
src/lib/components/ZettelEditor.svelte

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

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

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

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

@ -7,15 +7,19 @@
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { import {
getMatchingTags, getMatchingTags,
toNpub,
} from "$lib/utils/nostrUtils"; } from "$lib/utils/nostrUtils";
import { WebSocketPool } from "$lib/data_structures/websocket_pool"; import { WebSocketPool } from "$lib/data_structures/websocket_pool";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
import { searchCache } from "$lib/utils/searchCache"; import { searchCache } from "$lib/utils/searchCache";
import { indexEventCache } from "$lib/utils/indexEventCache"; import { indexEventCache } from "$lib/utils/indexEventCache";
import { isValidNip05Address } from "$lib/utils/search_utility"; import { isValidNip05Address } from "$lib/utils/search_utility";
import { userStore } from "$lib/stores/userStore.ts";
import { nip19 } from "nostr-tools";
const props = $props<{ const props = $props<{
searchQuery?: string; searchQuery?: string;
showOnlyMyPublications?: boolean;
onEventCountUpdate?: (counts: { displayed: number; total: number }) => void; onEventCountUpdate?: (counts: { displayed: number; total: number }) => void;
}>(); }>();
@ -27,6 +31,7 @@
let loading: boolean = $state(true); let loading: boolean = $state(true);
let hasInitialized = $state(false); let hasInitialized = $state(false);
let fallbackTimeout: ReturnType<typeof setTimeout> | null = null; let fallbackTimeout: ReturnType<typeof setTimeout> | null = null;
let gridContainer: HTMLElement;
// Relay management // Relay management
let allRelays: string[] = $state([]); let allRelays: string[] = $state([]);
@ -35,6 +40,42 @@
// Event management // Event management
let allIndexEvents: NDKEvent[] = $state([]); 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 // Initialize relays and fetch events
async function initializeAndFetch() { async function initializeAndFetch() {
if (!ndk) { if (!ndk) {
@ -56,6 +97,17 @@
if (newRelays.length === 0) { if (newRelays.length === 0) {
console.debug('[PublicationFeed] No relays available, waiting...'); 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; return;
} }
@ -121,8 +173,8 @@
`[PublicationFeed] Using cached index events (${cachedEvents.length} events)`, `[PublicationFeed] Using cached index events (${cachedEvents.length} events)`,
); );
allIndexEvents = cachedEvents; allIndexEvents = cachedEvents;
eventsInView = allIndexEvents.slice(0, 30); eventsInView = allIndexEvents.slice(0, publicationsToDisplay);
endOfFeed = allIndexEvents.length <= 30; endOfFeed = allIndexEvents.length <= publicationsToDisplay;
loading = false; loading = false;
return; return;
} }
@ -210,8 +262,8 @@
allIndexEvents.sort((a, b) => b.created_at! - a.created_at!); allIndexEvents.sort((a, b) => b.created_at! - a.created_at!);
// Update the view immediately with new events // Update the view immediately with new events
eventsInView = allIndexEvents.slice(0, 30); eventsInView = allIndexEvents.slice(0, publicationsToDisplay);
endOfFeed = allIndexEvents.length <= 30; endOfFeed = allIndexEvents.length <= publicationsToDisplay;
console.debug(`[PublicationFeed] Updated view with ${newEvents.length} new events from ${relay}, total: ${allIndexEvents.length}`); console.debug(`[PublicationFeed] Updated view with ${newEvents.length} new events from ${relay}, total: ${allIndexEvents.length}`);
} }
@ -236,15 +288,109 @@
indexEventCache.set(allRelays, allIndexEvents); indexEventCache.set(allRelays, allIndexEvents);
// Final update to ensure we have the latest view // Final update to ensure we have the latest view
eventsInView = allIndexEvents.slice(0, 30); eventsInView = allIndexEvents.slice(0, publicationsToDisplay);
endOfFeed = allIndexEvents.length <= 30; endOfFeed = allIndexEvents.length <= publicationsToDisplay;
loading = false; 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 // Function to filter events based on search query
const filterEventsBySearch = (events: NDKEvent[]) => { const filterEventsBySearch = (events: NDKEvent[]) => {
if (!props.searchQuery) return events; if (!props.searchQuery) return events;
const query = props.searchQuery.toLowerCase(); const query = props.searchQuery.trim();
console.debug( console.debug(
"[PublicationFeed] Filtering events with query:", "[PublicationFeed] Filtering events with query:",
query, query,
@ -261,6 +407,27 @@
return cachedResult.events; 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 // Check if the query is a NIP-05 address
const isNip05Query = isValidNip05Address(query); const isNip05Query = isValidNip05Address(query);
console.debug("[PublicationFeed] Is NIP-05 query:", isNip05Query); console.debug("[PublicationFeed] Is NIP-05 query:", isNip05Query);
@ -276,7 +443,7 @@
// For NIP-05 queries, only match against NIP-05 tags // For NIP-05 queries, only match against NIP-05 tags
if (isNip05Query) { if (isNip05Query) {
const matches = nip05 === query; const matches = nip05 === query.toLowerCase();
if (matches) { if (matches) {
console.debug("[PublicationFeed] Event matches NIP-05 search:", { console.debug("[PublicationFeed] Event matches NIP-05 search:", {
id: event.id, id: event.id,
@ -288,11 +455,12 @@
} }
// For regular queries, match against all fields // For regular queries, match against all fields
const queryLower = query.toLowerCase();
const matches = const matches =
title.includes(query) || title.includes(queryLower) ||
authorName.includes(query) || authorName.includes(queryLower) ||
authorPubkey.includes(query) || authorPubkey.includes(queryLower) ||
nip05.includes(query); nip05.includes(queryLower);
if (matches) { if (matches) {
console.debug("[PublicationFeed] Event matches search:", { console.debug("[PublicationFeed] Event matches search:", {
id: event.id, id: event.id,
@ -323,21 +491,37 @@
// Debounced search function // Debounced search function
const debouncedSearch = debounceAsync(async (query: string) => { 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()) { if (query && query.trim()) {
const filtered = filterEventsBySearch(allIndexEvents); filtered = filterEventsBySearch(filtered);
eventsInView = filtered.slice(0, 30);
endOfFeed = filtered.length <= 30;
} else {
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
} }
eventsInView = filtered.slice(0, publicationsToDisplay);
endOfFeed = filtered.length <= publicationsToDisplay;
}, 300); }, 300);
// AI-NOTE: Watch for changes in search query and user filter
$effect(() => { $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); 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 // Emit event count updates
$effect(() => { $effect(() => {
if (props.onEventCountUpdate) { if (props.onEventCountUpdate) {
@ -351,10 +535,17 @@
async function loadMorePublications() { async function loadMorePublications() {
loadingMore = true; loadingMore = true;
const current = eventsInView.length; const current = eventsInView.length;
let source = props.searchQuery.trim() let source = allIndexEvents;
? filterEventsBySearch(allIndexEvents)
: allIndexEvents; // Apply user filter first
eventsInView = source.slice(0, current + 30); 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; endOfFeed = eventsInView.length >= source.length;
loadingMore = false; loadingMore = false;
} }
@ -388,14 +579,57 @@
cleanup(); cleanup();
}); });
onMount(async () => { onMount(() => {
console.debug('[PublicationFeed] onMount called'); console.debug('[PublicationFeed] onMount called');
// The effect will handle fetching when relays become available // 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> </script>
<div class="flex flex-col space-y-4"> <div class="flex flex-col space-y-4">
<div <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" 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} {#if loading && eventsInView.length === 0}

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

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

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

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

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

@ -21,7 +21,7 @@
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { getUserMetadata } from "$lib/utils/nostrUtils"; 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 }>(); let { pubkey, isNav = false } = $props<{ pubkey?: string, isNav?: boolean }>();
@ -187,6 +187,23 @@
try { try {
console.log("Refreshing profile for npub:", userState.npub); 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 // Try using NDK's built-in profile fetching first
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (ndk && userState.ndkUser) { if (ndk && userState.ndkUser) {

140
src/lib/data_structures/publication_tree.ts

@ -2,6 +2,10 @@ import { Lazy } from "./lazy.ts";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk";
import { fetchEventById } from "../utils/websocket_utils.ts"; 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 { enum PublicationTreeNodeType {
Branch, Branch,
@ -685,24 +689,108 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!event) { if (!event) {
const [kind, pubkey, dTag] = address.split(":"); 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)], kinds: [parseInt(kind)],
authors: [pubkey], authors: [pubkey],
"#d": [dTag], "#d": [dTag],
}); }, 5000) // 5 second timeout for publication events
.then(fetchedEvent => {
// Cache the event if found if (fetchedEvent) {
if (fetchedEvent) { // Cache the event if found
this.#eventCache.set(address, fetchedEvent); this.#eventCache.set(address, fetchedEvent);
event = 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) { return Promise.resolve(this.#buildNodeFromEvent(event, address, parentNode));
console.debug( }
`[PublicationTree] Event with address ${address} not found.`,
);
/**
* 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 { return {
type: PublicationTreeNodeType.Leaf, type: PublicationTreeNodeType.Leaf,
status: PublicationTreeNodeStatus.Error, status: PublicationTreeNodeStatus.Error,
@ -711,7 +799,17 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
children: [], 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); this.#events.set(address, event);
const childAddresses = event.tags const childAddresses = event.tags
@ -754,14 +852,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
}); });
const resolvedAddresses = await Promise.all(eTagPromises); // Note: We can't await here since this is a synchronous method
const validAddresses = resolvedAddresses.filter(addr => addr !== null) as string[]; // The e-tag resolution will happen when the children are processed
// For now, we'll add the e-tags as potential child addresses
console.debug(`[PublicationTree] Resolved ${validAddresses.length} valid addresses from e-tags:`, validAddresses); const eTagAddresses = eTags.map(tag => tag[1]);
childAddresses.push(...eTagAddresses);
if (validAddresses.length > 0) {
childAddresses.push(...validAddresses);
}
} }
const node: PublicationTreeNode = { const node: PublicationTreeNode = {
@ -772,10 +867,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
children: [], children: [],
}; };
// Add children asynchronously
const childPromises = childAddresses.map(address => const childPromises = childAddresses.map(address =>
this.addEventByAddress(address, event) 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)); this.#nodeResolvedObservers.forEach((observer) => observer(address));

36
src/lib/stores/userStore.ts

@ -288,14 +288,20 @@ export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser) {
*/ */
export async function loginWithNpub(pubkeyOrNpub: string) { export async function loginWithNpub(pubkeyOrNpub: string) {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (!ndk) throw new Error("NDK not initialized"); if (!ndk) {
// Only clear previous login state after successful login throw new Error("NDK not initialized");
}
let hexPubkey: string; let hexPubkey: string;
if (pubkeyOrNpub.startsWith("npub")) { if (pubkeyOrNpub.startsWith("npub1")) {
try { 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) { } catch (e) {
console.error("Failed to decode hex pubkey from npub:", pubkeyOrNpub, e); console.error("Failed to decode npub:", pubkeyOrNpub, e);
throw e; throw e;
} }
} else { } else {
@ -313,6 +319,18 @@ export async function loginWithNpub(pubkeyOrNpub: string) {
const user = ndk.getUser({ npub }); const user = ndk.getUser({ npub });
let profile: NostrProfile | null = null; 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 { try {
profile = await getUserMetadata(npub, true); // Force fresh fetch profile = await getUserMetadata(npub, true); // Force fresh fetch
console.log("Login with npub - fetched profile:", profile); console.log("Login with npub - fetched profile:", profile);
@ -344,14 +362,6 @@ export async function loginWithNpub(pubkeyOrNpub: string) {
userStore.set(userState); userStore.set(userState);
userPubkey.set(user.pubkey); 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(); clearLogin();
localStorage.removeItem("alexandria/logout/flag"); localStorage.removeItem("alexandria/logout/flag");
persistLogin(user, "npub"); persistLogin(user, "npub");

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

@ -295,10 +295,19 @@ function processBasicFormatting(content: string): string {
<div class="text-gray-600 font-medium">Image</div> <div class="text-gray-600 font-medium">Image</div>
</div> </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';"> <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="this.parentElement.querySelector('img').style.opacity='1'; this.style.display='none';"> <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 Reveal Image
</button> </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>`; </div>`;
} }
// Otherwise, render as a clickable link // Otherwise, render as a clickable link

27
src/lib/utils/nostrUtils.ts

@ -5,7 +5,7 @@ import { npubCache } from "./npubCache.ts";
import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type { NDKKind, NostrEvent } from "@nostr-dev-kit/ndk"; import type { NDKKind, NostrEvent } from "@nostr-dev-kit/ndk";
import type { Filter } from "./search_types.ts"; 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 { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk"; import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk";
import { sha256 } from "@noble/hashes/sha2.js"; import { sha256 } from "@noble/hashes/sha2.js";
@ -446,15 +446,17 @@ export async function fetchEventWithFallback(
// Use both inbox and outbox relays for better event discovery // Use both inbox and outbox relays for better event discovery
const inboxRelays = get(activeInboxRelays); const inboxRelays = get(activeInboxRelays);
const outboxRelays = get(activeOutboxRelays); const outboxRelays = get(activeOutboxRelays);
const allRelays = [...inboxRelays, ...outboxRelays]; let allRelays = [...inboxRelays, ...outboxRelays];
console.log("fetchEventWithFallback: Using inbox relays:", inboxRelays); console.log("fetchEventWithFallback: Using inbox relays:", inboxRelays);
console.log("fetchEventWithFallback: Using outbox relays:", outboxRelays); console.log("fetchEventWithFallback: Using outbox relays:", outboxRelays);
// Check if we have any relays available // Check if we have any relays available
if (allRelays.length === 0) { if (allRelays.length === 0) {
console.warn("fetchEventWithFallback: No relays available for event fetch"); console.warn("fetchEventWithFallback: No relays available for event fetch, using fallback relays");
return null; // 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 // Create relay set from all available relays
@ -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 { export function toNpub(pubkey: string | undefined): string | null {
if (!pubkey) return null; if (!pubkey) return null;
try { 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)) { if (new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, "i").test(pubkey)) {
return nip19.npubEncode(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; return null;
} catch { } catch {
return null; return null;

88
src/lib/utils/notification_utils.ts

@ -4,9 +4,11 @@ import { getUserMetadata, NDKRelaySetFromNDK, toNpub } from "$lib/utils/nostrUti
import { get } from "svelte/store"; import { get } from "svelte/store";
import { ndkInstance } from "$lib/ndk"; import { ndkInstance } from "$lib/ndk";
import { searchRelays } from "$lib/consts"; 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 { buildCompleteRelaySet } from "$lib/utils/relay_management";
import { neventEncode } from "$lib/utils"; 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 // AI-NOTE: Notification-specific utility functions that don't exist elsewhere
@ -71,6 +73,48 @@ export async function parseContent(content: string): Promise<string> {
return await parseBasicmarkup(content); 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 * Renders quoted content for a message
*/ */
@ -82,19 +126,22 @@ export async function renderQuotedContent(message: NDKEvent, publicMessages: NDK
const eventId = qTag[1]; const eventId = qTag[1];
if (eventId) { 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 // First try to find in local messages
let quotedMessage = publicMessages.find(msg => msg.id === eventId); let quotedMessage = publicMessages.find(msg => msg.id === eventId);
// If not found locally, fetch from relays // If not found locally, fetch from relays
if (!quotedMessage) { if (!quotedMessage) {
try { try {
const ndk = get(ndkInstance); const ndk: NDK | undefined = get(ndkInstance);
if (ndk) { if (ndk) {
const userStoreValue = get(userStore); const userStoreValue: UserState = get(userStore);
const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null; const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null;
const relaySet = await buildCompleteRelaySet(ndk, user); const relaySet = await buildCompleteRelaySet(ndk, user);
const allRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays, ...searchRelays]; const allRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays, ...searchRelays];
if (allRelays.length > 0) { if (allRelays.length > 0) {
const ndkRelaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk); const ndkRelaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
const fetchedEvent = await ndk.fetchEvent({ ids: [eventId], limit: 1 }, undefined, ndkRelaySet); const fetchedEvent = await ndk.fetchEvent({ ids: [eventId], limit: 1 }, undefined, ndkRelaySet);
@ -111,9 +158,20 @@ export async function renderQuotedContent(message: NDKEvent, publicMessages: NDK
const parsedContent = await parseBasicmarkup(quotedContent); 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>`; 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 { } else {
// Fallback to nevent link // Fallback to nevent link - only if eventId is valid
const nevent = neventEncode({ id: eventId } as any, []); if (isValidEventId) {
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>`; 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
// Try search relays // Try search relays
for (const relay of searchRelays) { for (const relay of searchRelays) {
try { try {
const ndk = get(ndkInstance); const ndk: NDK | undefined = get(ndkInstance);
if (!ndk) break; if (!ndk) break;
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk); const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
@ -187,10 +245,10 @@ export async function fetchAuthorProfiles(events: NDKEvent[]): Promise<Map<strin
// Try all available relays as fallback // Try all available relays as fallback
try { try {
const ndk = get(ndkInstance); const ndk: NDK | undefined = get(ndkInstance);
if (!ndk) return; 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 user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null;
const relaySet = await buildCompleteRelaySet(ndk, user); const relaySet = await buildCompleteRelaySet(ndk, user);
const allRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays]; const allRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays];
@ -216,10 +274,10 @@ export async function fetchAuthorProfiles(events: NDKEvent[]): Promise<Map<strin
console.warn(`[fetchAuthorProfiles] Failed to fetch profile from all relays:`, error); console.warn(`[fetchAuthorProfiles] Failed to fetch profile from all relays:`, error);
} }
} catch (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; return authorProfiles;
} }

10
src/lib/utils/websocket_utils.ts

@ -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 // Try all available relays in parallel and return the first result
const relayPromises = availableRelays.map(async (relay) => { const relayPromises = uniqueRelays.map(async (relay) => {
try { try {
const ws = await WebSocketPool.instance.acquire(relay); const ws = await WebSocketPool.instance.acquire(relay);
const subId = crypto.randomUUID(); const subId = crypto.randomUUID();

85
src/routes/+page.svelte

@ -1,13 +1,54 @@
<script lang="ts"> <script lang="ts">
import { Input } from "flowbite-svelte"; import { Input, Modal, Button } from "flowbite-svelte";
import PublicationFeed from "$lib/components/publications/PublicationFeed.svelte"; import PublicationFeed from "$lib/components/publications/PublicationFeed.svelte";
import { userStore } from "$lib/stores/userStore.ts";
let searchQuery = $state(""); let searchQuery = $state("");
let showOnlyMyPublications = $state(false);
let eventCount = $state({ displayed: 0, total: 0 }); let eventCount = $state({ displayed: 0, total: 0 });
let showClearSearchModal = $state(false);
let pendingCheckboxState = $state(false);
function handleEventCountUpdate(counts: { displayed: number; total: number }) { function handleEventCountUpdate(counts: { displayed: number; total: number }) {
eventCount = counts; 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> </script>
<main class="leather flex flex-col flex-grow-0 space-y-4 p-4"> <main class="leather flex flex-col flex-grow-0 space-y-4 p-4">
@ -22,13 +63,51 @@
</div> </div>
{#if eventCount.total > 0} {#if eventCount.total > 0}
<div class="text-center text-sm text-gray-600 dark:text-gray-400"> <div class="flex items-center justify-center gap-4 text-sm text-gray-600 dark:text-gray-400">
Showing {eventCount.displayed} of {eventCount.total} events. <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> </div>
{/if} {/if}
<PublicationFeed <PublicationFeed
{searchQuery} {searchQuery}
{showOnlyMyPublications}
onEventCountUpdate={handleEventCountUpdate} onEventCountUpdate={handleEventCountUpdate}
/> />
</main> </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 @@
>The page you are looking for does not exist or has been moved.</P >The page you are looking for does not exist or has been moved.</P
> >
<div class="flex space-x-4"> <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 >Return to Home</Button
> >
<Button <Button
class="btn-leather !w-fit" class="btn-leather !w-fit"
outline outline
on:click={() => window.history.back()}>Go Back</Button onclick={() => window.history.back()}>Go Back</Button
> >
</div> </div>
</div> </div>

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

@ -183,7 +183,7 @@
{selectedTagTypes.has(type) {selectedTagTypes.has(type)
? 'border-2 border-amber-800' ? 'border-2 border-amber-800'
: 'border border-amber-200'}" : 'border border-amber-200'}"
on:click={() => toggleTagType(type)} onclick={() => toggleTagType(type)}
> >
{#if type.length === 1} {#if type.length === 1}
<span class="text-amber-400 font-mono">{type}</span> <span class="text-amber-400 font-mono">{type}</span>
@ -200,7 +200,7 @@
{#if tagsToShow.length > 0} {#if tagsToShow.length > 0}
<button <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" 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} disabled={tagFilter.size === 0}
> >
Clear Tag Filter Clear Tag Filter
@ -215,7 +215,7 @@
{tagFilter.has(tag) {tagFilter.has(tag)
? 'border-2 border-amber-800' ? 'border-2 border-amber-800'
: 'border border-amber-200'}" : 'border border-amber-200'}"
on:click={() => toggleTag(tag)} onclick={() => toggleTag(tag)}
> >
<span>{tag}</span> <span>{tag}</span>
</button> </button>
@ -240,7 +240,7 @@
<div class="font-semibold text-lg truncate flex-1 mr-2">{getTitle(event)}</div> <div class="font-semibold text-lg truncate flex-1 mr-2">{getTitle(event)}</div>
<button <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" 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" aria-label="Show tags"
> >
{showTags[event.id] ? "Hide Tags" : "Show Tags"} {showTags[event.id] ? "Hide Tags" : "Show Tags"}

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

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

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

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

214
src/styles/notifications.css

@ -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