diff --git a/.gitignore b/.gitignore index 76128a6..bf9bcbc 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ dist-ssr *.sw? /tmp /result +.envrc +.direnv diff --git a/src/components/repo.tsx b/src/components/repo.tsx index f9d5f90..ae4dd65 100644 --- a/src/components/repo.tsx +++ b/src/components/repo.tsx @@ -1017,6 +1017,16 @@ const highlightDiffLine = (line: string, language?: string) => { return `${escapeHtml(marker)}${highlighted}`; }; +/** Parse a URL hash like #L5 or #L3-L8 into {start, end} (1-based, inclusive). */ +const parseLineHash = (hash: string): { start: number; end: number } | null => { + const m = /^#L(\d+)(?:-L?(\d+))?$/.exec(hash); + if (!m) return null; + const start = parseInt(m[1], 10); + const end = m[2] ? parseInt(m[2], 10) : start; + if (isNaN(start) || isNaN(end)) return null; + return { start: Math.min(start, end), end: Math.max(start, end) }; +}; + export const CodeView: Component<{ text: string; id?: string; @@ -1041,6 +1051,57 @@ export const CodeView: Component<{ ) : undefined; + // Line selection state (only used in non-diff mode) + const [selection, setSelection] = createSignal<{ start: number; end: number } | null>(null); + // The line number of the last plain click (anchor for shift-click ranges) + let anchorLine: number | null = null; + + const applyHashSelection = (scroll = false) => { + const parsed = parseLineHash(window.location.hash); + setSelection(parsed); + if (scroll && parsed) { + requestAnimationFrame(() => { + const target = document.getElementById(`L${parsed.start}`); + target?.scrollIntoView({ behavior: 'instant', block: 'center' }); + }); + } + }; + + const handleLineNumberClick = (lineNum: number, event: MouseEvent) => { + event.preventDefault(); + let hash: string; + if (event.shiftKey && anchorLine !== null) { + const lo = Math.min(anchorLine, lineNum); + const hi = Math.max(anchorLine, lineNum); + hash = `#L${lo}-L${hi}`; + } else { + anchorLine = lineNum; + hash = `#L${lineNum}`; + } + history.pushState(null, '', hash); + setSelection(parseLineHash(hash)); + }; + + onMount(() => { + if (props.diff) return; + + applyHashSelection(true); + + const onHashChange = () => applyHashSelection(false); + window.addEventListener('hashchange', onHashChange); + window.addEventListener('popstate', onHashChange); + onCleanup(() => { + window.removeEventListener('hashchange', onHashChange); + window.removeEventListener('popstate', onHashChange); + }); + }); + + const isLineHighlighted = (lineNum: number) => { + const s = selection(); + if (!s) return false; + return lineNum >= s.start && lineNum <= s.end; + }; + return (
- {(line, index) => ( - - )} + {(line, index) => { + const lineNum = index() + 1; + return ( + + ); + }}
diff --git a/src/index.css b/src/index.css index 5933558..0e0ffe7 100644 --- a/src/index.css +++ b/src/index.css @@ -890,85 +890,675 @@ details[open] > .untangled-language-bar { min-width: 1px; } -.untangled-home-grid { +.untangled-home-page { + display: flex; + min-width: 0; + flex-direction: column; + gap: 1rem; +} + +.untangled-home-command { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(22rem, 32rem); + align-items: center; + gap: 1rem; + border-bottom: 1px solid rgb(229 231 235); + padding: 0.5rem 0.25rem 1rem; +} + +.untangled-home-brand { + display: flex; + min-width: 0; + align-items: center; + gap: 0.75rem; +} + +.untangled-home-brand h1 { + margin: 0; + font-size: 1.875rem; + font-weight: 800; + letter-spacing: 0; + line-height: 1.1; +} + +.untangled-home-brand p { + margin: 0.125rem 0 0; + color: rgb(107 114 128); + font-size: 0.875rem; +} + +.untangled-home-jump { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.5rem; +} + +.untangled-home-jump .untangled-input { + min-height: 2.25rem; + padding-top: 0.375rem; + padding-bottom: 0.375rem; +} + +.untangled-home-workspace { display: grid; - grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); - gap: 1.25rem; + grid-template-columns: 14.5rem minmax(0, 1fr) 16rem; + gap: 1rem; align-items: start; } -.untangled-home-grid > :first-child { - padding: 1.5rem; +.untangled-home-side { + position: sticky; + top: 0.75rem; + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem 0; } -.untangled-home-grid > :first-child > .flex:first-child { - margin-bottom: 1.5rem; +.untangled-home-side-left { + border-right: 1px solid rgb(229 231 235); + padding-right: 1rem; } -.untangled-home-grid form .flex { - align-items: stretch; +.untangled-home-side-right { + border-left: 1px solid rgb(229 231 235); + padding-left: 1rem; } -.untangled-home-grid form button { - min-width: 4.5rem; +.untangled-home-section-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid rgb(229 231 235); + color: inherit; +} + +.untangled-home-section-header h2 { + margin: 0; + font-size: 0.8125rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: rgb(107 114 128); +} + +.untangled-home-feed { + min-width: 0; +} + +.untangled-home-feed-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid rgb(229 231 235); + margin-bottom: 0.5rem; +} + +.untangled-home-feed-title { + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: rgb(107 114 128); +} + +.untangled-home-live-pill { + display: inline-flex; + align-items: center; justify-content: center; + border: 1px solid rgb(209 213 219); + border-radius: 9999px; + padding: 0.25rem 0.625rem; + color: rgb(75 85 99); + font-size: 0.75rem; + white-space: nowrap; } -.untangled-home-grid form button:hover { - color: rgb(255 255 255); +.untangled-home-activity-list { + display: flex; + min-width: 0; + flex-direction: column; + gap: 1rem; } -.untangled-home-grid a { - transition: - background-color 150ms, - border-color 150ms, - color 150ms, - transform 150ms; +/* upstream-style: individual bordered card per event, with connector lines */ +.untangled-home-activity-card-wrap { + position: relative; + min-width: 0; +} + +.untangled-home-activity-connector { + position: absolute; + left: 1rem; + top: -1rem; + width: 1px; + height: 1rem; + background: rgb(209 213 219); } -.untangled-home-grid a:hover { +.untangled-home-activity-card { + display: flex; + flex-direction: column; + border: 1px solid rgb(229 231 235); + border-radius: 0.125rem; + overflow: hidden; +} + +.untangled-home-activity-card-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + color: rgb(75 85 99); + font-size: 0.8125rem; +} + +.untangled-home-activity-header-text { + display: flex; + min-width: 0; + flex: 1; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + overflow: hidden; +} + +.untangled-home-activity-repo-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; + color: rgb(17 24 39); + font-size: 0.8125rem; +} + +.untangled-home-activity-time { + flex-shrink: 0; + color: rgb(107 114 128); + font-size: 0.75rem; +} + +.untangled-home-activity-card-body { + display: block; + padding: 0.5rem 0.75rem; background: rgb(249 250 251); - border-color: rgb(156 163 175); + border-top: 1px solid rgb(229 231 235); + color: rgb(55 65 81); + font-size: 0.8125rem; + line-height: 1.4; + text-decoration: none; + transition: background-color 150ms; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + display: -webkit-box; + overflow: hidden; +} + +.untangled-home-activity-card-body:hover { + background: rgb(243 244 246); + text-decoration: none; +} + +.untangled-home-activity-icon { + display: inline-flex; + width: 1.5rem; + height: 1.5rem; + align-items: center; + justify-content: center; + border-radius: 9999px; + background: rgb(239 246 255); + color: rgb(37 99 235); + flex-shrink: 0; +} + +.untangled-home-activity-icon-issue { + background: rgb(220 252 231); + color: rgb(22 101 52); +} + +.untangled-home-activity-icon-pull { + background: rgb(243 232 255); + color: rgb(126 34 206); +} + +.untangled-home-activity-icon-repo { + background: rgb(241 245 249); + color: rgb(51 65 85); +} + +/* old copy/meta styles no longer used but kept for compat with following activity */ +.untangled-home-activity-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.375rem; +} + +.untangled-home-activity-title { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: baseline; + gap: 0.35rem; + color: rgb(17 24 39); + font-size: 0.9375rem; +} + +.untangled-home-activity-title strong { + min-width: 0; + overflow-wrap: anywhere; + font-weight: 700; +} + +.untangled-home-activity-body { + display: -webkit-box; + overflow: hidden; + color: rgb(75 85 99); + font-size: 0.875rem; + line-height: 1.4; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.untangled-home-activity-meta { + display: flex; + align-items: center; + gap: 0.5rem; + color: rgb(107 114 128); + font-size: 0.8125rem; +} + +.untangled-home-feed-empty { + display: flex; + min-height: 18rem; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2rem; + text-align: center; + color: rgb(107 114 128); +} + +.untangled-home-feed-empty h3 { + margin: 0.5rem 0 0; + color: rgb(31 41 55); + font-size: 1rem; + font-weight: 700; +} + +.untangled-home-feed-empty p { + max-width: 22rem; + margin: 0; + font-size: 0.875rem; + line-height: 1.5; +} + +.untangled-home-compact-list, +.untangled-home-following-list { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0; +} + +/* upstream repoCard style: flat link with icon + name, description below, time at right */ +.untangled-home-repo-row { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.125rem; + border: 1px solid rgb(229 231 235); + border-radius: 0.125rem; + padding: 0.625rem 0.75rem; + margin-bottom: 0.25rem; color: inherit; + text-decoration: none; + transition: background-color 150ms; + background: rgb(255 255 255); +} + +.untangled-home-repo-row:last-child { + border-bottom-width: 1px; + margin-bottom: 0; +} + +.untangled-home-repo-row:hover { + background: rgb(249 250 251); + text-decoration: none; +} + +.untangled-home-repo-row-name { + display: flex; + align-items: center; + gap: 0.375rem; + min-width: 0; + font-weight: 600; + font-size: 0.875rem; + color: rgb(17 24 39); + overflow: hidden; } -.untangled-home-grid > :first-child a { +.untangled-home-repo-row-name span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.untangled-home-repo-row-desc { + color: rgb(75 85 99); + font-size: 0.75rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; display: block; +} + +.untangled-home-repo-row-time { + color: rgb(107 114 128); + font-size: 0.75rem; + margin-top: 0.125rem; +} + +.untangled-home-following-row { + display: flex; min-width: 0; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.125rem; + color: inherit; + transition: background-color 150ms; } -.untangled-home-grid > .flex.flex-col.gap-4 { - min-height: 0; +.untangled-home-following-row span { + display: flex; + min-width: 0; + flex-direction: column; +} + +.untangled-home-following-row span span { + overflow: hidden; + color: rgb(17 24 39); + font-size: 0.8125rem; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; } -.untangled-home-grid > .flex.flex-col.gap-4 > .rounded { +.untangled-home-following-row small { + color: rgb(107 114 128); + font-size: 0.75rem; +} + +.untangled-home-empty { + padding: 0.75rem 0.125rem; + color: rgb(107 114 128); + font-size: 0.875rem; + line-height: 1.4; +} + +.untangled-home-below { display: flex; - min-height: 0; flex-direction: column; - padding: 1.25rem; + gap: 1.5rem; + padding-top: 0.5rem; } -.untangled-home-grid > .flex.flex-col.gap-4 > .rounded > .flex.flex-col.gap-2 { - max-height: 16rem; - overflow-y: auto; - padding-right: 0.125rem; +.untangled-home-wide-section { + display: flex; + flex-direction: column; + gap: 0.75rem; + min-width: 0; + border-top: 1px solid rgb(209 213 219); + padding-top: 0.75rem; } -.untangled-home-grid > .flex.flex-col.gap-4 > .rounded a { - display: block; +.untangled-home-lower-grid { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(18rem, 0.8fr); + gap: 1.5rem; + align-items: start; } -.untangled-home-grid > .flex.flex-col.gap-4 > .rounded a:hover { - background: rgb(249 250 251); +.untangled-home-recent-strip, +.untangled-home-sample-list { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0; + margin-top: 0.75rem; + border-top: 1px solid rgb(229 231 235); + border-left: 1px solid rgb(229 231 235); +} + +.untangled-home-recent-link, +.untangled-home-sample-link { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.25rem; + border-right: 1px solid rgb(229 231 235); + border-bottom: 1px solid rgb(229 231 235); + padding: 0.875rem; + color: inherit; + text-decoration: none; + transition: background-color 150ms; +} + +.untangled-home-recent-link:hover, +.untangled-home-sample-link:hover { + background: rgb(255 255 255); + text-decoration: none; +} + +.untangled-home-recent-link span, +.untangled-home-sample-link strong { + overflow: hidden; + color: rgb(17 24 39); + font-size: 0.875rem; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.untangled-home-recent-link small, +.untangled-home-sample-link span { + overflow: hidden; + color: rgb(107 114 128); + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.untangled-home-sample-list { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.untangled-home-sample-list-compact { + grid-template-columns: minmax(0, 1fr); } @media (prefers-color-scheme: dark) { - .untangled-home-grid a:hover, - .untangled-home-grid > .flex.flex-col.gap-4 > .rounded a:hover { - background: rgb(55 65 81 / 0.4); - border-color: rgb(75 85 99); + .untangled-home-command, + .untangled-home-side-left, + .untangled-home-side-right, + .untangled-home-section-header, + .untangled-home-feed-header, + .untangled-home-wide-section, + .untangled-home-recent-strip, + .untangled-home-sample-list, + .untangled-home-recent-link, + .untangled-home-sample-link { + border-color: rgb(55 65 81); + } + + .untangled-home-activity-card, + .untangled-home-repo-row { + border-color: rgb(55 65 81); + } + + .untangled-home-activity-connector { + background: rgb(55 65 81); + } + + .untangled-home-activity-card-header { + background: rgb(31 41 55); + color: rgb(156 163 175); + } + + .untangled-home-activity-card-body { + background: rgb(17 24 39); + border-color: rgb(55 65 81); + color: rgb(209 213 219); + } + + .untangled-home-activity-card-body:hover { + background: rgb(31 41 55); + } + + .untangled-home-activity-repo-label { + color: rgb(243 244 246); + } + + .untangled-home-activity-time { + color: rgb(107 114 128); + } + + .untangled-home-repo-row { + background: rgb(31 41 55); + } + + .untangled-home-repo-row:hover { + background: rgb(55 65 81 / 0.6); + } + + .untangled-home-repo-row-name { + color: rgb(243 244 246); + } + + .untangled-home-repo-row-desc, + .untangled-home-repo-row-time, + .untangled-home-following-row small { + color: rgb(107 114 128); + } + + .untangled-home-brand p, + .untangled-home-activity-body, + .untangled-home-activity-meta, + .untangled-home-feed-empty, + .untangled-home-empty, + .untangled-home-recent-link small, + .untangled-home-sample-link span { + color: rgb(156 163 175); + } + + .untangled-home-feed-empty h3, + .untangled-home-activity-title, + .untangled-home-following-row span span, + .untangled-home-recent-link span, + .untangled-home-sample-link strong { color: rgb(243 244 246); } + + .untangled-home-live-pill { + border-color: rgb(75 85 99); + color: rgb(209 213 219); + } + + .untangled-home-recent-link:hover, + .untangled-home-sample-link:hover { + background: rgb(55 65 81 / 0.35); + } + + .untangled-home-activity-icon { + background: rgb(15 23 42); + color: rgb(191 219 254); + } + + .untangled-home-activity-icon-issue { + background: rgb(22 101 52 / 0.45); + color: rgb(134 239 172); + } + + .untangled-home-activity-icon-pull { + background: rgb(88 28 135 / 0.55); + color: rgb(216 180 254); + } + + .untangled-home-activity-icon-repo { + background: rgb(15 23 42); + color: rgb(191 219 254); + } +} + +@media (max-width: 1023px) { + .untangled-home-command { + grid-template-columns: minmax(0, 1fr); + } + + .untangled-home-workspace { + display: flex; + flex-direction: column; + } + + .untangled-home-feed { + order: -1; + width: 100%; + } + + .untangled-home-side { + position: static; + width: 100%; + border-right: 0; + border-left: 0; + border-top: 1px solid rgb(229 231 235); + padding: 0.75rem 0 0; + } + + .untangled-home-lower-grid, + .untangled-home-recent-strip, + .untangled-home-sample-list { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 1023px) and (prefers-color-scheme: dark) { + .untangled-home-side { + border-top-color: rgb(55 65 81); + } +} + +@media (max-width: 767px) { + .untangled-home-brand h1 { + font-size: 1.5rem; + } + + .untangled-home-jump { + grid-template-columns: auto minmax(0, 1fr); + } + + .untangled-home-jump button { + grid-column: 1 / -1; + } + + .untangled-home-feed-header { + align-items: flex-start; + flex-direction: column; + } + + .untangled-home-activity-row { + padding-right: 1rem; + padding-left: 1rem; + } } .untangled-safe-footer { @@ -1644,6 +2234,20 @@ details[open] > .untangled-language-bar { .untangled-code-line-number:hover { text-decoration: underline; + cursor: pointer; +} + +/* Highlighted line (selected via line number click or URL hash) */ +.untangled-code-line--hl .untangled-code-line-number, +.untangled-code-line--hl .untangled-code-line-content { + background-color: rgb(254 240 138 / 0.4); +} + +@media (prefers-color-scheme: dark) { + .untangled-code-line--hl .untangled-code-line-number, + .untangled-code-line--hl .untangled-code-line-content { + background-color: rgb(161 98 7 / 0.3); + } } .untangled-code-line-content { @@ -2231,8 +2835,7 @@ details[open] > .untangled-language-bar { @media (max-width: 767px) { .untangled-repo-overview, - .untangled-filter-grid, - .untangled-home-grid { + .untangled-filter-grid { grid-template-columns: minmax(0, 1fr); } diff --git a/src/lib/api/graph.ts b/src/lib/api/graph.ts new file mode 100644 index 0000000..b2434bb --- /dev/null +++ b/src/lib/api/graph.ts @@ -0,0 +1,50 @@ +import { ok } from '@atcute/client'; +import type { ResolvedActor } from '@atcute/identity-resolver'; +import type { Nsid, ResourceUri } from '@atcute/lexicons/syntax'; +import { ShTangledGraphFollow } from '@atcute/tangled'; + +import { getRpc } from './client'; +import { resolveActor } from './identity'; +import { parseAtUri } from './records'; + +const FOLLOW_COLLECTION = 'sh.tangled.graph.follow' as Nsid; + +export interface FollowRecord { + uri: ResourceUri; + cid: string; + rkey: string; + value: ShTangledGraphFollow.Main; +} + +export const listFollowRecords = async (actor: string | ResolvedActor): Promise => { + const resolved = typeof actor === 'string' ? await resolveActor(actor) : actor; + const rpc = getRpc(resolved.pds); + const records: FollowRecord[] = []; + let cursor: string | undefined; + + do { + const page = await ok( + rpc.get('com.atproto.repo.listRecords', { + params: { + repo: resolved.did, + collection: FOLLOW_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 ShTangledGraphFollow.Main, + })), + ); + cursor = page.cursor; + } while (cursor); + + return records; +}; diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 45d899a..d00b166 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -1,13 +1,26 @@ import clsx from 'clsx'; -import { A, useNavigate } from '@solidjs/router'; +import { + BookMarked, + CircleDot, + FolderCode, + GalleryVertical, + GitPullRequest, + LoaderCircle, + Star, + Users, + UserRound, +} from 'lucide-solid'; +import { A } from '@solidjs/router'; import { createQuery } from '@tanstack/solid-query'; -import { For, Show, createMemo, createSignal, type Component } from 'solid-js'; +import { For, Show, createEffect, createMemo, onCleanup, type Component, type JSX } from 'solid-js'; -import { LoadingState, buttonStyles, cardStyles, inputStyles } from '../components/common'; +import { Avatar, ErrorState, LoadingState } from '../components/common'; +import { listFollowRecords } from '../lib/api/graph'; import { resolveActor } from '../lib/api/identity'; -import { listRepoRecords } from '../lib/api/repos'; +import { listRepoRecords, type RepoRecord } from '../lib/api/repos'; import { useAuth } from '../lib/auth'; -import { formatRelativeTime, loadRecentRepos, parseRepoJump } from '../lib/repo-utils'; +import { useLiveEvents, type LiveEvent } from '../lib/live-events'; +import { formatRelativeTime, loadRecentRepos } from '../lib/repo-utils'; const SAMPLE_REPOS = [ { owner: 'mary.my.id', slug: 'atcute' }, @@ -15,88 +28,366 @@ const SAMPLE_REPOS = [ { owner: 'mary.my.id', slug: 'aglais' }, ]; +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; + title: string; + href: string; + rkey: string; + repoDid?: string; + description?: string; + createdAt?: string; +}; + +type HomeActivityKind = 'issue' | 'pull' | 'repo'; + +type HomeActivityItem = { + id: string; + kind: HomeActivityKind; + title: string; + repoLabel: string; + href: string; + at?: string; + body?: string; + actorDid?: string; +}; + +type FollowingHomeData = { + actors: Array<{ + did: string; + handle: string; + repoCount: number; + }>; + activity: HomeActivityItem[]; +}; + +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; +}; + +const formatCount = (count: number, singular: string, plural = `${singular}s`) => + `${count} ${count === 1 ? singular : plural}`; + +const homeRepoItem = (owner: string, repo: RepoRecord): HomeRepoItem => { + const slug = repoSlug(repo); + return { + owner, + slug, + title: `${owner}/${slug}`, + href: `/${owner}/${slug}`, + rkey: repo.rkey, + repoDid: repo.value.repoDid, + 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 => { + const follows = await listFollowRecords(viewerDid); + const resolved = await Promise.all( + follows.slice(0, 8).map(async (follow) => { + try { + const actor = await resolveActor(follow.value.subject); + const repos = await listRepoRecords(actor).catch(() => []); + return { + follow, + actor, + repos, + }; + } catch { + return null; + } + }), + ); + + const actors = resolved + .filter((item): item is NonNullable => Boolean(item)) + .map((item) => ({ + did: item.actor.did, + handle: item.actor.handle, + repoCount: item.repos.length, + })); + + const activity = resolved + .filter((item): item is NonNullable => Boolean(item)) + .flatMap((item) => + item.repos.map((repo) => { + const repoItem = homeRepoItem(item.actor.handle, repo); + return { + id: `following-repo:${repo.uri}`, + kind: 'repo' as const, + title: 'repository published', + repoLabel: repoItem.title, + href: repoItem.href, + at: repoItem.createdAt, + body: repoItem.description, + actorDid: item.actor.did, + }; + }), + ) + .sort((left, right) => activityTimestamp(right) - activityTimestamp(left)) + .slice(0, 8); + + return { actors, activity }; +}; + export const HomePage: Component = () => { const auth = useAuth(); - const [jump, setJump] = createSignal(''); + const live = useLiveEvents(); const recentRepos = createMemo(loadRecentRepos); - const navigate = useNavigate(); const ownReposQuery = createQuery(() => ({ queryKey: ['own-repos', auth.currentDid()], enabled: Boolean(auth.currentDid()), queryFn: async () => { const actor = await resolveActor(auth.currentDid()!); - return listRepoRecords(actor); + const records = await listRepoRecords(actor); + return { actor, records }; }, })); - const onSubmit = (event: SubmitEvent) => { - event.preventDefault(); - const parsed = parseRepoJump(jump()); - if (!parsed) { + const followingQuery = createQuery(() => ({ + queryKey: ['home-following', auth.currentDid()], + enabled: Boolean(auth.currentDid()), + queryFn: async () => fetchFollowingHomeData(auth.currentDid()!), + })); + + 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 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; } - navigate(`/${parsed.owner}/${parsed.repo}`); - }; + 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), + at: event.receivedAt, + }; + }) + .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, + })); + + return [...liveItems, ...repoItems] + .sort((left, right) => activityTimestamp(right) - activityTimestamp(left)) + .slice(0, 8); + }); return ( -
-
-
-
- -
-

untangled

-

tightly-knit social coding

-
+
+
+ + +
+
+

+ + activity +

+ + + 0} fallback="idle"> + {formatCount(ownRepoItems().length, 'repo')} watched + + +
-
- -
- setJump(event.currentTarget.value)} - placeholder="mary.my.id/atcute" - class={inputStyles()} - /> - -
-
+ } + > + } + > + } + > + 0} + fallback={} + > +
+ + {(item, index) => } + +
+
+
+
+
+
-
-
sample repositories
-
- - {(repo) => ( - -
- {repo.owner}/{repo.slug} -
-
- )} -
-
-
-
-
+ + -
-
-
recent
+
+
+ } title="recent repositories" /> 0} - fallback={
No recent repositories.
} + fallback={} > -
+
{(repo) => ( - -
{repo.title}
-
{formatRelativeTime(repo.updatedAt)}
+
+ {repo.title} + {formatRelativeTime(repo.updatedAt)} )}
@@ -104,32 +395,143 @@ export const HomePage: Component = () => {
- -
-
your repositories
+
+
+ } title="following activity" /> } + when={auth.currentDid()} + fallback={} > - + } + > + } + > + 0} + fallback={} + > +
+ + {(item, index) => } + +
+
+
+
-
- -
+
+ +
+ } title="sample repositories" /> + +
+
+
); }; + +const HomeSectionHeader: Component<{ icon: JSX.Element; title: string }> = (props) => ( +
+ {props.icon} +

{props.title}

+
+); + +const HomeEmpty: Component<{ label: string }> = (props) => ( +
{props.label}
+); + +const HomeFeedEmpty: Component<{ title: string; body: string }> = (props) => ( +
+ +

{props.title}

+

{props.body}

+
+); + +const HomeFeedLoading: Component = () => ( +
+ +

Loading activity

+

Checking your repositories.

+
+); + +const RepoLinkRow: Component<{ + href: string; + title: string; + description?: string; + meta?: string; +}> = (props) => ( + + + + {props.title} + + + {props.description} + + + + + +); + +const ActivityRow: Component<{ item: HomeActivityItem; index: number }> = (props) => ( +
+ 0}> +
+ +
+
+ + + + + + + + {props.item.title} + {props.item.repoLabel} + + +
+ + + {props.item.body} + + +
+
+); + +const ActivityIcon: Component<{ kind: HomeActivityKind }> = (props) => ( + }> + + + } + > + +
+); + +const HomeSampleRepos: Component<{ compact?: boolean }> = (props) => ( +
+ + {(repo) => ( + + {repo.owner} + {repo.slug} + + )} + +
+);