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 (
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
-
+
+
+
+
+
+
-
+ }
+ >
+ }
+ >
+ }
+ >
+ 0}
+ fallback={}
+ >
+
+
+ {(item, index) => }
+
+
+
+
+
+
+
-
-
sample repositories
-
-
-
-
+
+
-
-
-
recent
+
+
+
} title="recent repositories" />
0}
- fallback={No recent repositories.
}
+ fallback={}
>
-
+
-
-
-
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) => (
+
+);
+
+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) => (
+
+);
+
+const ActivityIcon: Component<{ kind: HomeActivityKind }> = (props) => (
+ }>
+
+
+ }
+ >
+
+
+);
+
+const HomeSampleRepos: Component<{ compact?: boolean }> = (props) => (
+
+);