import clsx from 'clsx';
import { Ban, CirclePlus, LoaderCircle, MessageSquarePlus, Pencil, RefreshCcwDot, Reply, Search, Smile, X } from 'lucide-solid';
import { A, useNavigate, useParams, useSearchParams, useLocation } from '@solidjs/router';
import { createQuery, useQueryClient } from '@tanstack/solid-query';
import type { ResourceUri } from '@atcute/lexicons/syntax';
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js';
import { createIssue, createIssueComment, getIssue, listIssuesPage, setIssueState, type IssueComment } from '../../lib/api/issues';
import { resolveActor } from '../../lib/api/identity';
import { parseAtUri } from '../../lib/api/records';
import type { RepoContext } from '../../lib/api/repos';
import { useAuth } from '../../lib/auth';
import { useIssuePreloader } from '../../lib/preloading';
import { Avatar, ErrorState, PaginationControls, StateBadge, ToggleButton, buttonStyles, cardStyles, inputStyles, textareaStyles } from '../../components/common';
import { AtUriPanel, MarkdownBlock, RepoFormSkeleton, RepoListPageSkeleton, RepoListSkeleton, RepoThreadSkeleton, IssueOrPullRow, ThreadCommentView } from '../../components/repo';
import { RepoFrame, issueQueryKey, issuesQueryKey, useRepoQuery } from './shared';
import { REPO_LIST_PAGE_LIMIT, formatRelativeTime, getErrorMessage, issueHref, parseIntegerSearchParam, uniqueCommenters, saveRecentIssueOrPull } from '../../lib/repo-utils';
import { buildIssueCommentThreads, buildIssueFilterQuery, parseIssueFilter } from './issues-helpers';
export const IssuesPage: Component = () => {
const auth = useAuth();
const repoQuery = useRepoQuery();
const issuePreloader = useIssuePreloader();
const [searchParams, setSearchParams] = useSearchParams();
const filter = createMemo(() => parseIssueFilter(searchParams.q));
const stateFilter = createMemo(() => filter().state);
const authorFilter = createMemo(() => filter().author);
const textFilter = createMemo(() => filter().query);
const filterQuery = createMemo(() => buildIssueFilterQuery(filter()));
const [filterDraft, setFilterDraft] = createSignal(filterQuery());
const pageOffset = createMemo(() => parseIntegerSearchParam(searchParams.offset, 0, 0));
const pageLimit = createMemo(() => parseIntegerSearchParam(searchParams.limit, REPO_LIST_PAGE_LIMIT, 1));
const basePath = createMemo(() => {
const repo = repoQuery.data;
return repo ? `/${repo.owner.handle}/${repo.slug}/issues` : '/';
});
createEffect(() => {
setFilterDraft(filterQuery());
});
const setStateFilter = (state: 'open' | 'closed') => {
setSearchParams({
q: buildIssueFilterQuery({ ...filter(), state }),
offset: '0',
limit: String(pageLimit()),
});
};
const applyFilter = (event: SubmitEvent) => {
event.preventDefault();
setSearchParams({
q: buildIssueFilterQuery(parseIssueFilter(filterDraft())),
offset: '0',
limit: String(pageLimit()),
});
};
const resetFilter = () => {
setSearchParams({
q: 'state:open',
offset: '0',
limit: String(pageLimit()),
});
};
const issuesQuery = createQuery(() => {
const repo = repoQuery.data;
return {
queryKey: [...issuesQueryKey(repo?.repoDid ?? ''), stateFilter(), authorFilter(), textFilter(), pageOffset(), pageLimit()],
enabled: Boolean(repo),
queryFn: async () => {
const author = authorFilter() ? (await resolveActor(authorFilter()!)).did : undefined;
return listIssuesPage(repo!, {
state: stateFilter(),
author,
query: textFilter(),
offset: pageOffset(),
limit: pageLimit(),
});
},
};
});
const activeCount = createMemo(() => {
const data = issuesQuery.data;
if (!data) return undefined;
return data.totalCount === undefined ? `${pageOffset() + data.items.length}+` : String(data.totalCount);
});
const paginated = createMemo(() => issuesQuery.data?.items ?? []);
return (
<RepoFrame active="issues" loadingFallback={<RepoListPageSkeleton kind="issues" showCreate={Boolean(auth.agent())} />}>
<div class="rounded bg-white p-4 dark:bg-gray-800">
<div class="untangled-filter-grid">
<div class="untangled-toggle-group">
<ToggleButton
active={stateFilter() === 'open'}
onClick={() => setStateFilter('open')}
label="open"
meta={stateFilter() === 'open' ? activeCount() : undefined}
/>
<ToggleButton
active={stateFilter() === 'closed'}
onClick={() => setStateFilter('closed')}
label="closed"
meta={stateFilter() === 'closed' ? activeCount() : undefined}
/>
</div>
<form onSubmit={applyFilter} class="untangled-repo-filter-search">
<input
value={filterDraft()}
onInput={(event) => setFilterDraft(event.currentTarget.value)}
placeholder="search issues..."
class="untangled-repo-filter-input"
/>
<button type="button" class="untangled-repo-filter-reset" aria-label="reset filters" onClick={resetFilter}>
<X class="size-4" />
</button>
<button type="submit" class="untangled-repo-filter-submit" aria-label="apply filters">
<Search class="size-4" />
</button>
</form>
<Show when={auth.agent() && repoQuery.data}>
<A
href={`/${repoQuery.data!.owner.handle}/${repoQuery.data!.slug}/issues/new`}
class={clsx(buttonStyles('primary'), 'col-start-3 justify-center no-underline hover:no-underline')}
>
<CirclePlus class="size-4" />
new issue
</A>
</Show>
</div>
</div>
<div class="mt-2">
<Show
when={!issuesQuery.isLoading}
fallback={
<RepoListSkeleton kind="issues" />
}
>
<Show
when={!issuesQuery.error}
fallback={
<div class={cardStyles('p-6')}>
<ErrorState message={getErrorMessage(issuesQuery.error)} />
</div>
}
>
<Show
when={paginated().length > 0}
fallback={
<div class={cardStyles('px-6 py-4 text-sm text-gray-500 dark:text-gray-400')}>
{stateFilter() === 'open' ? 'No open issues' : 'No closed issues'}
<Show when={authorFilter()}>
{' '}by {authorFilter()}
</Show>
.
</div>
}
>
<div class="flex flex-col gap-2">
<div class="contents">
<For each={paginated()}>
{(issue) => (
<IssueOrPullRow
title={issue.value.title}
number={issue.number || issue.rkey}
state={issue.state}
kind="issue"
author={{ did: issue.author.did, handle: issue.author.handle }}
createdAt={issue.value.createdAt}
href={issueHref(repoQuery.data!, issue.number || issue.rkey)}
{...issuePreloader(
`${repoQuery.data!.owner.handle}/${repoQuery.data!.slug}`,
'issue',
issue.uri,
issue.number || issue.rkey,
repoQuery.data!,
)}
/>
)}
</For>
</div>
</div>
<Show when={repoQuery.data}>
<PaginationControls
basePath={basePath()}
offset={pageOffset()}
limit={pageLimit()}
totalCount={issuesQuery.data?.totalCount}
hasNext={issuesQuery.data?.hasNext}
query={{ q: filterQuery() }}
/>
</Show>
</Show>
</Show>
</Show>
</div>
</RepoFrame>
);
};
export const NewIssuePage: Component = () => {
const auth = useAuth();
const repoQuery = useRepoQuery();
const navigate = useNavigate();
const [title, setTitle] = createSignal('');
const [body, setBody] = createSignal('');
const [error, setError] = createSignal<string | null>(null);
const [submitting, setSubmitting] = createSignal(false);
const client = useQueryClient();
const submit = async (event: SubmitEvent) => {
event.preventDefault();
const agent = auth.agent();
const repo = repoQuery.data;
if (!agent || !repo) {
setError('Sign in first.');
return;
}
if (!title().trim()) {
setError('Title required.');
return;
}
setSubmitting(true);
setError(null);
try {
const created = await createIssue(agent, repo, { title: title(), body: body() });
await client.invalidateQueries({ queryKey: issuesQueryKey(repo.repoDid) });
navigate(issueHref(repo, created.rkey));
} catch (cause) {
setError(getErrorMessage(cause));
} finally {
setSubmitting(false);
}
};
return (
<RepoFrame active="issues" loadingFallback={<RepoFormSkeleton titleWidth="w-36" />}>
<div class={cardStyles('p-6')}>
<form onSubmit={submit} class="flex flex-col gap-4">
<h1 class="text-2xl font-bold m-0">new issue</h1>
<input value={title()} onInput={(event) => setTitle(event.currentTarget.value)} placeholder="title" class={inputStyles()} />
<textarea value={body()} onInput={(event) => setBody(event.currentTarget.value)} placeholder="body" class={textareaStyles()} rows="14" />
<Show when={error()}>
<p class="text-sm text-red-500">{error()}</p>
</Show>
<div class="flex justify-end">
<button type="submit" class={clsx(buttonStyles('primary'), 'justify-center min-w-32')} disabled={submitting()}>
<Show when={submitting()} fallback="create issue">
<span class="inline-flex items-center gap-2">
<LoaderCircle class="size-4 animate-spin" />
creating
</span>
</Show>
</button>
</div>
</form>
</div>
</RepoFrame>
);
};
const updateIssueStateData = (
data: unknown,
issueUri: ResourceUri,
state: 'open' | 'closed',
): unknown => {
if (!data || typeof data !== 'object') return data;
if ('issue' in data) {
const detail = data as { issue?: { uri?: string; state?: string } };
if (detail.issue?.uri === issueUri) {
return {
...detail,
issue: {
...detail.issue,
state,
},
};
}
}
if ('items' in data) {
const page = data as { items?: Array<{ uri?: string; state?: string }> };
if (Array.isArray(page.items)) {
return {
...page,
items: page.items.map((issue) => (issue.uri === issueUri ? { ...issue, state } : issue)),
};
}
}
return data;
};
export const IssuePage: Component = () => {
const auth = useAuth();
const repoQuery = useRepoQuery();
const params = useParams();
const client = useQueryClient();
const location = useLocation();
const issueRef = createMemo(() => params.issueRef ?? '');
const [comment, setComment] = createSignal('');
const [replyingTo, setReplyingTo] = createSignal<IssueComment | null>(null);
const [replyBody, setReplyBody] = createSignal('');
const [workingAction, setWorkingAction] = createSignal<'comment' | 'reply' | 'state' | null>(null);
const working = createMemo(() => workingAction() !== null);
const [error, setError] = createSignal<string | null>(null);
const issueQuery = createQuery(() => {
const repo = repoQuery.data;
return {
queryKey: issueQueryKey(repo?.repoDid ?? '', issueRef()),
enabled: Boolean(repo && issueRef()),
queryFn: async () => getIssue(repo!, issueRef()),
};
});
createEffect(() => {
const repo = repoQuery.data;
const detail = issueQuery.data;
if (repo && detail) {
saveRecentIssueOrPull(repo.owner.handle, repo.slug, {
uri: detail.issue.uri,
kind: 'issue',
title: detail.issue.value.title,
number: detail.issue.number,
state: detail.issue.state,
author: detail.issue.author.handle,
});
}
});
createEffect(() => {
const hash = location.hash;
const cleanHash = hash.startsWith('#') ? hash.slice(1) : hash;
if (cleanHash && cleanHash.startsWith('comment-') && issueQuery.data?.comments) {
setTimeout(() => {
const element = document.getElementById(cleanHash);
if (element) {
// Remove highlight from any other elements first
document.querySelectorAll('.untangled-comment-highlight').forEach((el) => {
el.classList.remove('untangled-comment-highlight');
});
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
element.classList.add('untangled-comment-highlight');
}
}, 150);
}
});
const withIssueInvalidation = async (repo: RepoContext, issueRef: string, issueUri: string) => {
await client.invalidateQueries({ queryKey: issuesQueryKey(repo.repoDid) });
await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, issueRef) });
await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, parseAtUri(issueUri).rkey) });
};
const updateIssueStateCaches = (
repo: RepoContext,
currentIssueRef: string,
issueUri: ResourceUri,
state: 'open' | 'closed',
) => {
client.setQueryData(issueQueryKey(repo.repoDid, currentIssueRef), (data: unknown) =>
updateIssueStateData(data, issueUri, state),
);
client.setQueryData(issueQueryKey(repo.repoDid, parseAtUri(issueUri).rkey), (data: unknown) =>
updateIssueStateData(data, issueUri, state),
);
client.setQueriesData<unknown>({ queryKey: issuesQueryKey(repo.repoDid) }, (data: unknown) =>
updateIssueStateData(data, issueUri, state),
);
};
const toggleState = async () => {
const agent = auth.agent();
const repo = repoQuery.data;
const detail = issueQuery.data;
if (!agent || !repo || !detail) {
return;
}
const nextState = detail.issue.state === 'open' ? 'closed' : 'open';
const commentBody = nextState === 'closed' ? comment().trim() : '';
setWorkingAction('state');
setError(null);
try {
if (commentBody) {
await createIssueComment(agent, detail.issue.uri, detail.issue.cid ?? '', commentBody);
setComment('');
}
await setIssueState(agent, detail.issue.uri, nextState);
if (commentBody) {
await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, issueRef()) });
await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, parseAtUri(detail.issue.uri).rkey) });
}
updateIssueStateCaches(repo, issueRef(), detail.issue.uri, nextState);
await client.invalidateQueries({ queryKey: issuesQueryKey(repo.repoDid), refetchType: 'none' });
await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, issueRef()), refetchType: 'none' });
await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, parseAtUri(detail.issue.uri).rkey), refetchType: 'none' });
} catch (cause) {
setError(getErrorMessage(cause));
} finally {
setWorkingAction(null);
}
};
const addComment = async (event: SubmitEvent) => {
event.preventDefault();
const agent = auth.agent();
const repo = repoQuery.data;
const detail = issueQuery.data;
if (!agent || !repo || !detail || !comment().trim()) {
return;
}
setWorkingAction('comment');
setError(null);
try {
await createIssueComment(agent, detail.issue.uri, detail.issue.cid ?? '', comment());
setComment('');
await withIssueInvalidation(repo, issueRef(), detail.issue.uri);
} catch (cause) {
setError(getErrorMessage(cause));
} finally {
setWorkingAction(null);
}
};
const submitReply = async (event: SubmitEvent, replyToComment: IssueComment) => {
event.preventDefault();
const agent = auth.agent();
const repo = repoQuery.data;
const detail = issueQuery.data;
if (!agent || !repo || !detail || !replyBody().trim()) {
return;
}
setWorkingAction('reply');
setError(null);
try {
await createIssueComment(
agent,
detail.issue.uri,
detail.issue.cid ?? '',
replyBody(),
{ uri: replyToComment.uri, cid: replyToComment.cid ?? '' }
);
setReplyBody('');
setReplyingTo(null);
await withIssueInvalidation(repo, issueRef(), detail.issue.uri);
} catch (cause) {
setError(getErrorMessage(cause));
} finally {
setWorkingAction(null);
}
};
return (
<RepoFrame active="issues" loadingFallback={<RepoThreadSkeleton kind="issue" />}>
<Switch>
<Match when={issueQuery.isLoading}>
<RepoThreadSkeleton kind="issue" />
</Match>
<Match when={issueQuery.error}>
<div class={cardStyles('p-6')}>
<ErrorState message={getErrorMessage(issueQuery.error)} />
</div>
</Match>
<Match when={issueQuery.data}>
{(detail) => {
const participants = createMemo(() => {
const seen = new Set<string>();
const people = [
{ did: detail().issue.author.did, handle: detail().issue.author.handle },
...uniqueCommenters(detail().comments),
];
return people.filter((person) => {
if (seen.has(person.did)) return false;
seen.add(person.did);
return true;
});
});
const currentDid = createMemo(() => auth.currentDid());
const currentHandleQuery = createQuery(() => {
const did = currentDid();
return {
queryKey: ['actor-handle', did ?? ''],
enabled: Boolean(did),
queryFn: async () => (await resolveActor(did!)).handle,
};
});
const currentHandle = createMemo(() => currentHandleQuery.data ?? currentDid());
const commentThreads = createMemo(() => buildIssueCommentThreads(detail().comments));
return (
<div class="grid grid-cols-1 gap-4 w-full md:grid-cols-10">
<div class="col-span-1 min-w-0 md:col-span-8">
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
<header class="pb-2">
<h1 class="m-0 text-2xl">
{detail().issue.value.title}{' '}
<span class="text-gray-500 dark:text-gray-400">#{detail().issue.number || detail().issue.rkey}</span>
</h1>
</header>
<div class="mt-3 flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<StateBadge state={detail().issue.state} kind="issue" />
<span>opened by</span>
<Avatar did={detail().issue.author.did} size="size-6" />
<a href={`/${detail().issue.author.did}`} class="text-gray-700 dark:text-gray-200">{detail().issue.author.handle}</a>
<span class="select-none">·</span>
<span>{formatRelativeTime(detail().issue.value.createdAt)}</span>
</div>
<Show when={detail().issue.value.body}>
<div class="pt-5 min-w-0">
<MarkdownBlock markdown={detail().issue.value.body!} />
</div>
</Show>
<div class="pt-5">
<button type="button" class="inline-flex size-8 items-center justify-center rounded border border-gray-300 text-gray-500 dark:border-gray-600 dark:text-gray-400">
<Smile class="size-4" />
</button>
</div>
</section>
<div class="flex min-w-0 flex-col gap-4"> <div class="mt-4 flex flex-col gap-4">
<For each={commentThreads()}>
{(thread) => (
<div class="untangled-thread-group">
<ThreadCommentView
id={`comment-${thread.item.rkey}`}
author={{ did: thread.item.author.did, handle: thread.item.author.handle }}
createdAt={thread.item.value.createdAt}
markdown={thread.item.value.body}
isAuthor={thread.item.author.did === detail().issue.author.did}
class="untangled-thread-comment"
/>
<Show when={thread.replies.length > 0}>
<div class="untangled-thread-replies-list">
<For each={thread.replies}>
{(reply) => (
<ThreadCommentView
id={`comment-${reply.rkey}`}
author={{ did: reply.author.did, handle: reply.author.handle }}
createdAt={reply.value.createdAt}
markdown={reply.value.body}
isAuthor={reply.author.did === detail().issue.author.did}
class="untangled-thread-reply"
/>
)}
</For>
</div>
</Show>
<Show when={auth.agent()}>
<Show
when={replyingTo()?.uri === thread.item.uri}
fallback={
<button
type="button"
class="untangled-thread-reply-trigger"
onClick={() => {
setReplyingTo(thread.item);
setReplyBody('');
}}
>
<Show when={currentDid()}>
{(did) => <Avatar did={did()} size="size-8 mr-1" />}
</Show>
<span class="w-full p-0">Leave a reply...</span>
</button>
}
>
<form
onSubmit={(event) => void submitReply(event, thread.item)}
class="untangled-thread-reply-form"
>
<Show when={currentDid()}>
{(did) => (
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Avatar did={did()} size="size-6" />
<span>{currentHandle()}</span>
</div>
)}
</Show>
<textarea
value={replyBody()}
onInput={(event) => setReplyBody(event.currentTarget.value)}
class={textareaStyles()}
rows="3"
placeholder="Leave a reply..."
/>
<div class="flex flex-wrap items-stretch justify-end gap-2 text-sm text-gray-500 dark:text-gray-400">
<button type="button" class={clsx(buttonStyles(), 'text-red-500 dark:text-red-400')} onClick={() => setReplyingTo(null)}>
<X class="size-4" />
cancel
</button>
<button type="submit" class={buttonStyles('primary')} disabled={working() || !replyBody().trim()}>
<Show when={workingAction() === 'reply'} fallback={<Reply class="size-4" />}>
<LoaderCircle class="size-4 animate-spin" />
</Show>
reply
</button>
</div>
</form>
</Show>
</Show>
</div>
)}
</For>
<Show when={auth.agent()}>
<form onSubmit={addComment} class="flex min-w-0 flex-col gap-2">
<div class="untangled-comment-composer">
<Show when={currentDid()}>
{(did) => (
<div class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
<Avatar did={did()} size="size-6" />
<span>{currentHandle()}</span>
</div>
)}
</Show>
<textarea
value={comment()}
onInput={(event) => setComment(event.currentTarget.value)}
class={textareaStyles()}
rows="5"
placeholder="Add to the discussion. Markdown is supported."
/>
<Show when={error()}>
<p class="text-sm text-red-500">{error()}</p>
</Show>
</div>
<div class="flex flex-wrap items-center gap-2">
<button type="submit" class={clsx(buttonStyles('primary'), 'justify-center')} disabled={working() || !comment().trim()}>
<Show when={workingAction() === 'comment'} fallback={<MessageSquarePlus class="size-4" />}>
<LoaderCircle class="size-4 animate-spin" />
</Show>
comment
</button>
<button type="button" class={buttonStyles()} disabled={working()} onClick={() => void toggleState()}>
<Show
when={workingAction() === 'state'}
fallback={
<Show when={detail().issue.state === 'open'} fallback={<RefreshCcwDot class="size-4" />}>
<Ban class="size-4" />
</Show>
}
>
<LoaderCircle class="size-4 animate-spin" />
</Show>
{detail().issue.state === 'open' ? (comment().trim() ? 'close with comment' : 'close') : 'reopen'}
</button>
</div>
</form>
</Show>
</div>
</div>
</div>
<div class="untangled-thread-meta col-span-1 flex min-w-0 flex-col gap-6 text-sm md:col-span-2">
<section>
<div class="flex items-center justify-between gap-2 font-semibold uppercase text-gray-500 dark:text-gray-400">
<span>Labels</span>
<Pencil class="size-3.5" />
</div>
<div class="mt-1 text-gray-500 dark:text-gray-400">None yet.</div>
</section>
<section>
<div class="flex items-center justify-between gap-2 font-semibold uppercase text-gray-500 dark:text-gray-400">
<span>Assignee</span>
<Pencil class="size-3.5" />
</div>
<div class="mt-1 text-gray-500 dark:text-gray-400">None yet.</div>
</section>
<section>
<div class="font-semibold text-gray-500 dark:text-gray-400">
Participants <span class="rounded bg-gray-700 px-1 text-gray-200">{participants().length}</span>
</div>
<div class="untangled-overlap-avatars mt-2 flex">
<For each={participants()}>
{(participant) => <Avatar did={participant.did} size="size-8" />}
</For>
</div>
</section>
<AtUriPanel uri={detail().issue.uri} />
</div>
</div>
);
}}
</Match>
</Switch>
</RepoFrame>
);
};