diff --git a/AGENTS.md b/AGENTS.md index 3fb67f9..ea277ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,8 @@ rtk npm run build ``` 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 index 0000000..9257add --- /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/lexicons/place/wisp/untangled/v1/watch.json b/lexicons/place/wisp/untangled/v1/watch.json new file mode 100644 index 0000000..f1fe30d --- /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" + } + } + } + } + } +} diff --git a/public/oauth-client-metadata.json b/public/oauth-client-metadata.json index d632bd0..3e669cc 100644 --- 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 index 126560b..050c11a 100644 --- a/src/index.css +++ b/src/index.css @@ -1481,11 +1481,9 @@ details[open] > .untangled-language-bar { 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 @@ details[open] > .untangled-language-bar { @media (min-width: 1280px) { .untangled-home-below { - padding-left: 4rem; - padding-right: 4rem; + padding-left: 0; + padding-right: 0; } } @@ -4005,3 +4003,298 @@ details[open] > .untangled-language-bar { 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 index f6bfabe..371e5e4 100644 --- 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'; @@ -306,6 +306,10 @@ const Topbar: Component = () => { + } diff --git a/src/lib/api.ts b/src/lib/api.ts index 1479a1d..d2bf930 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -68,6 +68,12 @@ export { export { searchRecords, } from './api/search'; +export { + createWatch, + deleteWatch, + findWatchRecord, + listWatchRecords, +} from './api/watches'; export { getString, listStringRecords, @@ -140,3 +146,7 @@ export type { SearchRecordsOptions, SearchResponse, } from './api/search'; +export type { + WatchRecord, + WatchRecordValue, +} from './api/watches'; diff --git a/src/lib/api/constants.ts b/src/lib/api/constants.ts index 027310f..52c7a8f 100644 --- a/src/lib/api/constants.ts +++ b/src/lib/api/constants.ts @@ -22,4 +22,4 @@ export const PULL_COMMENT_COLLECTION: Nsid = 'sh.tangled.repo.pull.comment'; 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 index 0000000..0780f04 --- /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/home.tsx b/src/pages/home.tsx index db2c6a4..27e772d 100644 --- 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 { 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 { listFollowRecords } from '../lib/api/graph'; 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 AnnouncementCard: Component<{ ); }; -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 @@ type FollowingHomeData = { 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; @@ -256,19 +242,6 @@ const homeRepoItem = (owner: string, repo: RepoRecord): HomeRepoItem => { }; }; -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 => { const follows = await listFollowRecords(viewerDid); const resolved = await Promise.all( @@ -321,7 +294,6 @@ const fetchFollowingHomeData = async (viewerDid: string): Promise { const auth = useAuth(); - const live = useLiveEvents(); const issuePreloader = useIssuePreloader(); const recentRepos = createMemo(loadRecentRepos); const recentIssuesPulls = createMemo(loadRecentIssuesPulls); @@ -376,129 +348,11 @@ export const HomePage: Component = () => { })); - 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/repo/shared.tsx b/src/pages/repo/shared.tsx index 2a65cc3..88a7e5b 100644 --- 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 { getRepo, getRepoForkCount, getRepoByDid } from '../../lib/api/repos'; 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 @@ export const RepoFrame: Component<{ 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), @@ -83,6 +88,14 @@ export const RepoFrame: Component<{ 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(() => { const source = repoQuery.data?.record.value.source; @@ -225,6 +238,51 @@ export const RepoFrame: Component<{ } }; + 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) { @@ -341,6 +399,37 @@ export const RepoFrame: Component<{
+
+ alert('sign in to watch repositories')} + > + + watch + + } + > + }> + + + +
{ + 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/routes.tsx b/src/routes.tsx index ed847a9..610be4e 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -18,6 +18,7 @@ import { } from './pages/repo'; import { RepoLayout } from './pages/repo/shared'; import { HomePage } from './pages/home'; +import { WatchingPage } from './pages/watching'; import { NotFoundPage } from './pages/not-found'; import { OAuthCallbackPage } from './pages/oauth-callback'; import { SearchPage } from './pages/search'; @@ -39,6 +40,8 @@ import { export const AppRoutes: Component = () => ( + +