Event JSON
{
"id": "525274d09a912662c78d38c8e0d3a50237840230d1b4a560b07f8ef1fc8b9b02",
"pubkey": "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194",
"created_at": 1713888484,
"kind": 1617,
"tags": [
[
"a",
"30617:a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d:gitworkshop",
"wss://relay.damus.io"
],
[
"r",
"f9cbcfb08098bf664cd5fd304f31dc0f2bdc31da"
],
[
"r",
"ef62256d050161fdfccafd2111005ab46c4f49dd"
],
[
"t",
"root"
],
[
"branch-name",
"stars"
],
[
"p",
"a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d"
],
[
"commit",
"ef62256d050161fdfccafd2111005ab46c4f49dd"
],
[
"parent-commit",
"961d1d8645078f4c4600b200e1d56048c25f2439"
],
[
"commit-pgp-sig",
""
],
[
"description",
"repo bookmarking\n"
],
[
"author",
"Alejandro Gómez",
"alejandrogomez@bitrefill.com",
"1713783278",
"120"
],
[
"committer",
"Alejandro Gómez",
"alejandrogomez@bitrefill.com",
"1713888426",
"120"
]
],
"content": "From ef62256d050161fdfccafd2111005ab46c4f49dd Mon Sep 17 00:00:00 2001\nFrom: Alejandro Gómez \u003calejandrogomez@bitrefill.com\u003e\nDate: Mon, 22 Apr 2024 12:54:38 +0200\nSubject: [PATCH] repo bookmarking\n\n---\n src/lib/components/AsyncButton.svelte | 21 +++++++++++++++++++++\n src/lib/components/repo/RepoDetails.svelte | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--\n src/lib/components/stars/icons.ts | 11 +++++++++++\n src/lib/components/stars/type.ts | 13 +++++++++++++\n src/lib/kinds.ts | 2 ++\n src/lib/promise.ts | 3 +++\n src/lib/stores/Issues.ts | 2 +-\n src/lib/stores/Proposal.ts | 2 +-\n src/lib/stores/Stargazers.ts | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\n src/lib/wrappers/RepoMenu.svelte | 33 ++++++++++++++++++++++++++++++++-\n src/lib/wrappers/RepoPageWrapper.svelte | 2 ++\n src/routes/repo/[repo_id]/stargazers/+page.svelte | 34 ++++++++++++++++++++++++++++++++++\n src/routes/repo/[repo_id]/stargazers/+page.ts | 5 +++++\n 13 files changed, 317 insertions(+), 5 deletions(-)\n create mode 100644 src/lib/components/AsyncButton.svelte\n create mode 100644 src/lib/components/stars/icons.ts\n create mode 100644 src/lib/components/stars/type.ts\n create mode 100644 src/lib/promise.ts\n create mode 100644 src/lib/stores/Stargazers.ts\n create mode 100644 src/routes/repo/[repo_id]/stargazers/+page.svelte\n create mode 100644 src/routes/repo/[repo_id]/stargazers/+page.ts\n\ndiff --git a/src/lib/components/AsyncButton.svelte b/src/lib/components/AsyncButton.svelte\nnew file mode 100644\nindex 0000000..a750834\n--- /dev/null\n+++ b/src/lib/components/AsyncButton.svelte\n@@ -0,0 +1,21 @@\n+\u003cscript lang=\"ts\"\u003e\n+ let isLoading = false\n+\n+ export let disabled: boolean | undefined = false\n+ export let onClick = async () =\u003e {}\n+\n+ async function onClickHandler(){\n+ isLoading = true;\n+ try {\n+ await onClick();\n+ } catch (error) {\n+ console.error(error);\n+ } finally {\n+ isLoading = false;\n+ }\n+ }\n+\u003c/script\u003e\n+\n+\u003cbutton class=\"bg-transparent hover:bg-gray-100 hover:border-transparent hover:text-gray-700 border border-gray-500 text-xs font-semibold py-1 px-1 rounded inline-flex items-center\" class:cursor-not-allowed={disabled || isLoading} class:cursor-progress={isLoading} disabled={disabled || isLoading ? 'true' : ''} on:click={onClickHandler}\u003e\n+ \u003cslot /\u003e\n+\u003c/button\u003e\ndiff --git a/src/lib/components/repo/RepoDetails.svelte b/src/lib/components/repo/RepoDetails.svelte\nindex 9be1f41..287ed68 100644\n--- a/src/lib/components/repo/RepoDetails.svelte\n+++ b/src/lib/components/repo/RepoDetails.svelte\n@@ -1,6 +1,14 @@\n \u003cscript lang=\"ts\"\u003e\n import UserHeader from '$lib/components/users/UserHeader.svelte'\n- import { NDKUser } from '@nostr-dev-kit/ndk'\n+ import AsyncButton from '$lib/components/AsyncButton.svelte'\n+ import { timeout } from '$lib/promise'\n+ import { star_icon_path } from '$lib/components/stars/icons'\n+ import { ndk } from '$lib/stores/ndk'\n+ import { stargazers } from '$lib/stores/Stargazers'\n+ import { getUserRelays, logged_in_user } from '$lib/stores/users'\n+ import { bookmarks_kind } from '$lib/kinds'\n+ import { NDKUser, NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk'\n+ import { nip19 } from 'nostr-tools'\n import { icons_misc } from '../icons'\n import { event_defaults } from './type'\n \n@@ -27,9 +35,91 @@\n let naddr_copied = false\n let git_url_copied: false | string = false\n let maintainer_copied: false | string = false\n+\n+ let ref: string | undefined = undefined;\n+ $: if (naddr) {\n+ const decoded = nip19.decode(naddr)\n+ if (decoded.type === \"naddr\") {\n+ const { kind, pubkey, identifier } = decoded.data;\n+ ref = `${kind}:${pubkey}:${identifier}`\n+ }\n+ }\n+\n+ let isStarred = false\n+ $: isStarred = $stargazers.events.some((e) =\u003e e.pubkey === $logged_in_user?.hexpubkey)\n+\n+ async function toggleStarred(){\n+ if (!$logged_in_user) {\n+ return\n+ }\n+ const user_relays = await getUserRelays($logged_in_user.hexpubkey)\n+ const relayUrls = [\n+ ...relays,\n+ ...(user_relays.ndk_relays\n+ ? user_relays.ndk_relays.writeRelayUrls\n+ : []),\n+ ]\n+ const relaySet = NDKRelaySet.fromRelayUrls(relayUrls, ndk)\n+\n+ if (isStarred) {\n+ let event = new NDKEvent(ndk);\n+ const oldEvent = $stargazers.events.find(e =\u003e e.pubkey === $logged_in_user.hexpubkey);\n+ event.kind = bookmarks_kind;\n+ event.content = oldEvent.content;\n+ event.tags = oldEvent.tags.filter(t =\u003e t[0] === 'a' \u0026\u0026 t[1] !== ref);\n+ try {\n+ await event.sign()\n+ } catch {\n+ alert('failed to sign event')\n+ }\n+ try {\n+ await event.publish(relaySet)\n+ stargazers.update((stars) =\u003e {\n+ return {\n+ ...stars,\n+ events: stars.events.filter(e =\u003e e.pubkey !== $logged_in_user.hexpubkey),\n+ }\n+ })\n+ } catch {\n+ alert('failed to publish event')\n+ }\n+ } else {\n+ const oldEvent = await Promise.race([\n+ ndk.fetchEvent({\n+ kinds: [bookmarks_kind],\n+ author: [$logged_in_user.hexpubkey],\n+ }, { groupable: false }, relaySet),\n+\ttimeout(2000),\n+ ])\n+ let event = new NDKEvent(ndk)\n+ event.kind = bookmarks_kind\n+ if (oldEvent) {\n+ event.tags = oldEvent.tags;\n+ }\n+ event.tags.push(['a', ref, relays.length ? relays[0] : ''])\n+ try {\n+ await event.sign()\n+ } catch {\n+ alert('failed to sign event')\n+ }\n+ try {\n+ await event.publish(relaySet)\n+ stargazers.update((stars) =\u003e {\n+ return {\n+ ...stars,\n+ events: stars.events.filter(e =\u003e e.pubkey !== $logged_in_user.hexpubkey).concat([event]),\n+ }\n+ })\n+ } catch {\n+ alert('failed to publish event')\n+ }\n+ }\n+ }\n \u003c/script\u003e\n \n-\u003cdiv class=\"prose w-full max-w-md\"\u003e\n+\u003cdiv class=\"w-full max-w-md\"\u003e\n+ \u003cdiv class=\"flex justify-between items-start\"\u003e\n+ \u003cdiv class=\"prose\"\u003e\n {#if name == identifier}\n {#if loading}\n \u003cdiv class=\"skeleton my-3 h-5 w-20\"\u003e\u003c/div\u003e\n@@ -66,6 +156,32 @@\n \u003cp class=\"my-2 break-words text-sm\"\u003e{identifier}\u003c/p\u003e\n {/if}\n {/if}\n+ \u003c/div\u003e\n+ {#if ref}\n+ \u003cAsyncButton disabled={$logged_in_user || !ref ? '' : 'true'} onClick={toggleStarred}\u003e\n+ \u003csvg\n+ xmlns=\"http://www.w3.org/2000/svg\"\n+ viewBox=\"0 0 16 16\"\n+ class={`w-4 h-4 mr-2 ${isStarred ? \"fill-yellow-500\" : \"fill-gray-400\"}`}\n+ \u003e\n+ {#if isStarred}\n+ {#each star_icon_path.filled as p}\n+ \u003cpath fill-rule=\"evenodd\" d={p} /\u003e\n+ {/each}\n+ {:else}\n+ {#each star_icon_path.outline as p}\n+ \u003cpath fill-rule=\"evenodd\" d={p} /\u003e\n+ {/each}\n+ {/if}\n+ \u003c/svg\u003e\n+ {#if isStarred}\n+ \u003cspan\u003eUnstar\u003c/span\u003e\n+ {:else}\n+ \u003cspan\u003eStar\u003c/span\u003e\n+ {/if}\n+ \u003c/AsyncButton\u003e\n+ {/if}\n+ \u003c/div\u003e\n {#if loading}\n \u003cdiv class=\"skeleton my-3 h-5 w-20\"\u003e\u003c/div\u003e\n \u003cdiv class=\"skeleton my-2 h-4\"\u003e\u003c/div\u003e\ndiff --git a/src/lib/components/stars/icons.ts b/src/lib/components/stars/icons.ts\nnew file mode 100644\nindex 0000000..73c465a\n--- /dev/null\n+++ b/src/lib/components/stars/icons.ts\n@@ -0,0 +1,11 @@\n+// icon are MIT licenced\n+export const star_icon_path = {\n+ // https://icon-sets.iconify.design/gravity-ui/star/\n+ outline: [\n+ \"m9.194 5l.351.873l.94.064l3.197.217l-2.46 2.055l-.722.603l.23.914l.782 3.108l-2.714-1.704L8 10.629l-.798.5l-2.714 1.705l.782-3.108l.23-.914l-.723-.603l-2.46-2.055l3.198-.217l.94-.064l.35-.874L8 2.025zm-7.723-.292l3.943-.268L6.886.773C7.29-.231 8.71-.231 9.114.773l1.472 3.667l3.943.268c1.08.073 1.518 1.424.688 2.118L12.185 9.36l.964 3.832c.264 1.05-.886 1.884-1.802 1.31L8 12.4l-3.347 2.101c-.916.575-2.066-.26-1.802-1.309l.964-3.832L.783 6.826c-.83-.694-.391-2.045.688-2.118\",\n+ ],\n+ // https://icon-sets.iconify.design/gravity-ui/star-fill/\n+ filled: [\n+ \"M6.886.773C7.29-.231 8.71-.231 9.114.773l1.472 3.667l3.943.268c1.08.073 1.518 1.424.688 2.118L12.185 9.36l.964 3.832c.264 1.05-.886 1.884-1.802 1.31L8 12.4l-3.347 2.101c-.916.575-2.066-.26-1.802-1.309l.964-3.832L.783 6.826c-.83-.694-.391-2.045.688-2.118l3.943-.268z\",\n+ ],\n+}\ndiff --git a/src/lib/components/stars/type.ts b/src/lib/components/stars/type.ts\nnew file mode 100644\nindex 0000000..f076086\n--- /dev/null\n+++ b/src/lib/components/stars/type.ts\n@@ -0,0 +1,13 @@\n+import type { NDKEvent } from '@nostr-dev-kit/ndk'\n+\n+export interface Stargazers {\n+ id: string | undefined\n+ events: NDKEvent[]\n+ loading: boolean;\n+}\n+\n+export const stars_defaults: Stargazers = {\n+ id: '',\n+ events: [],\n+ loading: true,\n+}\ndiff --git a/src/lib/kinds.ts b/src/lib/kinds.ts\nindex fd65cf2..06cd21b 100644\n--- a/src/lib/kinds.ts\n+++ b/src/lib/kinds.ts\n@@ -27,3 +27,5 @@ export const repo_kind: number = 30617\n export const patch_kind: number = 1617\n \n export const issue_kind: number = 1621\n+\n+export const bookmarks_kind: number = 10617\ndiff --git a/src/lib/promise.ts b/src/lib/promise.ts\nnew file mode 100644\nindex 0000000..1577059\n--- /dev/null\n+++ b/src/lib/promise.ts\n@@ -0,0 +1,3 @@\n+export function timeout(ms: number) {\n+ return new Promise((resolve) =\u003e setTimeout(resolve, ms))\n+}\ndiff --git a/src/lib/stores/Issues.ts b/src/lib/stores/Issues.ts\nindex 23b6023..ba229fb 100644\n--- a/src/lib/stores/Issues.ts\n+++ b/src/lib/stores/Issues.ts\n@@ -34,7 +34,7 @@ let selected_repo_id: string | undefined = ''\n let sub: NDKSubscription\n \n export const ensureIssueSummaries = async (repo_id: string | undefined) =\u003e {\n- if (selected_repo_id == repo_id) return\n+ if (selected_repo_id === repo_id) return\n issue_summaries.set({\n id: repo_id,\n summaries: [],\ndiff --git a/src/lib/stores/Proposal.ts b/src/lib/stores/Proposal.ts\nindex c92d5f7..7aad603 100644\n--- a/src/lib/stores/Proposal.ts\n+++ b/src/lib/stores/Proposal.ts\n@@ -109,7 +109,7 @@ export const ensureProposalFull = (\n created_at: event.created_at,\n comments: 0,\n author: {\n- hexpubkey: event.pubkey,\n+ pubkey: event.pubkey,\n loading: true,\n npub: '',\n },\ndiff --git a/src/lib/stores/Stargazers.ts b/src/lib/stores/Stargazers.ts\nnew file mode 100644\nindex 0000000..fbf5a11\n--- /dev/null\n+++ b/src/lib/stores/Stargazers.ts\n@@ -0,0 +1,74 @@\n+import {\n+ NDKRelaySet,\n+ type NDKEvent,\n+ NDKSubscription,\n+ type NDKFilter,\n+} from '@nostr-dev-kit/ndk'\n+import { writable, type Writable } from 'svelte/store'\n+import { awaitSelectedRepoCollection } from './repo'\n+import { selectRepoFromCollection } from '$lib/components/repo/utils'\n+import { base_relays, ndk } from './ndk'\n+import { repo_kind, bookmarks_kind } from '$lib/kinds'\n+import { stars_defaults, type Stargazers } from '$lib/components/stars/type'\n+\n+export const stargazers: Writable\u003cStargazers\u003e = writable(stars_defaults)\n+\n+let selected_repo_id: string | undefined = ''\n+\n+let sub: NDKSubscription\n+\n+export async function fetchStargazers(repo_id: string | undefined) {\n+ if (selected_repo_id === repo_id) return\n+ selected_repo_id = repo_id;\n+ stargazers.set({\n+ id: repo_id,\n+ events: [],\n+ loading: true,\n+ })\n+ if (sub) sub.stop()\n+ if (repo_id) {\n+ const repo_collection = await awaitSelectedRepoCollection(repo_id)\n+ const repo = selectRepoFromCollection(repo_collection)\n+ if (!repo) {\n+ // TODO: display error info bar\n+ return\n+ }\n+ const relays_to_use =\n+ repo.relays.length \u003e 3\n+ ? repo.relays\n+ : [...base_relays].concat(repo.relays)\n+\n+ // todo: relays usually return max 500 results, if a repo is very popular, we may need to paginate\n+ const filter = {\n+ kinds: [bookmarks_kind],\n+ '#a': repo.maintainers.map((m) =\u003e `${repo_kind}:${m}:${repo.identifier}`),\n+ }\n+\n+ sub = ndk.subscribe(\n+ filter,\n+ {\n+ closeOnEose: false,\n+ },\n+ NDKRelaySet.fromRelayUrls(relays_to_use, ndk)\n+ )\n+\n+ sub.on('event', (event: NDKEvent) =\u003e {\n+ stargazers.update((stars) =\u003e {\n+ return {\n+ ...stars,\n+ events: stars.events.concat([event]),\n+ loading: false,\n+ }\n+ })\n+ })\n+\n+ sub.on('eose', () =\u003e {\n+ stargazers.update((stars) =\u003e {\n+ return {\n+ ...stars,\n+ loading: false,\n+ }\n+ })\n+ })\n+ }\n+}\ndiff --git a/src/lib/wrappers/RepoMenu.svelte b/src/lib/wrappers/RepoMenu.svelte\nindex a5958be..df9e477 100644\n--- a/src/lib/wrappers/RepoMenu.svelte\n+++ b/src/lib/wrappers/RepoMenu.svelte\n@@ -1,17 +1,23 @@\n \u003cscript lang=\"ts\"\u003e\n import { issue_icon_path } from '$lib/components/issues/icons'\n+ import { star_icon_path } from '$lib/components/stars/icons'\n import { proposal_icon_path } from '$lib/components/proposals/icons'\n import type { RepoPage } from '$lib/components/repo/type'\n import { proposal_status_open } from '$lib/kinds'\n import { issue_summaries } from '$lib/stores/Issues'\n+ import { stargazers } from '$lib/stores/Stargazers'\n+ import { logged_in_user } from '$lib/stores/users'\n import { proposal_summaries } from '$lib/stores/Proposals'\n import { selected_repo_readme } from '$lib/stores/repo'\n \n export let selected_tab: RepoPage = 'about'\n export let identifier = ''\n+\n+ let isStarred = false\n+ $: isStarred = $stargazers.events.some((e) =\u003e e.pubkey === $logged_in_user?.hexpubkey)\n \u003c/script\u003e\n \n-\u003cdiv class=\"flex border-b border-base-400\"\u003e\n+\u003cdiv class=\"flex border-b border-base-400 overflow-x-auto\"\u003e\n \u003cdiv role=\"tablist\" class=\"tabs tabs-bordered flex-none\"\u003e\n {#if !$selected_repo_readme.failed}\n \u003ca\n@@ -66,6 +72,31 @@\n \u003c/span\u003e\n {/if}\n \u003c/a\u003e\n+ \u003ca\n+ href={`/repo/${identifier}/stargazers`}\n+ class=\"tab\"\n+ class:tab-active={selected_tab === 'stargazers'}\n+ \u003e\n+ \u003csvg\n+ xmlns=\"http://www.w3.org/2000/svg\"\n+ viewBox=\"0 0 16 16\"\n+ class={`mb-1 mr-1 h-4 w-4 flex-none fill-base-content pt-1 ${isStarred ? \"fill-yellow-500\" : \"opacity-50\"}`}\n+ \u003e\n+\t{#if isStarred}\n+\t {#each star_icon_path.filled as p}\n+\t \u003cpath fill-rule=\"evenodd\" d={p} /\u003e\n+\t {/each}\n+\t{:else}\n+ {#each star_icon_path.outline as p}\n+ \u003cpath fill-rule=\"evenodd\" d={p} /\u003e\n+ {/each}\n+\t{/if}\n+ \u003c/svg\u003e\n+ Stars\n+ \u003cspan class=\"badge badge-neutral badge-sm ml-2\"\u003e\n+ {$stargazers.events.length}\n+ \u003c/span\u003e\n+ \u003c/a\u003e\n \u003c/div\u003e\n \u003cdiv class=\"flex-grow\"\u003e\u003c/div\u003e\n \u003c/div\u003e\ndiff --git a/src/lib/wrappers/RepoPageWrapper.svelte b/src/lib/wrappers/RepoPageWrapper.svelte\nindex 76107b8..d9618cd 100644\n--- a/src/lib/wrappers/RepoPageWrapper.svelte\n+++ b/src/lib/wrappers/RepoPageWrapper.svelte\n@@ -9,6 +9,7 @@\n import Container from '$lib/components/Container.svelte'\n import { ensureProposalSummaries } from '$lib/stores/Proposals'\n import { ensureIssueSummaries } from '$lib/stores/Issues'\n+ import { fetchStargazers } from '$lib/stores/Stargazers'\n import type { RepoPage } from '$lib/components/repo/type'\n \n export let identifier = ''\n@@ -18,6 +19,7 @@\n ensureSelectedRepoCollection(identifier)\n ensureProposalSummaries(identifier)\n ensureIssueSummaries(identifier)\n+ fetchStargazers(identifier)\n \n let repo_error = false\n \ndiff --git a/src/routes/repo/[repo_id]/stargazers/+page.svelte b/src/routes/repo/[repo_id]/stargazers/+page.svelte\nnew file mode 100644\nindex 0000000..e77188d\n--- /dev/null\n+++ b/src/routes/repo/[repo_id]/stargazers/+page.svelte\n@@ -0,0 +1,34 @@\n+\u003cscript lang=\"ts\"\u003e\n+ import UserHeader from '$lib/components/users/UserHeader.svelte'\n+ import type { IssueSummary } from '$lib/components/issues/type'\n+ import {\n+ proposal_status_applied,\n+ proposal_status_closed,\n+ proposal_status_open,\n+ statusKindtoText,\n+ } from '$lib/kinds'\n+ import { stargazers } from '$lib/stores/Stargazers'\n+ import RepoPageWrapper from '$lib/wrappers/RepoPageWrapper.svelte'\n+\n+ export let data: { repo_id: string }\n+ let identifier = data.repo_id\n+ let status: number = proposal_status_open\n+\u003c/script\u003e\n+\n+\u003cRepoPageWrapper {identifier} selected_tab=\"stargazers\"\u003e\n+ {#if !$stargazers.loading }\n+ \u003cdiv class=\"mt-2 border border-base-400 p-2\"\u003e\n+ {#if $stargazers.events.length === 0}\n+ \u003cdiv class=\"py-10 text-center lowercase\"\u003e\n+ there aren't any stargazers yet\n+ \u003c/div\u003e\n+ {:else}\n+ \u003cdiv class=\"flex flex-col gap-2\"\u003e\n+ {#each $stargazers.events as event}\n+ \u003cUserHeader user={event.pubkey} inline={true} size=\"md\" /\u003e\n+ {/each}\n+ \u003c/div\u003e\n+ {/if}\n+ \u003c/div\u003e\n+ {/if}\n+\u003c/RepoPageWrapper\u003e\ndiff --git a/src/routes/repo/[repo_id]/stargazers/+page.ts b/src/routes/repo/[repo_id]/stargazers/+page.ts\nnew file mode 100644\nindex 0000000..c70bf13\n--- /dev/null\n+++ b/src/routes/repo/[repo_id]/stargazers/+page.ts\n@@ -0,0 +1,5 @@\n+export const load = ({ params }: { params: { repo_id: string } }) =\u003e {\n+ return {\n+ repo_id: decodeURIComponent(params.repo_id),\n+ }\n+}\n--\nlibgit2 1.7.2\n\n",
"sig": "5f8cd7a23c7fb612f5bd8dfbe9301c3909e859a9853ca076c2d1e3c754e2bf75723e6fdcca2dd8dd514902bbdc9e28db87b52ccc6cb58731cb6cbfe2c5c81879"
}