mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Distinguish Buzz-hosted repositories from linked GitHub repositories, provide a safe local clone flow for public GitHub remotes, and replace misleading empty states with actionable availability feedback. Signed-off-by: Thomas Petersen <thomasp@squareup.com>
372 lines
10 KiB
TypeScript
372 lines
10 KiB
TypeScript
import type {
|
|
Project,
|
|
ProjectActivitySummary,
|
|
} from "@/features/projects/hooks";
|
|
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
|
import { normalizePubkey } from "@/shared/lib/pubkey";
|
|
|
|
export type ProjectsViewMode = "grid" | "list";
|
|
export type ProjectsRepositoryScope =
|
|
| "all"
|
|
| "mine"
|
|
| "local"
|
|
| "buzz"
|
|
| "linked";
|
|
export type ProjectsWorkItemScope = "all" | "mine";
|
|
export type ProjectsFilter =
|
|
| "all"
|
|
| "mine"
|
|
| "local"
|
|
| "repositories"
|
|
| "prs"
|
|
| "issues"
|
|
| "agents"
|
|
| "users";
|
|
export type ProjectsSort = "updated" | "created" | "name";
|
|
|
|
const PROJECTS_VIEW_MODE_STORAGE_KEY = "buzz.projects.viewMode";
|
|
const PROJECTS_FILTER_STORAGE_KEY = "buzz.projects.filter";
|
|
const PROJECTS_REPOSITORY_SCOPE_STORAGE_KEY = "buzz.projects.repositoryScope";
|
|
const PROJECTS_PULL_REQUEST_SCOPE_STORAGE_KEY =
|
|
"buzz.projects.pullRequestScope";
|
|
const PROJECTS_ISSUE_SCOPE_STORAGE_KEY = "buzz.projects.issueScope";
|
|
const PROJECTS_SORT_STORAGE_KEY = "buzz.projects.sort";
|
|
|
|
export function readStoredViewMode(): ProjectsViewMode | null {
|
|
try {
|
|
const value = globalThis.localStorage?.getItem(
|
|
PROJECTS_VIEW_MODE_STORAGE_KEY,
|
|
);
|
|
return value === "grid" || value === "list" ? value : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function writeStoredViewMode(viewMode: ProjectsViewMode) {
|
|
try {
|
|
globalThis.localStorage?.setItem(PROJECTS_VIEW_MODE_STORAGE_KEY, viewMode);
|
|
} catch {
|
|
// Persistence is best-effort; the in-memory toggle still works.
|
|
}
|
|
}
|
|
|
|
export function readStoredFilter(): ProjectsFilter {
|
|
try {
|
|
const value = globalThis.localStorage?.getItem(PROJECTS_FILTER_STORAGE_KEY);
|
|
return value === "mine" ||
|
|
value === "local" ||
|
|
value === "repositories" ||
|
|
value === "prs" ||
|
|
value === "issues" ||
|
|
value === "agents" ||
|
|
value === "users"
|
|
? value
|
|
: "all";
|
|
} catch {
|
|
return "all";
|
|
}
|
|
}
|
|
|
|
export function writeStoredFilter(filter: ProjectsFilter) {
|
|
try {
|
|
globalThis.localStorage?.setItem(PROJECTS_FILTER_STORAGE_KEY, filter);
|
|
} catch {
|
|
// Persistence is best-effort; the in-memory toggle still works.
|
|
}
|
|
}
|
|
|
|
export function readStoredRepositoryScope(): ProjectsRepositoryScope {
|
|
try {
|
|
const value = globalThis.localStorage?.getItem(
|
|
PROJECTS_REPOSITORY_SCOPE_STORAGE_KEY,
|
|
);
|
|
if (
|
|
value === "mine" ||
|
|
value === "local" ||
|
|
value === "buzz" ||
|
|
value === "linked"
|
|
) {
|
|
return value;
|
|
}
|
|
const legacyFilter = globalThis.localStorage?.getItem(
|
|
PROJECTS_FILTER_STORAGE_KEY,
|
|
);
|
|
return legacyFilter === "mine" || legacyFilter === "local"
|
|
? legacyFilter
|
|
: "all";
|
|
} catch {
|
|
return "all";
|
|
}
|
|
}
|
|
|
|
export function writeStoredRepositoryScope(scope: ProjectsRepositoryScope) {
|
|
try {
|
|
globalThis.localStorage?.setItem(
|
|
PROJECTS_REPOSITORY_SCOPE_STORAGE_KEY,
|
|
scope,
|
|
);
|
|
} catch {
|
|
// Persistence is best-effort; the in-memory filter still works.
|
|
}
|
|
}
|
|
|
|
function readStoredWorkItemScope(key: string): ProjectsWorkItemScope {
|
|
try {
|
|
return globalThis.localStorage?.getItem(key) === "mine" ? "mine" : "all";
|
|
} catch {
|
|
return "all";
|
|
}
|
|
}
|
|
|
|
function writeStoredWorkItemScope(key: string, scope: ProjectsWorkItemScope) {
|
|
try {
|
|
globalThis.localStorage?.setItem(key, scope);
|
|
} catch {
|
|
// Persistence is best-effort; the in-memory filter still works.
|
|
}
|
|
}
|
|
|
|
export function readStoredPullRequestScope(): ProjectsWorkItemScope {
|
|
return readStoredWorkItemScope(PROJECTS_PULL_REQUEST_SCOPE_STORAGE_KEY);
|
|
}
|
|
|
|
export function writeStoredPullRequestScope(scope: ProjectsWorkItemScope) {
|
|
writeStoredWorkItemScope(PROJECTS_PULL_REQUEST_SCOPE_STORAGE_KEY, scope);
|
|
}
|
|
|
|
export function readStoredIssueScope(): ProjectsWorkItemScope {
|
|
return readStoredWorkItemScope(PROJECTS_ISSUE_SCOPE_STORAGE_KEY);
|
|
}
|
|
|
|
export function writeStoredIssueScope(scope: ProjectsWorkItemScope) {
|
|
writeStoredWorkItemScope(PROJECTS_ISSUE_SCOPE_STORAGE_KEY, scope);
|
|
}
|
|
|
|
export function readStoredSort(): ProjectsSort {
|
|
try {
|
|
const value = globalThis.localStorage?.getItem(PROJECTS_SORT_STORAGE_KEY);
|
|
return value === "created" || value === "name" ? value : "updated";
|
|
} catch {
|
|
return "updated";
|
|
}
|
|
}
|
|
|
|
export function writeStoredSort(sort: ProjectsSort) {
|
|
try {
|
|
globalThis.localStorage?.setItem(PROJECTS_SORT_STORAGE_KEY, sort);
|
|
} catch {
|
|
// Persistence is best-effort; the in-memory toggle still works.
|
|
}
|
|
}
|
|
|
|
export function pluralize(
|
|
count: number,
|
|
singular: string,
|
|
plural = `${singular}s`,
|
|
) {
|
|
return `${count} ${count === 1 ? singular : plural}`;
|
|
}
|
|
|
|
/**
|
|
* Flatten Markdown source into a clean single-line-friendly plain-text string
|
|
* for compact previews (activity feed, list excerpts). This is intentionally
|
|
* lightweight — it strips the common syntax that would otherwise leak as raw
|
|
* characters (`##`, `**`, backticks, links) rather than rendering rich
|
|
* Markdown, which is inappropriate inside a clamped one/two-line preview.
|
|
*/
|
|
export function markdownToPlainText(input: string): string {
|
|
return (
|
|
input
|
|
// Fenced code blocks: drop the fences, keep the inner code text.
|
|
.replace(/```[^\n]*\n?/g, "")
|
|
.replace(/```/g, "")
|
|
// Images `` -> alt text.
|
|
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
// Links `[text](url)` -> text.
|
|
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
// Line-leading markers: headings, blockquotes, list bullets/numbers.
|
|
.replace(/^[ \t]{0,3}(?:#{1,6}|>|[-*+]|\d+[.)])[ \t]+/gm, "")
|
|
// Emphasis: bold/italic/strikethrough — keep the inner text.
|
|
.replace(/(\*\*|__)(.+?)\1/g, "$2")
|
|
.replace(/([*_])(.+?)\1/g, "$2")
|
|
.replace(/~~(.+?)~~/g, "$1")
|
|
// Inline code — keep the inner text.
|
|
.replace(/`([^`]+)`/g, "$1")
|
|
);
|
|
}
|
|
|
|
export function formatCreatedDate(createdAt: number) {
|
|
return new Date(createdAt * 1_000).toLocaleDateString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
export function relativeTime(
|
|
createdAt: number,
|
|
nowSeconds = Math.floor(Date.now() / 1_000),
|
|
) {
|
|
const elapsedSeconds = Math.max(1, Math.floor(nowSeconds - createdAt));
|
|
const units = [
|
|
{ label: "day", seconds: 24 * 60 * 60 },
|
|
{ label: "hour", seconds: 60 * 60 },
|
|
{ label: "minute", seconds: 60 },
|
|
{ label: "second", seconds: 1 },
|
|
];
|
|
|
|
if (elapsedSeconds >= 7 * 24 * 60 * 60) {
|
|
const createdDate = new Date(createdAt * 1_000);
|
|
const nowDate = new Date(nowSeconds * 1_000);
|
|
return createdDate.toLocaleDateString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
...(createdDate.getFullYear() === nowDate.getFullYear()
|
|
? {}
|
|
: { year: "numeric" }),
|
|
});
|
|
}
|
|
|
|
for (const unit of units) {
|
|
const value = Math.floor(elapsedSeconds / unit.seconds);
|
|
if (value >= 1) {
|
|
return `${value} ${unit.label}${value === 1 ? "" : "s"} ago`;
|
|
}
|
|
}
|
|
|
|
return "just now";
|
|
}
|
|
|
|
export function formatExactTimestamp(createdAt: number) {
|
|
return new Date(createdAt * 1_000).toLocaleString(undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
});
|
|
}
|
|
|
|
export function projectPeople(
|
|
project: Project,
|
|
summary?: ProjectActivitySummary,
|
|
): string[] {
|
|
return [
|
|
...new Set(
|
|
[
|
|
project.owner,
|
|
...project.contributors,
|
|
...(summary?.participantPubkeys ?? []),
|
|
].map(normalizePubkey),
|
|
),
|
|
];
|
|
}
|
|
|
|
export function normalizeRepositoryUrl(url: string) {
|
|
try {
|
|
const parsed = new URL(url);
|
|
const normalizedPath = parsed.pathname
|
|
.replace(/\/+$/, "")
|
|
.replace(/\.git$/i, "")
|
|
.toLowerCase();
|
|
return `${parsed.protocol.toLowerCase()}//${parsed.host.toLowerCase()}${normalizedPath}`;
|
|
} catch {
|
|
return url
|
|
.trim()
|
|
.replace(/\/+$/, "")
|
|
.replace(/\.git$/i, "")
|
|
.toLowerCase();
|
|
}
|
|
}
|
|
|
|
export function getClonePathLabel(project: Project) {
|
|
const cloneUrl = project.cloneUrls[0];
|
|
if (!cloneUrl) return "Clone path pending";
|
|
|
|
try {
|
|
const parsed = new URL(cloneUrl);
|
|
return `${parsed.hostname}${parsed.pathname}`;
|
|
} catch {
|
|
return cloneUrl;
|
|
}
|
|
}
|
|
|
|
function repositoryIdentityKey(project: Project) {
|
|
const cloneUrl = project.cloneUrls[0];
|
|
if (cloneUrl) return normalizeRepositoryUrl(cloneUrl);
|
|
return (project.name || project.dtag).trim().toLowerCase();
|
|
}
|
|
|
|
export function uniqueRepositories(projects: Project[]) {
|
|
const seen = new Set<string>();
|
|
return projects.filter((project) => {
|
|
const key = repositoryIdentityKey(project);
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export function getActivityLabel(summary: ProjectActivitySummary | undefined) {
|
|
if (!summary || summary.activityCount === 0) {
|
|
return "No activity yet";
|
|
}
|
|
|
|
return [
|
|
pluralize(summary.commitCount, "commit"),
|
|
pluralize(summary.prCount, "PR"),
|
|
pluralize(summary.issueCount, "issue"),
|
|
].join(" · ");
|
|
}
|
|
|
|
export function getProjectUpdatedAt(
|
|
project: Project,
|
|
summary: ProjectActivitySummary | undefined,
|
|
) {
|
|
// A summary with no recorded activity has `updatedAt: 0` — fall back to
|
|
// the announcement's creation time rather than rendering the Unix epoch.
|
|
return summary?.updatedAt || project.createdAt;
|
|
}
|
|
|
|
export function isProjectMine(
|
|
project: Project,
|
|
currentPubkey: string | undefined,
|
|
) {
|
|
if (!currentPubkey) return false;
|
|
const normalizedCurrentPubkey = normalizePubkey(currentPubkey);
|
|
return (
|
|
normalizePubkey(project.owner) === normalizedCurrentPubkey ||
|
|
project.contributors.some(
|
|
(pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey,
|
|
)
|
|
);
|
|
}
|
|
|
|
export function isProjectOwnedByCurrentUser(
|
|
project: Project,
|
|
currentPubkey: string | undefined,
|
|
) {
|
|
return currentPubkey
|
|
? normalizePubkey(project.owner) === normalizePubkey(currentPubkey)
|
|
: false;
|
|
}
|
|
|
|
export function projectHasAgent(
|
|
project: Project,
|
|
people: string[],
|
|
profiles: UserProfileLookup | undefined,
|
|
) {
|
|
const projectPubkeys = [project.owner, ...people];
|
|
return projectPubkeys.some(
|
|
(pubkey) => profiles?.[normalizePubkey(pubkey)]?.isAgent === true,
|
|
);
|
|
}
|
|
|
|
export function projectOwnerIsUser(
|
|
project: Project,
|
|
profiles: UserProfileLookup | undefined,
|
|
) {
|
|
return profiles?.[normalizePubkey(project.owner)]?.isAgent !== true;
|
|
}
|