From f311e67f75889ba8f87ce70e9416bff18d074b8a Mon Sep 17 00:00:00 2001 From: nekomimi.pet Date: Sat, 01 Aug 2026 18:54:32 +0000 Subject: [PATCH] add watched repositories --- AGENTS.md | 4 ++-- PRODUCT.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ public/oauth-client-metadata.json | 2 +- src/index.css | 307 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------- src/layout.tsx | 6 +++++- src/routes.tsx | 3 +++ src/lib/api.ts | 10 ++++++++++ src/pages/home.tsx | 169 +++---------------------------------------------------------------------------------------------------------------------------------------------------------------------- src/pages/watching.tsx | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib/api/constants.ts | 2 +- src/lib/api/watches.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/pages/repo/shared.tsx | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- lexicons/place/wisp/untangled/v1/watch.json | 26 ++++++++++++++++++++++++++ 13 file(s) changed, 778 insertion(s)(+), 179 deletion(s)(-) diff --git a/AGENTS.md b/AGENTS.md --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,8 @@ ``` This is a Solid/Vite TypeScript frontend. The build runs `tsc -b && vite build`. -Do not start the Vite dev server from agent sessions; use the build command for -verification. +Use the build command as the main verification step. A Vite dev server is also +allowed when the user requests live development or browser inspection. ## Architecture Map diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,54 @@ +# Product + + + +## Platform + +web + +## Users + +inferred from the current application and request: atproto users who browse and contribute to tangled repositories and want a personal place to keep track of repositories they care about. + +## Product Purpose + +untangled is a solid-js web client for browsing and working with tangled repositories. it should make repository discovery, code, issues, pull requests, commits, and personal repository workflows easy to access from one quiet tool surface. + +## Positioning + +inferred: repository activity is organized around the user's own watched-repository list rather than a generic global feed, with direct links back to the exact repository work that needs attention. + +## Operating Context + +users sign in with atproto oauth, open repositories through owner and repo routes, and use the app to inspect code, issues, pull requests, commits, stars, profiles, and settings. repository records and personal preference records are stored through atproto services. + +## Capabilities and Constraints + +- the frontend is a solid/vite typescript app and the main verification command is `rtk npm run build`. +- repositories are identified by atproto records and their tangled repository did metadata. +- the watched-repository feature uses the new `place.wisp.untangled.v1.watch` record type, with one record per watched repository. +- watched repositories should expose recent issues, pull requests, and commits, with quick links and commit details available without leaving the dashboard. +- daily briefs are an intended product capability; exact server generation, persistence, and delivery are open decisions until a backend or appview contract exists. +- the visual language should remain a quiet, dense repository tool interface and should not invent fake upstream controls. + +## Brand Commitments + +the product name is untangled. interface copy is lowercase and the current application uses compact, restrained repository-tool styling. + +## Evidence on Hand + +- existing repository, issue, pull request, commit, oauth, and atproto record APIs under `src/lib/api/`. +- existing home/activity surface under `src/pages/home.tsx` and its styles in `src/index.css`. +- user-provided activity screenshot showing a dense activity feed with recent-repository and recent-issue/pr quick links. + +## Product Principles + +- organize attention around repositories the user deliberately chose. +- make the next useful repository action one click away. +- summarize recent work without hiding the underlying commit, issue, or pull request details. +- preserve atproto ownership and user-controlled records. +- keep the interface dense, quiet, and honest about unavailable automation. + +## Accessibility & Inclusion + +the web interface should retain keyboard-accessible links and buttons, visible focus states, semantic headings, and hover details that also have a keyboard/focus or click path. diff --git a/public/oauth-client-metadata.json b/public/oauth-client-metadata.json --- a/public/oauth-client-metadata.json +++ b/public/oauth-client-metadata.json @@ -3,7 +3,7 @@ "client_name": "untangled", "client_uri": "https://untangled.wisp.place", "redirect_uris": ["https://untangled.wisp.place/oauth/callback"], - "scope": "atproto repo:sh.tangled.actor.profile repo:sh.tangled.publicKey repo:sh.tangled.repo repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* rpc:sh.tangled.repo.create?aud=* repo:sh.tangled.feed.comment repo:sh.tangled.string repo:sh.tangled.feed.star blob:*/* repo:sh.tangled.graph.follow", + "scope": "atproto repo:sh.tangled.actor.profile repo:sh.tangled.publicKey repo:sh.tangled.repo repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* rpc:sh.tangled.repo.create?aud=* repo:sh.tangled.feed.comment repo:sh.tangled.string repo:sh.tangled.feed.star blob:*/* repo:sh.tangled.graph.follow repo:place.wisp.untangled.v1.watch", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none", diff --git a/src/index.css b/src/index.css --- a/src/index.css +++ b/src/index.css @@ -1481,11 +1481,9 @@ gap: 1.5rem; padding-top: 2rem; padding-bottom: 4rem; - width: 100vw; - margin-left: calc(50% - 50vw); - margin-right: calc(50% - 50vw); - padding-left: 2rem; - padding-right: 2rem; + width: 100%; + padding-left: 0; + padding-right: 0; background: rgb(248 250 252); box-sizing: border-box; } @@ -1498,8 +1496,8 @@ @media (min-width: 1280px) { .untangled-home-below { - padding-left: 4rem; - padding-right: 4rem; + padding-left: 0; + padding-right: 0; } } @@ -4004,4 +4002,299 @@ .untangled-checkbox { padding: 0 !important; margin: 0 !important; +} + +.untangled-watched-page { + display: flex; + min-width: 0; + flex-direction: column; + gap: 1rem; +} + +.untangled-watched-page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid rgb(229 231 235); + padding: 0.5rem 0.25rem 1rem; +} + +.untangled-watched-page-header h1 { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 0; + font-size: 1.5rem; + font-weight: 750; +} + +.untangled-watched-page-header p { + margin: 0.35rem 0 0; + color: rgb(107 114 128); + font-size: 0.875rem; +} + +.untangled-watched-count { + border: 1px solid rgb(209 213 219); + border-radius: 9999px; + padding: 0.3rem 0.65rem; + color: rgb(75 85 99); + font-size: 0.75rem; + white-space: nowrap; +} + +.untangled-watched-list { + display: flex; + min-width: 0; + flex-direction: column; + gap: 1rem; +} + +.untangled-watched-repo { + min-width: 0; + border: 1px solid rgb(229 231 235); + border-radius: 0.125rem; + background: rgb(255 255 255); + overflow: visible; +} + +.untangled-watched-repo-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 0.875rem 1rem; +} + +.untangled-watched-repo-title { + font-size: 1rem; + font-weight: 700; + color: rgb(17 24 39); +} + +.untangled-watched-repo-description { + max-width: 65ch; + margin: 0.35rem 0 0 1.5rem; + color: rgb(107 114 128); + font-size: 0.8125rem; + line-height: 1.4; +} + +.untangled-watched-quick-links { + display: flex; + align-items: center; + gap: 0.25rem; + color: rgb(107 114 128); +} + +.untangled-watched-quick-links a { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border: 1px solid transparent; + border-radius: 0.125rem; +} + +.untangled-watched-quick-links a:hover, +.untangled-watched-quick-links a:focus-visible { + border-color: rgb(209 213 219); + background: rgb(249 250 251); + color: rgb(37 99 235); +} + +.untangled-watched-stats { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem 1.25rem; + border-top: 1px solid rgb(229 231 235); + border-bottom: 1px solid rgb(229 231 235); + padding: 0.65rem 1rem; + color: rgb(75 85 99); + font-size: 0.8125rem; +} + +.untangled-watch-stat { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.untangled-watch-stat strong { + color: rgb(17 24 39); +} + +.untangled-watch-stat-button { + cursor: pointer; + list-style: none; +} + +.untangled-watch-stat-button::-webkit-details-marker { + display: none; +} + +.untangled-watch-stat-button:hover, +.untangled-watch-stat-button:focus-visible { + color: rgb(37 99 235); +} + +.untangled-watch-commit-details { + position: relative; +} + +.untangled-watch-commit-details:hover > .untangled-watch-commit-popover { + display: block; +} + +.untangled-watch-commit-popover { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + z-index: 20; + width: min(32rem, calc(100vw - 2rem)); + border: 1px solid rgb(209 213 219); + border-radius: 0.125rem; + background: rgb(255 255 255); + padding: 0.5rem; + box-shadow: 0 8px 24px rgb(15 23 42 / 0.12); +} + +.untangled-watch-popover-heading { + padding: 0.35rem 0.5rem 0.5rem; + color: rgb(75 85 99); + font-size: 0.75rem; + font-weight: 700; + text-transform: lowercase; +} + +.untangled-watch-commit-row, +.untangled-watch-work-link { + display: flex; + min-width: 0; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + border-radius: 0.125rem; + font-size: 0.8125rem; +} + +.untangled-watch-commit-row:hover, +.untangled-watch-commit-row:focus-visible, +.untangled-watch-work-link:hover, +.untangled-watch-work-link:focus-visible { + background: rgb(249 250 251); + text-decoration: none; +} + +.untangled-watch-commit-hash { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + color: rgb(37 99 235); +} + +.untangled-watched-work-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + padding: 0.875rem 1rem 1rem; +} + +.untangled-watched-work-grid section { + min-width: 0; +} + +.untangled-watched-work-grid h2 { + margin: 0 0 0.5rem; + color: rgb(75 85 99); + font-size: 0.75rem; + font-weight: 700; + text-transform: lowercase; +} + +.untangled-watch-work-list { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.untangled-watch-muted, +.untangled-watch-loading { + color: rgb(107 114 128); + font-size: 0.8125rem; +} + +.untangled-watch-loading { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 1rem 0.25rem; +} + +.untangled-watch-empty { + border: 1px dashed rgb(209 213 219); + padding: 2rem 1rem; + text-align: center; +} + +.untangled-watch-empty h2 { + margin: 0; + font-size: 1rem; + font-weight: 700; +} + +.untangled-watch-empty p { + max-width: 40ch; + margin: 0.5rem auto 0; + color: rgb(107 114 128); + font-size: 0.875rem; +} + +@media (prefers-color-scheme: dark) { + .untangled-watched-page-header, + .untangled-watched-repo, + .untangled-watched-stats { + border-color: rgb(55 65 81); + } + + .untangled-watched-repo, + .untangled-watch-commit-popover { + background: rgb(31 41 55); + } + + .untangled-watched-repo-title, + .untangled-watch-stat strong { + color: rgb(243 244 246); + } + + .untangled-watched-quick-links a:hover, + .untangled-watched-quick-links a:focus-visible, + .untangled-watch-commit-row:hover, + .untangled-watch-commit-row:focus-visible, + .untangled-watch-work-link:hover, + .untangled-watch-work-link:focus-visible { + border-color: rgb(75 85 99); + background: rgb(55 65 81); + } + + .untangled-watched-count, + .untangled-watch-empty { + border-color: rgb(75 85 99); + } +} + +@media (max-width: 640px) { + .untangled-watched-page-header, + .untangled-watched-repo-header { + flex-direction: column; + } + + .untangled-watched-quick-links { + align-self: flex-end; + } + + .untangled-watched-work-grid { + grid-template-columns: minmax(0, 1fr); + } } diff --git a/src/layout.tsx b/src/layout.tsx --- a/src/layout.tsx +++ b/src/layout.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { Bell, BookMarked, LogOut, Search, Settings, UserRound, Plus, LineSquiggle, BookPlus } from 'lucide-solid'; +import { Bell, BookMarked, Eye, LogOut, Search, Settings, UserRound, Plus, LineSquiggle, BookPlus } from 'lucide-solid'; import { A, useLocation, useNavigate } from '@solidjs/router'; import { createQuery } from './lib/atproto-query'; import { For, Show, createEffect, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; @@ -305,6 +305,10 @@ + + ( + + diff --git a/src/lib/api.ts b/src/lib/api.ts --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -69,6 +69,12 @@ searchRecords, } from './api/search'; export { + createWatch, + deleteWatch, + findWatchRecord, + listWatchRecords, +} from './api/watches'; +export { getString, listStringRecords, deleteString, @@ -140,3 +146,7 @@ SearchRecordsOptions, SearchResponse, } from './api/search'; +export type { + WatchRecord, + WatchRecordValue, +} from './api/watches'; diff --git a/src/pages/home.tsx b/src/pages/home.tsx --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -1,10 +1,7 @@ import { BookMarked, CircleDot, - FolderCode, - GalleryVertical, GitPullRequest, - LoaderCircle, UserRound, TrendingUp, ShieldCheck, @@ -15,7 +12,7 @@ import clsx from 'clsx'; import { A } from '@solidjs/router'; import { createQuery } from '../lib/atproto-query'; -import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component, type JSX } from 'solid-js'; +import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js'; import { Avatar, ErrorState, LoadingState, StateBadge } from '../components/common'; import { RepoCard } from '../components/repo'; @@ -23,8 +20,8 @@ import { resolveActor } from '../lib/api/identity'; import { listRepoRecords, type RepoRecord } from '../lib/api/repos'; import { useAuth } from '../lib/auth'; -import { useLiveEvents, type LiveEvent } from '../lib/live-events'; import { useIssuePreloader, useRepoPreloader } from '../lib/preloading'; +import { WatchedReposPanel } from './watching'; import { formatRelativeTime, getErrorMessage, loadRecentRepos, loadRecentIssuesPulls } from '../lib/repo-utils'; const SAMPLE_REPOS: Array<{ @@ -189,12 +186,6 @@ ); }; -const HOME_ACTIVITY_SOURCES = [ - 'sh.tangled.repo.issue:repoDid', - 'sh.tangled.repo.issue:repo', - 'sh.tangled.repo.pull:target.repo', -]; - type HomeRepoItem = { owner: string; slug: string; @@ -232,11 +223,6 @@ const repoSlug = (repo: RepoRecord): string => repo.value.name?.trim() || repo.rkey; -const repoTimestamp = (repo: HomeRepoItem): number => { - const timestamp = repo.createdAt ? new Date(repo.createdAt).getTime() : 0; - return Number.isFinite(timestamp) ? timestamp : 0; -}; - const activityTimestamp = (item: HomeActivityItem): number => { const timestamp = item.at ? new Date(item.at).getTime() : 0; return Number.isFinite(timestamp) ? timestamp : 0; @@ -254,19 +240,6 @@ description: repo.value.description, createdAt: repo.value.createdAt, }; -}; - -const activityLabelForEvent = (event: LiveEvent): Pick => { - if (event.source.includes('.pull')) { - return { kind: 'pull', title: 'new pull request' }; - } - - return { kind: 'issue', title: 'new issue' }; -}; - -const eventActivityHref = (repo: HomeRepoItem, event: LiveEvent): string => { - const collection = event.source.includes('.pull') ? 'pulls' : 'issues'; - return `${repo.href}/${collection}/${encodeURIComponent(event.sourceRecord)}`; }; const fetchFollowingHomeData = async (viewerDid: string): Promise => { @@ -321,7 +294,6 @@ export const HomePage: Component = () => { const auth = useAuth(); - const live = useLiveEvents(); const issuePreloader = useIssuePreloader(); const recentRepos = createMemo(loadRecentRepos); const recentIssuesPulls = createMemo(loadRecentIssuesPulls); @@ -376,129 +348,11 @@ })); - const ownRepoItems = createMemo(() => { - const data = ownReposQuery.data; - if (!data) return []; - return data.records - .map((repo) => homeRepoItem(data.actor.handle, repo)) - .sort((left, right) => repoTimestamp(right) - repoTimestamp(left)); - }); - - const currentUserHandle = () => ownReposQuery.data?.actor.handle || ''; - const currentUserDid = () => auth.currentDid() || ''; - - const repoByDid = createMemo(() => { - const repos = new Map(); - for (const repo of ownRepoItems()) { - if (repo.repoDid) repos.set(repo.repoDid, repo); - } - return repos; - }); - - createEffect(() => { - if (!auth.currentDid()) { - live.clear(); - return; - } - - if (ownReposQuery.isLoading) { - return; - } - - const subjects = ownRepoItems() - .map((repo) => repo.repoDid) - .filter((repoDid): repoDid is string => Boolean(repoDid)); - - if (subjects.length === 0) { - live.clear(); - return; - } - - live.subscribe({ - subjects, - sources: HOME_ACTIVITY_SOURCES, - }); - - onCleanup(() => { - live.clear(); - }); - }); - - - - const activityItems = createMemo(() => { - const repos = repoByDid(); - const liveItems = live.events() - .map((event): HomeActivityItem | null => { - const repo = repos.get(event.subject); - if (!repo) return null; - const label = activityLabelForEvent(event); - return { - id: `live:${event.sourceRecord}:${event.receivedAt}`, - ...label, - repoLabel: repo.title, - href: eventActivityHref(repo, event), - uri: event.sourceRecord, - at: event.receivedAt, - actorDid: currentUserDid(), - actorHandle: currentUserHandle(), - }; - }) - .filter((item): item is HomeActivityItem => Boolean(item)); - - const repoItems: HomeActivityItem[] = ownRepoItems().map((repo) => ({ - id: `repo:${repo.owner}/${repo.rkey}`, - kind: 'repo' as const, - title: 'repository published', - repoLabel: repo.title, - href: repo.href, - at: repo.createdAt, - body: repo.description, - actorDid: currentUserDid(), - actorHandle: currentUserHandle(), - })); - - return [...liveItems, ...repoItems] - .sort((left, right) => activityTimestamp(right) - activityTimestamp(left)) - .slice(0, 8); - }); - return (
-
-

- - activity -

-
- - } - > - } - > - } - > - 0} - fallback={} - > -
- - {(item, index) => } - -
-
-
-
-
+
); }; - diff --git a/src/pages/watching.tsx b/src/pages/watching.tsx new file mode 100644 --- /dev/null +++ b/src/pages/watching.tsx @@ -0,0 +1,187 @@ +import { A } from '@solidjs/router'; +import { CircleDot, Eye, GitCommitHorizontal, GitPullRequest, LoaderCircle, SquareChartGantt } from 'lucide-solid'; +import { For, Show, createMemo, type Component } from 'solid-js'; + +import { ErrorState } from '../components/common'; +import { getRepoDefaultBranch, getRepoByUri, getRepoLog, type CommitEntry, type GitHashValue, type RepoContext } from '../lib/api/repos'; +import { listIssues, type IssueSummary } from '../lib/api/issues'; +import { listPulls, type PullSummary } from '../lib/api/pulls'; +import { listWatchRecords, type WatchRecord } from '../lib/api/watches'; +import { useAuth } from '../lib/auth'; +import { createQuery } from '../lib/atproto-query'; +import { formatRelativeTime, getErrorMessage } from '../lib/repo-utils'; + +type WatchedRepo = { + watch: WatchRecord; + repo: RepoContext; + issues: IssueSummary[]; + pulls: PullSummary[]; + commits: CommitEntry[]; +}; + +const hashText = (hash?: GitHashValue): string => { + if (typeof hash === 'string') return hash; + if (Array.isArray(hash)) return hash.map((part) => part.toString(16).padStart(2, '0')).join(''); + return hash?.String ?? hash?.string ?? ''; +}; + +const commitDate = (commit: CommitEntry): string | undefined => commit.author?.When ?? commit.committer?.When; + +const commitMessage = (message: string): string => message.split('\n')[0]?.trim() || 'untitled commit'; + +const loadWatchedRepo = async (watch: WatchRecord): Promise => { + try { + const repo = await getRepoByUri(watch.value.repo); + const branch = await getRepoDefaultBranch(repo).catch(() => undefined); + const ref = branch?.name ?? branch?.branch ?? branch?.reference?.name ?? 'HEAD'; + const [issues, pulls, log] = await Promise.all([ + listIssues(repo).catch(() => []), + listPulls(repo).catch(() => []), + getRepoLog(repo, ref, { limit: 30 }).catch(() => ({ commits: [] } as { commits: CommitEntry[] })), + ]); + + return { + watch, + repo, + issues: issues.slice(0, 4), + pulls: pulls.slice(0, 4), + commits: log.commits.slice(0, 30), + }; + } catch { + return null; + } +}; + +const WorkLink: Component<{ href: string; kind: 'issue' | 'pull'; item: IssueSummary | PullSummary }> = (props) => { + const isPull = () => props.kind === 'pull'; + return ( + + {isPull() ? : } + {props.item.value.title} + #{props.item.number} + + ); +}; + +const CommitDetails: Component<{ repo: RepoContext; commits: CommitEntry[] }> = (props) => ( +
+ + + {props.commits.length} + commits + +
+
recent commits
+ 0} fallback={

no commit data available

}> + + {(commit) => { + const hash = hashText(commit.hash ?? commit.this); + return ( + + {hash.slice(0, 7)} + {commitMessage(commit.message)} + {commitDate(commit) ? formatRelativeTime(commitDate(commit)!) : ''} + + ); + }} + +
+
+
+); + +const WatchedRepoRow: Component<{ item: WatchedRepo }> = (props) => { + const repo = () => props.item.repo; + return ( +
+
+
+ + +

{repo().record.value.description}

+
+
+ +
+ +
+
{props.item.issues.length}recent issues
+
{props.item.pulls.length}recent prs
+ +
+ +
+
+

issues

+ 0} fallback={

no recent issues

}> +
+ {(issue) => } +
+
+
+
+

pull requests

+ 0} fallback={

no recent pull requests

}> +
+ {(pull) => } +
+
+
+
+
+ ); +}; + +export const WatchedReposPanel: Component = () => { + const auth = useAuth(); + const watchesQuery = createQuery(() => ({ + queryKey: ['watched-repos', auth.currentDid()], + enabled: Boolean(auth.currentDid()), + staleTime: 60_000, + queryFn: async () => { + const watches = await listWatchRecords(auth.currentDid()!); + const items = await Promise.all(watches.map(loadWatchedRepo)); + return items.filter((item): item is WatchedRepo => Boolean(item)); + }, + })); + + const watchCount = createMemo(() => watchesQuery.data?.length ?? 0); + + return ( +
+
+
+

watched repos

+

your daily brief for the repositories you care about.

+
+ + {watchCount()} {watchCount() === 1 ? 'repo' : 'repos'} + +
+ +

sign in to watch repositories

watch a repository to keep its recent issues, pull requests, and commits in one place.

}> + loading your watched repos...}> + }> + 0} fallback={

nothing watched yet

open a repository and choose watch to start your daily brief.

find a repository
}> +
+ {(item) => } +
+
+
+
+
+ + ); +}; + +export const WatchingPage: Component = () => ; diff --git a/src/lib/api/constants.ts b/src/lib/api/constants.ts --- a/src/lib/api/constants.ts +++ b/src/lib/api/constants.ts @@ -22,4 +22,4 @@ export const PULL_STATUS_COLLECTION: Nsid = 'sh.tangled.repo.pull.status'; export const FEED_COMMENT_COLLECTION: Nsid = 'sh.tangled.feed.comment'; export const STRING_COLLECTION: Nsid = 'sh.tangled.string'; - +export const WATCH_COLLECTION: Nsid = 'place.wisp.untangled.v1.watch'; diff --git a/src/lib/api/watches.ts b/src/lib/api/watches.ts new file mode 100644 --- /dev/null +++ b/src/lib/api/watches.ts @@ -0,0 +1,96 @@ +import { ok } from '@atcute/client'; +import type { ResolvedActor } from '@atcute/identity-resolver'; +import type { ResourceUri } from '@atcute/lexicons/syntax'; +import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; + +import { WATCH_COLLECTION } from './constants'; +import { getRpc } from './client'; +import { createAuthRpc, resolveActor } from './identity'; +import { parseAtUri, type RecordCreationResult } from './records'; + +export interface WatchRecordValue { + $type: typeof WATCH_COLLECTION; + repo: ResourceUri; + createdAt: string; +} + +export interface WatchRecord { + uri: ResourceUri; + cid: string; + rkey: string; + value: WatchRecordValue; +} + +export const listWatchRecords = async (actorIdentifier: string | ResolvedActor): Promise => { + const actor = typeof actorIdentifier === 'string' ? await resolveActor(actorIdentifier) : actorIdentifier; + const rpc = getRpc(actor.pds); + const records: WatchRecord[] = []; + let cursor: string | undefined; + + do { + const page = await ok( + rpc.get('com.atproto.repo.listRecords', { + params: { + repo: actor.did, + collection: WATCH_COLLECTION, + limit: 100, + cursor, + reverse: true, + }, + }), + ); + + records.push( + ...page.records.map((record) => ({ + uri: record.uri, + cid: record.cid, + rkey: parseAtUri(record.uri).rkey, + value: record.value as unknown as WatchRecordValue, + })), + ); + cursor = page.cursor; + } while (cursor); + + return records.filter((record) => record.value?.repo?.startsWith('at://')); +}; + +export const findWatchRecord = (records: WatchRecord[], repoUri: ResourceUri): WatchRecord | undefined => + records.find((record) => record.value.repo === repoUri); + +export const createWatch = async ( + agent: OAuthUserAgent, + repoUri: ResourceUri, +): Promise => { + const rpc = createAuthRpc(agent); + const result = await ok( + rpc.post('com.atproto.repo.createRecord', { + input: { + repo: agent.sub, + collection: WATCH_COLLECTION, + record: { + $type: WATCH_COLLECTION, + repo: repoUri, + createdAt: new Date().toISOString(), + }, + }, + }), + ); + + return { + uri: result.uri, + rkey: parseAtUri(result.uri).rkey, + }; +}; + +export const deleteWatch = async (agent: OAuthUserAgent, rkey: string): Promise => { + const rpc = createAuthRpc(agent); + await ok( + rpc.post('com.atproto.repo.deleteRecord', { + input: { + repo: agent.sub, + collection: WATCH_COLLECTION, + rkey, + }, + }), + ); +}; diff --git a/src/pages/repo/shared.tsx b/src/pages/repo/shared.tsx --- a/src/pages/repo/shared.tsx +++ b/src/pages/repo/shared.tsx @@ -1,4 +1,4 @@ -import { CircleDot, GitFork, GitPullRequest, Globe, LoaderCircle, Rss, SquareChartGantt, Star } from 'lucide-solid'; +import { CircleDot, Eye, EyeOff, GitFork, GitPullRequest, Globe, LoaderCircle, Rss, SquareChartGantt, Star } from 'lucide-solid'; import type { Did } from '@atcute/lexicons/syntax'; import { A, type RouteSectionProps, useParams } from '@solidjs/router'; import { createQuery } from '../../lib/atproto-query'; @@ -9,6 +9,7 @@ import { resolveActor } from '../../lib/api/identity'; import { parseAtUri } from '../../lib/api/records'; import { createRepoStar, deleteRepoStar, getRepoStarSummary } from '../../lib/api/stars'; +import { createWatch, deleteWatch, findWatchRecord, listWatchRecords } from '../../lib/api/watches'; import { useAuth } from '../../lib/auth'; import { useLiveEvents } from '../../lib/live-events'; import { Avatar, ErrorState, cardStyles } from '../../components/common'; @@ -63,6 +64,10 @@ const [starError, setStarError] = createSignal(null); const [starWorking, setStarWorking] = createSignal(false); const [optimisticStar, setOptimisticStar] = createSignal(null); + const [watchError, setWatchError] = createSignal(null); + const [watchWorking, setWatchWorking] = createSignal(false); + const [optimisticWatched, setOptimisticWatched] = createSignal(null); + const [watchRkey, setWatchRkey] = createSignal(null); const starSummaryQuery = createQuery(() => ({ queryKey: ['repo-star-summary', repoQuery.data?.repoDid, auth.currentDid()], enabled: Boolean(repoQuery.data), @@ -82,6 +87,14 @@ queryKey: ['repo-fork-count', repoQuery.data?.repoDid], enabled: Boolean(repoQuery.data), queryFn: async () => getRepoForkCount(repoQuery.data!), + })); + const watchQuery = createQuery(() => ({ + queryKey: ['repo-watch', auth.currentDid(), repoQuery.data?.record.uri], + enabled: Boolean(auth.currentDid() && repoQuery.data), + queryFn: async () => { + const records = await listWatchRecords(auth.currentDid()!); + return findWatchRecord(records, repoQuery.data!.record.uri); + }, })); const sourceRepoInfo = createMemo(() => { @@ -225,6 +238,51 @@ } }; + const visibleWatched = () => optimisticWatched() ?? Boolean(watchQuery.data); + const watchBusy = () => watchWorking() || (Boolean(auth.currentDid()) && watchQuery.isLoading); + + const watch = async () => { + const agent = auth.agent(); + const repo = repoQuery.data; + if (!agent || !repo || watchBusy() || visibleWatched()) { + return; + } + + setWatchWorking(true); + setWatchError(null); + setOptimisticWatched(true); + try { + const result = await createWatch(agent, repo.record.uri); + setWatchRkey(result.rkey); + } catch (cause) { + setOptimisticWatched(null); + setWatchError(getErrorMessage(cause)); + } finally { + setWatchWorking(false); + } + }; + + const unwatch = async () => { + const agent = auth.agent(); + const rkey = watchRkey() ?? watchQuery.data?.rkey; + if (!agent || !rkey || watchBusy()) { + return; + } + + setWatchWorking(true); + setWatchError(null); + setOptimisticWatched(false); + try { + await deleteWatch(agent, rkey); + setWatchRkey(null); + } catch (cause) { + setOptimisticWatched(null); + setWatchError(getErrorMessage(cause)); + } finally { + setWatchWorking(false); + } + }; + createEffect(() => { const repo = repoQuery.data; if (!repo) { @@ -339,6 +397,37 @@ - + + +
+ alert('sign in to watch repositories')} + > + + watch + + } + > + }> + +
diff --git a/lexicons/place/wisp/untangled/v1/watch.json b/lexicons/place/wisp/untangled/v1/watch.json new file mode 100644 --- /dev/null +++ b/lexicons/place/wisp/untangled/v1/watch.json @@ -0,0 +1,26 @@ +{ + "lexicon": 1, + "id": "place.wisp.untangled.v1.watch", + "defs": { + "main": { + "type": "record", + "description": "a user's watched repository preference.", + "key": "tid", + "record": { + "type": "object", + "required": ["repo", "createdAt"], + "properties": { + "repo": { + "type": "string", + "format": "at-uri", + "description": "the at-uri of the tangled repository record." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } +} -- tangled.sh