From ef62256d050161fdfccafd2111005ab46c4f49dd Mon Sep 17 00:00:00 2001 From: Alejandro Gómez <alejandrogomez@bitrefill.com> Date: Mon, 22 Apr 2024 12:54:38 +0200 Subject: [PATCH] repo bookmarking --- src/lib/components/AsyncButton.svelte | 21 +++++++++++++++++++++ src/lib/components/repo/RepoDetails.svelte | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- src/lib/components/stars/icons.ts | 11 +++++++++++ src/lib/components/stars/type.ts | 13 +++++++++++++ src/lib/kinds.ts | 2 ++ src/lib/promise.ts | 3 +++ src/lib/stores/Issues.ts | 2 +- src/lib/stores/Proposal.ts | 2 +- src/lib/stores/Stargazers.ts | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib/wrappers/RepoMenu.svelte | 33 ++++++++++++++++++++++++++++++++- src/lib/wrappers/RepoPageWrapper.svelte | 2 ++ src/routes/repo/[repo_id]/stargazers/+page.svelte | 34 ++++++++++++++++++++++++++++++++++ src/routes/repo/[repo_id]/stargazers/+page.ts | 5 +++++ 13 files changed, 317 insertions(+), 5 deletions(-) create mode 100644 src/lib/components/AsyncButton.svelte create mode 100644 src/lib/components/stars/icons.ts create mode 100644 src/lib/components/stars/type.ts create mode 100644 src/lib/promise.ts create mode 100644 src/lib/stores/Stargazers.ts create mode 100644 src/routes/repo/[repo_id]/stargazers/+page.svelte create mode 100644 src/routes/repo/[repo_id]/stargazers/+page.ts diff --git a/src/lib/components/AsyncButton.svelte b/src/lib/components/AsyncButton.svelte new file mode 100644 index 0000000..a750834 --- /dev/null +++ b/src/lib/components/AsyncButton.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + let isLoading = false + + export let disabled: boolean | undefined = false + export let onClick = async () => {} + + async function onClickHandler(){ + isLoading = true; + try { + await onClick(); + } catch (error) { + console.error(error); + } finally { + isLoading = false; + } + } +</script> + +<button 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}> + <slot /> +</button> diff --git a/src/lib/components/repo/RepoDetails.svelte b/src/lib/components/repo/RepoDetails.svelte index 9be1f41..287ed68 100644 --- a/src/lib/components/repo/RepoDetails.svelte +++ b/src/lib/components/repo/RepoDetails.svelte @@ -1,6 +1,14 @@ <script lang="ts"> import UserHeader from '$lib/components/users/UserHeader.svelte' - import { NDKUser } from '@nostr-dev-kit/ndk' + import AsyncButton from '$lib/components/AsyncButton.svelte' + import { timeout } from '$lib/promise' + import { star_icon_path } from '$lib/components/stars/icons' + import { ndk } from '$lib/stores/ndk' + import { stargazers } from '$lib/stores/Stargazers' + import { getUserRelays, logged_in_user } from '$lib/stores/users' + import { bookmarks_kind } from '$lib/kinds' + import { NDKUser, NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk' + import { nip19 } from 'nostr-tools' import { icons_misc } from '../icons' import { event_defaults } from './type' @@ -27,9 +35,91 @@ let naddr_copied = false let git_url_copied: false | string = false let maintainer_copied: false | string = false + + let ref: string | undefined = undefined; + $: if (naddr) { + const decoded = nip19.decode(naddr) + if (decoded.type === "naddr") { + const { kind, pubkey, identifier } = decoded.data; + ref = `${kind}:${pubkey}:${identifier}` + } + } + + let isStarred = false + $: isStarred = $stargazers.events.some((e) => e.pubkey === $logged_in_user?.hexpubkey) + + async function toggleStarred(){ + if (!$logged_in_user) { + return + } + const user_relays = await getUserRelays($logged_in_user.hexpubkey) + const relayUrls = [ + ...relays, + ...(user_relays.ndk_relays + ? user_relays.ndk_relays.writeRelayUrls + : []), + ] + const relaySet = NDKRelaySet.fromRelayUrls(relayUrls, ndk) + + if (isStarred) { + let event = new NDKEvent(ndk); + const oldEvent = $stargazers.events.find(e => e.pubkey === $logged_in_user.hexpubkey); + event.kind = bookmarks_kind; + event.content = oldEvent.content; + event.tags = oldEvent.tags.filter(t => t[0] === 'a' && t[1] !== ref); + try { + await event.sign() + } catch { + alert('failed to sign event') + } + try { + await event.publish(relaySet) + stargazers.update((stars) => { + return { + ...stars, + events: stars.events.filter(e => e.pubkey !== $logged_in_user.hexpubkey), + } + }) + } catch { + alert('failed to publish event') + } + } else { + const oldEvent = await Promise.race([ + ndk.fetchEvent({ + kinds: [bookmarks_kind], + author: [$logged_in_user.hexpubkey], + }, { groupable: false }, relaySet), + timeout(2000), + ]) + let event = new NDKEvent(ndk) + event.kind = bookmarks_kind + if (oldEvent) { + event.tags = oldEvent.tags; + } + event.tags.push(['a', ref, relays.length ? relays[0] : '']) + try { + await event.sign() + } catch { + alert('failed to sign event') + } + try { + await event.publish(relaySet) + stargazers.update((stars) => { + return { + ...stars, + events: stars.events.filter(e => e.pubkey !== $logged_in_user.hexpubkey).concat([event]), + } + }) + } catch { + alert('failed to publish event') + } + } + } </script> -<div class="prose w-full max-w-md"> +<div class="w-full max-w-md"> + <div class="flex justify-between items-start"> + <div class="prose"> {#if name == identifier} {#if loading} <div class="skeleton my-3 h-5 w-20"></div> @@ -66,6 +156,32 @@ <p class="my-2 break-words text-sm">{identifier}</p> {/if} {/if} + </div> + {#if ref} + <AsyncButton disabled={$logged_in_user || !ref ? '' : 'true'} onClick={toggleStarred}> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 16 16" + class={`w-4 h-4 mr-2 ${isStarred ? "fill-yellow-500" : "fill-gray-400"}`} + > + {#if isStarred} + {#each star_icon_path.filled as p} + <path fill-rule="evenodd" d={p} /> + {/each} + {:else} + {#each star_icon_path.outline as p} + <path fill-rule="evenodd" d={p} /> + {/each} + {/if} + </svg> + {#if isStarred} + <span>Unstar</span> + {:else} + <span>Star</span> + {/if} + </AsyncButton> + {/if} + </div> {#if loading} <div class="skeleton my-3 h-5 w-20"></div> <div class="skeleton my-2 h-4"></div> diff --git a/src/lib/components/stars/icons.ts b/src/lib/components/stars/icons.ts new file mode 100644 index 0000000..73c465a --- /dev/null +++ b/src/lib/components/stars/icons.ts @@ -0,0 +1,11 @@ +// icon are MIT licenced +export const star_icon_path = { + // + outline: [ + "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", + ], + // + filled: [ + "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", + ], +} diff --git a/src/lib/components/stars/type.ts b/src/lib/components/stars/type.ts new file mode 100644 index 0000000..f076086 --- /dev/null +++ b/src/lib/components/stars/type.ts @@ -0,0 +1,13 @@ +import type { NDKEvent } from '@nostr-dev-kit/ndk' + +export interface Stargazers { + id: string | undefined + events: NDKEvent[] + loading: boolean; +} + +export const stars_defaults: Stargazers = { + id: '', + events: [], + loading: true, +} diff --git a/src/lib/kinds.ts b/src/lib/kinds.ts index fd65cf2..06cd21b 100644 --- a/src/lib/kinds.ts +++ b/src/lib/kinds.ts @@ -27,3 +27,5 @@ export const repo_kind: number = 30617 export const patch_kind: number = 1617 export const issue_kind: number = 1621 + +export const bookmarks_kind: number = 10617 diff --git a/src/lib/promise.ts b/src/lib/promise.ts new file mode 100644 index 0000000..1577059 --- /dev/null +++ b/src/lib/promise.ts @@ -0,0 +1,3 @@ +export function timeout(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/lib/stores/Issues.ts b/src/lib/stores/Issues.ts index 23b6023..ba229fb 100644 --- a/src/lib/stores/Issues.ts +++ b/src/lib/stores/Issues.ts @@ -34,7 +34,7 @@ let selected_repo_id: string | undefined = '' let sub: NDKSubscription export const ensureIssueSummaries = async (repo_id: string | undefined) => { - if (selected_repo_id == repo_id) return + if (selected_repo_id === repo_id) return issue_summaries.set({ id: repo_id, summaries: [], diff --git a/src/lib/stores/Proposal.ts b/src/lib/stores/Proposal.ts index c92d5f7..7aad603 100644 --- a/src/lib/stores/Proposal.ts +++ b/src/lib/stores/Proposal.ts @@ -109,7 +109,7 @@ export const ensureProposalFull = ( created_at: event.created_at, comments: 0, author: { - hexpubkey: event.pubkey, + pubkey: event.pubkey, loading: true, npub: '', }, diff --git a/src/lib/stores/Stargazers.ts b/src/lib/stores/Stargazers.ts new file mode 100644 index 0000000..fbf5a11 --- /dev/null +++ b/src/lib/stores/Stargazers.ts @@ -0,0 +1,74 @@ +import { + NDKRelaySet, + type NDKEvent, + NDKSubscription, + type NDKFilter, +} from '@nostr-dev-kit/ndk' +import { writable, type Writable } from 'svelte/store' +import { awaitSelectedRepoCollection } from './repo' +import { selectRepoFromCollection } from '$lib/components/repo/utils' +import { base_relays, ndk } from './ndk' +import { repo_kind, bookmarks_kind } from '$lib/kinds' +import { stars_defaults, type Stargazers } from '$lib/components/stars/type' + +export const stargazers: Writable<Stargazers> = writable(stars_defaults) + +let selected_repo_id: string | undefined = '' + +let sub: NDKSubscription + +export async function fetchStargazers(repo_id: string | undefined) { + if (selected_repo_id === repo_id) return + selected_repo_id = repo_id; + stargazers.set({ + id: repo_id, + events: [], + loading: true, + }) + if (sub) sub.stop() + if (repo_id) { + const repo_collection = await awaitSelectedRepoCollection(repo_id) + const repo = selectRepoFromCollection(repo_collection) + if (!repo) { + // TODO: display error info bar + return + } + const relays_to_use = + repo.relays.length > 3 + ? repo.relays + : [...base_relays].concat(repo.relays) + + // todo: relays usually return max 500 results, if a repo is very popular, we may need to paginate + const filter = { + kinds: [bookmarks_kind], + '#a': repo.maintainers.map((m) => `${repo_kind}:${m}:${repo.identifier}`), + } + + sub = ndk.subscribe( + filter, + { + closeOnEose: false, + }, + NDKRelaySet.fromRelayUrls(relays_to_use, ndk) + ) + + sub.on('event', (event: NDKEvent) => { + stargazers.update((stars) => { + return { + ...stars, + events: stars.events.concat([event]), + loading: false, + } + }) + }) + + sub.on('eose', () => { + stargazers.update((stars) => { + return { + ...stars, + loading: false, + } + }) + }) + } +} diff --git a/src/lib/wrappers/RepoMenu.svelte b/src/lib/wrappers/RepoMenu.svelte index a5958be..df9e477 100644 --- a/src/lib/wrappers/RepoMenu.svelte +++ b/src/lib/wrappers/RepoMenu.svelte @@ -1,17 +1,23 @@ <script lang="ts"> import { issue_icon_path } from '$lib/components/issues/icons' + import { star_icon_path } from '$lib/components/stars/icons' import { proposal_icon_path } from '$lib/components/proposals/icons' import type { RepoPage } from '$lib/components/repo/type' import { proposal_status_open } from '$lib/kinds' import { issue_summaries } from '$lib/stores/Issues' + import { stargazers } from '$lib/stores/Stargazers' + import { logged_in_user } from '$lib/stores/users' import { proposal_summaries } from '$lib/stores/Proposals' import { selected_repo_readme } from '$lib/stores/repo' export let selected_tab: RepoPage = 'about' export let identifier = '' + + let isStarred = false + $: isStarred = $stargazers.events.some((e) => e.pubkey === $logged_in_user?.hexpubkey) </script> -<div class="flex border-b border-base-400"> +<div class="flex border-b border-base-400 overflow-x-auto"> <div role="tablist" class="tabs tabs-bordered flex-none"> {#if !$selected_repo_readme.failed} <a @@ -66,6 +72,31 @@ </span> {/if} </a> + <a + href={`/repo/${identifier}/stargazers`} + class="tab" + class:tab-active={selected_tab === 'stargazers'} + > + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 16 16" + class={`mb-1 mr-1 h-4 w-4 flex-none fill-base-content pt-1 ${isStarred ? "fill-yellow-500" : "opacity-50"}`} + > + {#if isStarred} + {#each star_icon_path.filled as p} + <path fill-rule="evenodd" d={p} /> + {/each} + {:else} + {#each star_icon_path.outline as p} + <path fill-rule="evenodd" d={p} /> + {/each} + {/if} + </svg> + Stars + <span class="badge badge-neutral badge-sm ml-2"> + {$stargazers.events.length} + </span> + </a> </div> <div class="flex-grow"></div> </div> diff --git a/src/lib/wrappers/RepoPageWrapper.svelte b/src/lib/wrappers/RepoPageWrapper.svelte index 76107b8..d9618cd 100644 --- a/src/lib/wrappers/RepoPageWrapper.svelte +++ b/src/lib/wrappers/RepoPageWrapper.svelte @@ -9,6 +9,7 @@ import Container from '$lib/components/Container.svelte' import { ensureProposalSummaries } from '$lib/stores/Proposals' import { ensureIssueSummaries } from '$lib/stores/Issues' + import { fetchStargazers } from '$lib/stores/Stargazers' import type { RepoPage } from '$lib/components/repo/type' export let identifier = '' @@ -18,6 +19,7 @@ ensureSelectedRepoCollection(identifier) ensureProposalSummaries(identifier) ensureIssueSummaries(identifier) + fetchStargazers(identifier) let repo_error = false diff --git a/src/routes/repo/[repo_id]/stargazers/+page.svelte b/src/routes/repo/[repo_id]/stargazers/+page.svelte new file mode 100644 index 0000000..e77188d --- /dev/null +++ b/src/routes/repo/[repo_id]/stargazers/+page.svelte @@ -0,0 +1,34 @@ +<script lang="ts"> + import UserHeader from '$lib/components/users/UserHeader.svelte' + import type { IssueSummary } from '$lib/components/issues/type' + import { + proposal_status_applied, + proposal_status_closed, + proposal_status_open, + statusKindtoText, + } from '$lib/kinds' + import { stargazers } from '$lib/stores/Stargazers' + import RepoPageWrapper from '$lib/wrappers/RepoPageWrapper.svelte' + + export let data: { repo_id: string } + let identifier = data.repo_id + let status: number = proposal_status_open +</script> + +<RepoPageWrapper {identifier} selected_tab="stargazers"> + {#if !$stargazers.loading } + <div class="mt-2 border border-base-400 p-2"> + {#if $stargazers.events.length === 0} + <div class="py-10 text-center lowercase"> + there aren't any stargazers yet + </div> + {:else} + <div class="flex flex-col gap-2"> + {#each $stargazers.events as event} + <UserHeader user={event.pubkey} inline={true} size="md" /> + {/each} + </div> + {/if} + </div> + {/if} +</RepoPageWrapper> diff --git a/src/routes/repo/[repo_id]/stargazers/+page.ts b/src/routes/repo/[repo_id]/stargazers/+page.ts new file mode 100644 index 0000000..c70bf13 --- /dev/null +++ b/src/routes/repo/[repo_id]/stargazers/+page.ts @@ -0,0 +1,5 @@ +export const load = ({ params }: { params: { repo_id: string } }) => { + return { + repo_id: decodeURIComponent(params.repo_id), + } +} -- libgit2 1.7.2

Replies (25)

It's using kind 10617 as a replaceable list for storing a list of repos you want to bookmark. In the UI it is shown as "stars" following the GitHub convention. You can star/unstar a repository and see the list of stargazers on the repo UI. Follow-up work can include showing a start count on repo cards but I left that out for now.
I love your use of the term stargazers. Here is my code review: ## Stores and Structure The data can be more flexibly used if stored in a `stargazers` hex-pubkey string array parameter of `RepoEvent`, `RepoCollection` and `RepoSummary` in `components/repo/type.ts` rather than in a dedicated store. For example it creates the opportunity to inlude them on repo cards in the Latest Repositories of the homepage. The code to fetch the data could be moved into a `fetchStargazers` function and called in `ensureRepoCollection` in `stores/repos.ts` on line 96. I just abstracted out `fetchReferencesBy` to make `repos.ts` easier to read and so that it could be used as a model for `fetchStargazers` in commit 24427e1682d56eed07913df8383b248f95cf92af. ## Stargazers Event Kind and Format instead of a nip51 'standard list' with a custom kind, what about a nip51 'set' with the kind `30618` which is repo_event_kind + 1? `30618` could be the kind for all curated lists of `30617` repositories and a `d` identifier of `stared` could be used for this instance. ## UI integration It looks great to have stars shown, doesnt it? Gitworkshop uses daisyui which provides consistant theming for buttons. checkout daisyui, or some of the other buttons to see the classes Maybe the star button could show many current stars? tweaking this and how it is diplayed can come later. Thanks so much for contributing.
sorry I didn't get back to this but I'm super busy between 👶 and my full time job. hopefully it can be used as a starting point for a bookmarks implementation, feel free to take the code and modify it as you see fit!
I just zapped this commit using amethyst:
verbiricha's avatar verbiricha
From ef62256d050161fdfccafd2111005ab46c4f49dd Mon Sep 17 00:00:00 2001 From: Alejandro Gómez <alejandrogomez@bitrefill.com> Date: Mon, 22 Apr 2024 12:54:38 +0200 Subject: [PATCH] repo bookmarking --- src/lib/components/AsyncButton.svelte | 21 +++++++++++++++++++++ src/lib/components/repo/RepoDetails.svelte | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- src/lib/components/stars/icons.ts | 11 +++++++++++ src/lib/components/stars/type.ts | 13 +++++++++++++ src/lib/kinds.ts | 2 ++ src/lib/promise.ts | 3 +++ src/lib/stores/Issues.ts | 2 +- src/lib/stores/Proposal.ts | 2 +- src/lib/stores/Stargazers.ts | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib/wrappers/RepoMenu.svelte | 33 ++++++++++++++++++++++++++++++++- src/lib/wrappers/RepoPageWrapper.svelte | 2 ++ src/routes/repo/[repo_id]/stargazers/+page.svelte | 34 ++++++++++++++++++++++++++++++++++ src/routes/repo/[repo_id]/stargazers/+page.ts | 5 +++++ 13 files changed, 317 insertions(+), 5 deletions(-) create mode 100644 src/lib/components/AsyncButton.svelte create mode 100644 src/lib/components/stars/icons.ts create mode 100644 src/lib/components/stars/type.ts create mode 100644 src/lib/promise.ts create mode 100644 src/lib/stores/Stargazers.ts create mode 100644 src/routes/repo/[repo_id]/stargazers/+page.svelte create mode 100644 src/routes/repo/[repo_id]/stargazers/+page.ts diff --git a/src/lib/components/AsyncButton.svelte b/src/lib/components/AsyncButton.svelte new file mode 100644 index 0000000..a750834 --- /dev/null +++ b/src/lib/components/AsyncButton.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + let isLoading = false + + export let disabled: boolean | undefined = false + export let onClick = async () => {} + + async function onClickHandler(){ + isLoading = true; + try { + await onClick(); + } catch (error) { + console.error(error); + } finally { + isLoading = false; + } + } +</script> + +<button 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}> + <slot /> +</button> diff --git a/src/lib/components/repo/RepoDetails.svelte b/src/lib/components/repo/RepoDetails.svelte index 9be1f41..287ed68 100644 --- a/src/lib/components/repo/RepoDetails.svelte +++ b/src/lib/components/repo/RepoDetails.svelte @@ -1,6 +1,14 @@ <script lang="ts"> import UserHeader from '$lib/components/users/UserHeader.svelte' - import { NDKUser } from '@nostr-dev-kit/ndk' + import AsyncButton from '$lib/components/AsyncButton.svelte' + import { timeout } from '$lib/promise' + import { star_icon_path } from '$lib/components/stars/icons' + import { ndk } from '$lib/stores/ndk' + import { stargazers } from '$lib/stores/Stargazers' + import { getUserRelays, logged_in_user } from '$lib/stores/users' + import { bookmarks_kind } from '$lib/kinds' + import { NDKUser, NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk' + import { nip19 } from 'nostr-tools' import { icons_misc } from '../icons' import { event_defaults } from './type' @@ -27,9 +35,91 @@ let naddr_copied = false let git_url_copied: false | string = false let maintainer_copied: false | string = false + + let ref: string | undefined = undefined; + $: if (naddr) { + const decoded = nip19.decode(naddr) + if (decoded.type === "naddr") { + const { kind, pubkey, identifier } = decoded.data; + ref = `${kind}:${pubkey}:${identifier}` + } + } + + let isStarred = false + $: isStarred = $stargazers.events.some((e) => e.pubkey === $logged_in_user?.hexpubkey) + + async function toggleStarred(){ + if (!$logged_in_user) { + return + } + const user_relays = await getUserRelays($logged_in_user.hexpubkey) + const relayUrls = [ + ...relays, + ...(user_relays.ndk_relays + ? user_relays.ndk_relays.writeRelayUrls + : []), + ] + const relaySet = NDKRelaySet.fromRelayUrls(relayUrls, ndk) + + if (isStarred) { + let event = new NDKEvent(ndk); + const oldEvent = $stargazers.events.find(e => e.pubkey === $logged_in_user.hexpubkey); + event.kind = bookmarks_kind; + event.content = oldEvent.content; + event.tags = oldEvent.tags.filter(t => t[0] === 'a' && t[1] !== ref); + try { + await event.sign() + } catch { + alert('failed to sign event') + } + try { + await event.publish(relaySet) + stargazers.update((stars) => { + return { + ...stars, + events: stars.events.filter(e => e.pubkey !== $logged_in_user.hexpubkey), + } + }) + } catch { + alert('failed to publish event') + } + } else { + const oldEvent = await Promise.race([ + ndk.fetchEvent({ + kinds: [bookmarks_kind], + author: [$logged_in_user.hexpubkey], + }, { groupable: false }, relaySet), + timeout(2000), + ]) + let event = new NDKEvent(ndk) + event.kind = bookmarks_kind + if (oldEvent) { + event.tags = oldEvent.tags; + } + event.tags.push(['a', ref, relays.length ? relays[0] : '']) + try { + await event.sign() + } catch { + alert('failed to sign event') + } + try { + await event.publish(relaySet) + stargazers.update((stars) => { + return { + ...stars, + events: stars.events.filter(e => e.pubkey !== $logged_in_user.hexpubkey).concat([event]), + } + }) + } catch { + alert('failed to publish event') + } + } + } </script> -<div class="prose w-full max-w-md"> +<div class="w-full max-w-md"> + <div class="flex justify-between items-start"> + <div class="prose"> {#if name == identifier} {#if loading} <div class="skeleton my-3 h-5 w-20"></div> @@ -66,6 +156,32 @@ <p class="my-2 break-words text-sm">{identifier}</p> {/if} {/if} + </div> + {#if ref} + <AsyncButton disabled={$logged_in_user || !ref ? '' : 'true'} onClick={toggleStarred}> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 16 16" + class={`w-4 h-4 mr-2 ${isStarred ? "fill-yellow-500" : "fill-gray-400"}`} + > + {#if isStarred} + {#each star_icon_path.filled as p} + <path fill-rule="evenodd" d={p} /> + {/each} + {:else} + {#each star_icon_path.outline as p} + <path fill-rule="evenodd" d={p} /> + {/each} + {/if} + </svg> + {#if isStarred} + <span>Unstar</span> + {:else} + <span>Star</span> + {/if} + </AsyncButton> + {/if} + </div> {#if loading} <div class="skeleton my-3 h-5 w-20"></div> <div class="skeleton my-2 h-4"></div> diff --git a/src/lib/components/stars/icons.ts b/src/lib/components/stars/icons.ts new file mode 100644 index 0000000..73c465a --- /dev/null +++ b/src/lib/components/stars/icons.ts @@ -0,0 +1,11 @@ +// icon are MIT licenced +export const star_icon_path = { + // + outline: [ + "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", + ], + // + filled: [ + "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", + ], +} diff --git a/src/lib/components/stars/type.ts b/src/lib/components/stars/type.ts new file mode 100644 index 0000000..f076086 --- /dev/null +++ b/src/lib/components/stars/type.ts @@ -0,0 +1,13 @@ +import type { NDKEvent } from '@nostr-dev-kit/ndk' + +export interface Stargazers { + id: string | undefined + events: NDKEvent[] + loading: boolean; +} + +export const stars_defaults: Stargazers = { + id: '', + events: [], + loading: true, +} diff --git a/src/lib/kinds.ts b/src/lib/kinds.ts index fd65cf2..06cd21b 100644 --- a/src/lib/kinds.ts +++ b/src/lib/kinds.ts @@ -27,3 +27,5 @@ export const repo_kind: number = 30617 export const patch_kind: number = 1617 export const issue_kind: number = 1621 + +export const bookmarks_kind: number = 10617 diff --git a/src/lib/promise.ts b/src/lib/promise.ts new file mode 100644 index 0000000..1577059 --- /dev/null +++ b/src/lib/promise.ts @@ -0,0 +1,3 @@ +export function timeout(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/lib/stores/Issues.ts b/src/lib/stores/Issues.ts index 23b6023..ba229fb 100644 --- a/src/lib/stores/Issues.ts +++ b/src/lib/stores/Issues.ts @@ -34,7 +34,7 @@ let selected_repo_id: string | undefined = '' let sub: NDKSubscription export const ensureIssueSummaries = async (repo_id: string | undefined) => { - if (selected_repo_id == repo_id) return + if (selected_repo_id === repo_id) return issue_summaries.set({ id: repo_id, summaries: [], diff --git a/src/lib/stores/Proposal.ts b/src/lib/stores/Proposal.ts index c92d5f7..7aad603 100644 --- a/src/lib/stores/Proposal.ts +++ b/src/lib/stores/Proposal.ts @@ -109,7 +109,7 @@ export const ensureProposalFull = ( created_at: event.created_at, comments: 0, author: { - hexpubkey: event.pubkey, + pubkey: event.pubkey, loading: true, npub: '', }, diff --git a/src/lib/stores/Stargazers.ts b/src/lib/stores/Stargazers.ts new file mode 100644 index 0000000..fbf5a11 --- /dev/null +++ b/src/lib/stores/Stargazers.ts @@ -0,0 +1,74 @@ +import { + NDKRelaySet, + type NDKEvent, + NDKSubscription, + type NDKFilter, +} from '@nostr-dev-kit/ndk' +import { writable, type Writable } from 'svelte/store' +import { awaitSelectedRepoCollection } from './repo' +import { selectRepoFromCollection } from '$lib/components/repo/utils' +import { base_relays, ndk } from './ndk' +import { repo_kind, bookmarks_kind } from '$lib/kinds' +import { stars_defaults, type Stargazers } from '$lib/components/stars/type' + +export const stargazers: Writable<Stargazers> = writable(stars_defaults) + +let selected_repo_id: string | undefined = '' + +let sub: NDKSubscription + +export async function fetchStargazers(repo_id: string | undefined) { + if (selected_repo_id === repo_id) return + selected_repo_id = repo_id; + stargazers.set({ + id: repo_id, + events: [], + loading: true, + }) + if (sub) sub.stop() + if (repo_id) { + const repo_collection = await awaitSelectedRepoCollection(repo_id) + const repo = selectRepoFromCollection(repo_collection) + if (!repo) { + // TODO: display error info bar + return + } + const relays_to_use = + repo.relays.length > 3 + ? repo.relays + : [...base_relays].concat(repo.relays) + + // todo: relays usually return max 500 results, if a repo is very popular, we may need to paginate + const filter = { + kinds: [bookmarks_kind], + '#a': repo.maintainers.map((m) => `${repo_kind}:${m}:${repo.identifier}`), + } + + sub = ndk.subscribe( + filter, + { + closeOnEose: false, + }, + NDKRelaySet.fromRelayUrls(relays_to_use, ndk) + ) + + sub.on('event', (event: NDKEvent) => { + stargazers.update((stars) => { + return { + ...stars, + events: stars.events.concat([event]), + loading: false, + } + }) + }) + + sub.on('eose', () => { + stargazers.update((stars) => { + return { + ...stars, + loading: false, + } + }) + }) + } +} diff --git a/src/lib/wrappers/RepoMenu.svelte b/src/lib/wrappers/RepoMenu.svelte index a5958be..df9e477 100644 --- a/src/lib/wrappers/RepoMenu.svelte +++ b/src/lib/wrappers/RepoMenu.svelte @@ -1,17 +1,23 @@ <script lang="ts"> import { issue_icon_path } from '$lib/components/issues/icons' + import { star_icon_path } from '$lib/components/stars/icons' import { proposal_icon_path } from '$lib/components/proposals/icons' import type { RepoPage } from '$lib/components/repo/type' import { proposal_status_open } from '$lib/kinds' import { issue_summaries } from '$lib/stores/Issues' + import { stargazers } from '$lib/stores/Stargazers' + import { logged_in_user } from '$lib/stores/users' import { proposal_summaries } from '$lib/stores/Proposals' import { selected_repo_readme } from '$lib/stores/repo' export let selected_tab: RepoPage = 'about' export let identifier = '' + + let isStarred = false + $: isStarred = $stargazers.events.some((e) => e.pubkey === $logged_in_user?.hexpubkey) </script> -<div class="flex border-b border-base-400"> +<div class="flex border-b border-base-400 overflow-x-auto"> <div role="tablist" class="tabs tabs-bordered flex-none"> {#if !$selected_repo_readme.failed} <a @@ -66,6 +72,31 @@ </span> {/if} </a> + <a + href={`/repo/${identifier}/stargazers`} + class="tab" + class:tab-active={selected_tab === 'stargazers'} + > + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 16 16" + class={`mb-1 mr-1 h-4 w-4 flex-none fill-base-content pt-1 ${isStarred ? "fill-yellow-500" : "opacity-50"}`} + > + {#if isStarred} + {#each star_icon_path.filled as p} + <path fill-rule="evenodd" d={p} /> + {/each} + {:else} + {#each star_icon_path.outline as p} + <path fill-rule="evenodd" d={p} /> + {/each} + {/if} + </svg> + Stars + <span class="badge badge-neutral badge-sm ml-2"> + {$stargazers.events.length} + </span> + </a> </div> <div class="flex-grow"></div> </div> diff --git a/src/lib/wrappers/RepoPageWrapper.svelte b/src/lib/wrappers/RepoPageWrapper.svelte index 76107b8..d9618cd 100644 --- a/src/lib/wrappers/RepoPageWrapper.svelte +++ b/src/lib/wrappers/RepoPageWrapper.svelte @@ -9,6 +9,7 @@ import Container from '$lib/components/Container.svelte' import { ensureProposalSummaries } from '$lib/stores/Proposals' import { ensureIssueSummaries } from '$lib/stores/Issues' + import { fetchStargazers } from '$lib/stores/Stargazers' import type { RepoPage } from '$lib/components/repo/type' export let identifier = '' @@ -18,6 +19,7 @@ ensureSelectedRepoCollection(identifier) ensureProposalSummaries(identifier) ensureIssueSummaries(identifier) + fetchStargazers(identifier) let repo_error = false diff --git a/src/routes/repo/[repo_id]/stargazers/+page.svelte b/src/routes/repo/[repo_id]/stargazers/+page.svelte new file mode 100644 index 0000000..e77188d --- /dev/null +++ b/src/routes/repo/[repo_id]/stargazers/+page.svelte @@ -0,0 +1,34 @@ +<script lang="ts"> + import UserHeader from '$lib/components/users/UserHeader.svelte' + import type { IssueSummary } from '$lib/components/issues/type' + import { + proposal_status_applied, + proposal_status_closed, + proposal_status_open, + statusKindtoText, + } from '$lib/kinds' + import { stargazers } from '$lib/stores/Stargazers' + import RepoPageWrapper from '$lib/wrappers/RepoPageWrapper.svelte' + + export let data: { repo_id: string } + let identifier = data.repo_id + let status: number = proposal_status_open +</script> + +<RepoPageWrapper {identifier} selected_tab="stargazers"> + {#if !$stargazers.loading } + <div class="mt-2 border border-base-400 p-2"> + {#if $stargazers.events.length === 0} + <div class="py-10 text-center lowercase"> + there aren't any stargazers yet + </div> + {:else} + <div class="flex flex-col gap-2"> + {#each $stargazers.events as event} + <UserHeader user={event.pubkey} inline={true} size="md" /> + {/each} + </div> + {/if} + </div> + {/if} +</RepoPageWrapper> diff --git a/src/routes/repo/[repo_id]/stargazers/+page.ts b/src/routes/repo/[repo_id]/stargazers/+page.ts new file mode 100644 index 0000000..c70bf13 --- /dev/null +++ b/src/routes/repo/[repo_id]/stargazers/+page.ts @@ -0,0 +1,5 @@ +export const load = ({ params }: { params: { repo_id: string } }) => { + return { + repo_id: decodeURIComponent(params.repo_id), + } +} -- libgit2 1.7.2
View quoted note →
I've been thinking about this, and I think we need to get away from storing tables (unordered lists) in events, that can get really long and are frequently updated. Could we use labels, instead of bookmarks?
lets put this back on the roadmap with the event structure proposed here:
DanConwayDev's avatar DanConwayDev
I love your use of the term stargazers. Here is my code review: ## Stores and Structure The data can be more flexibly used if stored in a `stargazers` hex-pubkey string array parameter of `RepoEvent`, `RepoCollection` and `RepoSummary` in `components/repo/type.ts` rather than in a dedicated store. For example it creates the opportunity to inlude them on repo cards in the Latest Repositories of the homepage. The code to fetch the data could be moved into a `fetchStargazers` function and called in `ensureRepoCollection` in `stores/repos.ts` on line 96. I just abstracted out `fetchReferencesBy` to make `repos.ts` easier to read and so that it could be used as a model for `fetchStargazers` in commit 24427e1682d56eed07913df8383b248f95cf92af. ## Stargazers Event Kind and Format instead of a nip51 'standard list' with a custom kind, what about a nip51 'set' with the kind `30618` which is repo_event_kind + 1? `30618` could be the kind for all curated lists of `30617` repositories and a `d` identifier of `stared` could be used for this instance. ## UI integration It looks great to have stars shown, doesnt it? Gitworkshop uses daisyui which provides consistant theming for buttons. checkout daisyui, or some of the other buttons to see the classes Maybe the star button could show many current stars? tweaking this and how it is diplayed can come later. Thanks so much for contributing.
View quoted note →