csr tangled client in solid-js
15

Configure Feed

Select the types of activity you want to include in your feed.

fix string comments more

dawn (May 31, 2026, 3:42 AM +0300) 25d9c523 67f29a35

+213 -32
+137 -21
src/components/repo.tsx
··· 17 17 GitPullRequest, 18 18 LoaderCircle, 19 19 MessageSquarePlus, 20 + Reply, 21 + X, 20 22 } from 'lucide-solid'; 21 23 import { useAuth } from '../lib/auth'; 22 24 import { resolveActor } from '../lib/api/identity'; 23 25 import { marked } from 'marked'; 24 - import { A, useNavigate } from '@solidjs/router'; 25 - import { For, Show, createMemo, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; 26 + import { A, useNavigate, useLocation } from '@solidjs/router'; 27 + import { For, Show, createMemo, createSignal, createEffect, onCleanup, onMount, type Component, type JSX } from 'solid-js'; 26 28 import type { RepoContext, TreeEntry } from '../lib/api/repos'; 27 29 import type { Did } from '@atcute/lexicons/syntax'; 28 30 import { createQuery } from '@tanstack/solid-query'; ··· 30 32 import { getRepoStarCount } from '../lib/api/stars'; 31 33 import { getRepoIssueCount } from '../lib/api/issues'; 32 34 import { getRepoPullCount } from '../lib/api/pulls'; 33 - import { formatRelativeTime, languageColor, encodePath, formatLanguagePercent, blobHref, treeHref } from '../lib/repo-utils'; 35 + import { formatRelativeTime, languageColor, encodePath, formatLanguagePercent, blobHref, treeHref, getErrorMessage } from '../lib/repo-utils'; 34 36 import { getTangledAppviewService } from '../lib/settings'; 35 - import { Avatar, SkeletonBlock, StateBadge, PlaceholderAvatar, buttonStyles, cardStyles } from './common'; 37 + import { Avatar, SkeletonBlock, StateBadge, PlaceholderAvatar, buttonStyles, cardStyles, textareaStyles } from './common'; 36 38 import { useRepoPreloader } from '../lib/preloading'; 37 39 38 40 ··· 1625 1627 1626 1628 export interface GenericComment { 1627 1629 uri: string; 1630 + cid?: string; 1628 1631 rkey: string; 1629 1632 author: { did: string; handle: string }; 1630 1633 value: { ··· 1643 1646 threads: GenericCommentThread[]; 1644 1647 authorDid?: string; 1645 1648 title?: string; 1649 + onReplySubmit?: (body: string, parentComment: GenericComment) => Promise<void>; 1646 1650 }> = (props) => { 1651 + const auth = useAuth(); 1652 + const location = useLocation(); 1653 + const [replyingTo, setReplyingTo] = createSignal<GenericComment | null>(null); 1654 + const [replyBody, setReplyBody] = createSignal(''); 1655 + const [replyWorking, setReplyWorking] = createSignal(false); 1656 + const [replyError, setReplyError] = createSignal<string | null>(null); 1657 + 1658 + createEffect(() => { 1659 + const hash = location.hash; 1660 + const cleanHash = hash.startsWith('#') ? hash.slice(1) : hash; 1661 + if (cleanHash && cleanHash.startsWith('comment-') && props.threads.length > 0) { 1662 + setTimeout(() => { 1663 + const element = document.getElementById(cleanHash); 1664 + if (element) { 1665 + // Remove highlight from any other elements first 1666 + document.querySelectorAll('.untangled-comment-highlight').forEach((el) => { 1667 + el.classList.remove('untangled-comment-highlight'); 1668 + }); 1669 + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); 1670 + element.classList.add('untangled-comment-highlight'); 1671 + } 1672 + }, 150); 1673 + } 1674 + }); 1675 + 1676 + const viewerQuery = createQuery(() => ({ 1677 + queryKey: ['viewer', auth.currentDid()], 1678 + enabled: Boolean(auth.currentDid()), 1679 + queryFn: async () => resolveActor(auth.currentDid()!), 1680 + })); 1681 + 1682 + const handleReplySubmit = async (e: SubmitEvent, parent: GenericComment) => { 1683 + e.preventDefault(); 1684 + if (!props.onReplySubmit || !replyBody().trim() || replyWorking()) return; 1685 + 1686 + setReplyWorking(true); 1687 + setReplyError(null); 1688 + try { 1689 + await props.onReplySubmit(replyBody(), parent); 1690 + setReplyBody(''); 1691 + setReplyingTo(null); 1692 + } catch (err) { 1693 + setReplyError(getErrorMessage(err)); 1694 + } finally { 1695 + setReplyWorking(false); 1696 + } 1697 + }; 1698 + 1647 1699 return ( 1648 - <div class="mt-6 space-y-4"> 1700 + <div class={clsx(props.title && 'mt-6', 'space-y-4')}> 1649 1701 <Show when={props.title}> 1650 1702 <h3 class="text-lg font-bold border-b border-gray-200 dark:border-gray-700 pb-2 dark:text-white mb-4"> 1651 1703 {props.title} ··· 1654 1706 <div class="space-y-4"> 1655 1707 <For each={props.threads}> 1656 1708 {(thread) => ( 1657 - <div class="rounded border border-gray-200 dark:border-gray-700 w-full overflow-hidden shadow-sm bg-gray-50 dark:bg-gray-800/50"> 1709 + <div class="untangled-thread-group"> 1658 1710 <ThreadCommentView 1659 1711 id={`comment-${thread.item.rkey}`} 1660 1712 author={thread.item.author} 1661 1713 createdAt={thread.item.value.createdAt} 1662 1714 markdown={thread.item.value.body} 1663 1715 isAuthor={props.authorDid ? thread.item.author.did === props.authorDid : false} 1664 - class="rounded px-6 py-4 bg-white dark:bg-gray-800" 1716 + class="untangled-thread-comment" 1665 1717 /> 1666 1718 <Show when={thread.replies.length > 0}> 1667 - <div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700"> 1719 + <div class="untangled-thread-replies-list"> 1668 1720 <For each={thread.replies}> 1669 1721 {(reply) => ( 1670 - <div class="-ml-4"> 1671 - <ThreadCommentView 1672 - id={`comment-${reply.rkey}`} 1673 - author={reply.author} 1674 - createdAt={reply.value.createdAt} 1675 - markdown={reply.value.body} 1676 - isAuthor={props.authorDid ? reply.author.did === props.authorDid : false} 1677 - isReply={true} 1678 - class="py-4 pr-4" 1679 - /> 1680 - </div> 1722 + <ThreadCommentView 1723 + id={`comment-${reply.rkey}`} 1724 + author={reply.author} 1725 + createdAt={reply.value.createdAt} 1726 + markdown={reply.value.body} 1727 + isAuthor={props.authorDid ? reply.author.did === props.authorDid : false} 1728 + isReply={true} 1729 + class="untangled-thread-reply" 1730 + /> 1681 1731 )} 1682 1732 </For> 1683 1733 </div> 1684 1734 </Show> 1735 + 1736 + <Show when={props.onReplySubmit && auth.agent()}> 1737 + <Show 1738 + when={replyingTo()?.uri === thread.item.uri} 1739 + fallback={ 1740 + <button 1741 + type="button" 1742 + class="untangled-thread-reply-trigger" 1743 + onClick={() => { 1744 + setReplyingTo(thread.item); 1745 + setReplyBody(''); 1746 + setReplyError(null); 1747 + }} 1748 + > 1749 + <Show when={auth.currentDid()}> 1750 + {(did) => <Avatar did={did()} size="size-8 mr-1" />} 1751 + </Show> 1752 + <span class="w-full p-0">Leave a reply...</span> 1753 + </button> 1754 + } 1755 + > 1756 + <form 1757 + onSubmit={(event) => void handleReplySubmit(event, thread.item)} 1758 + class="untangled-thread-reply-form" 1759 + > 1760 + <Show when={viewerQuery.data}> 1761 + {(viewer) => ( 1762 + <div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> 1763 + <Avatar did={viewer().did} size="size-6" /> 1764 + <span>{viewer().handle || viewer().did}</span> 1765 + </div> 1766 + )} 1767 + </Show> 1768 + <textarea 1769 + value={replyBody()} 1770 + onInput={(event) => setReplyBody(event.currentTarget.value)} 1771 + class={textareaStyles()} 1772 + rows="3" 1773 + placeholder="Leave a reply..." 1774 + /> 1775 + <Show when={replyError()}> 1776 + <div class="text-sm text-red-500 mt-1">{replyError()}</div> 1777 + </Show> 1778 + <div class="flex flex-wrap items-stretch justify-end gap-2 text-sm text-gray-500 dark:text-gray-400"> 1779 + <button 1780 + type="button" 1781 + class={clsx(buttonStyles(), 'text-red-500 dark:text-red-400')} 1782 + onClick={() => setReplyingTo(null)} 1783 + > 1784 + <X class="size-4" /> 1785 + cancel 1786 + </button> 1787 + <button 1788 + type="submit" 1789 + class={buttonStyles('primary')} 1790 + disabled={replyWorking() || !replyBody().trim()} 1791 + > 1792 + <Show when={replyWorking()} fallback={<Reply class="size-4" />}> 1793 + <LoaderCircle class="size-4 animate-spin" /> 1794 + </Show> 1795 + reply 1796 + </button> 1797 + </div> 1798 + </form> 1799 + </Show> 1800 + </Show> 1685 1801 </div> 1686 1802 )} 1687 1803 </For> ··· 1707 1823 })); 1708 1824 1709 1825 return ( 1710 - <form onSubmit={props.onSubmit} class="group/form w-full mt-4 flex flex-col"> 1826 + <form onSubmit={props.onSubmit} class="group/form w-full flex flex-col"> 1711 1827 <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full border border-gray-200 dark:border-gray-700"> 1712 1828 <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 1713 1829 <Show when={viewerQuery.data} fallback={<div class="h-6" />}> ··· 1722 1838 <textarea 1723 1839 rows={5} 1724 1840 placeholder={props.placeholder ?? 'Add to the discussion. Markdown is supported.'} 1725 - class="w-full p-2 rounded border border-gray-200 dark:border-gray-700 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400 dark:text-white" 1841 + class={textareaStyles()} 1726 1842 required 1727 1843 value={props.value} 1728 1844 onInput={(event) => props.onValueChange(event.currentTarget.value)}
+1
src/index.css
··· 2091 2091 2092 2092 .untangled-thread-reply { 2093 2093 margin-left: -1rem; 2094 + width: calc(100% + 1rem) !important; 2094 2095 display: flex; 2095 2096 gap: 0.5rem; 2096 2097 padding: 1rem 1rem 1rem 0;
+23
src/lib/api/strings.ts
··· 125 125 return { owner, record }; 126 126 }; 127 127 128 + export const getStringRecord = async (uri: ResourceUri): Promise<{ owner: ResolvedActor; record: StringRecord }> => { 129 + const parsed = parseAtUri(uri); 130 + const owner = await resolveActor(parsed.did); 131 + const response = await ok( 132 + getRpc(owner.pds).get('com.atproto.repo.getRecord', { 133 + params: { 134 + repo: owner.did, 135 + collection: STRING_COLLECTION, 136 + rkey: parsed.rkey, 137 + }, 138 + }), 139 + ); 140 + 141 + const record: StringRecord = { 142 + uri: response.uri, 143 + cid: response.cid ?? '', 144 + rkey: parsed.rkey, 145 + value: response.value as ShTangledString.Main, 146 + }; 147 + return { owner, record }; 148 + }; 149 + 150 + 128 151 export const deleteString = async (agent: OAuthUserAgent, rkey: string): Promise<void> => { 129 152 const rpc = createAuthRpc(agent); 130 153 await ok(
+39 -10
src/pages/search.tsx
··· 23 23 import { getIssueRecord, getIssue } from '../lib/api/issues'; 24 24 import { getPullRecord, getPull } from '../lib/api/pulls'; 25 25 import { getRepoByDid } from '../lib/api/repos'; 26 + import { getStringRecord } from '../lib/api/strings'; 26 27 import { issueQueryKey, pullQueryKey } from './repo/shared'; 27 28 import { 28 29 ISSUE_COLLECTION, 30 + STRING_COLLECTION, 29 31 } from '../lib/api/constants'; 30 32 import { 31 33 parseAtUri, ··· 229 231 230 232 try { 231 233 const parsed = parseAtUri(uri); 234 + if (parsed.collection === STRING_COLLECTION) { 235 + return { 236 + uri, 237 + kind: 'string' as const, 238 + }; 239 + } 232 240 return { 233 241 uri, 234 242 kind: parsed.collection === ISSUE_COLLECTION ? 'issue' as const : 'pull' as const, ··· 243 251 enabled: Boolean(parentInfo()?.uri), 244 252 queryFn: async () => { 245 253 const info = parentInfo()!; 254 + if (info.kind === 'string') { 255 + return { kind: 'string' as const, data: await getStringRecord(info.uri) }; 256 + } 246 257 return info.kind === 'issue' 247 258 ? { kind: 'issue' as const, record: await getIssueRecord(info.uri) } 248 259 : { kind: 'pull' as const, record: await getPullRecord(info.uri) }; ··· 252 263 253 264 return ( 254 265 <Show when={parentQuery.data} fallback={<span>comment</span>}> 255 - {(parent) => { 256 - const repoDid = createMemo(() => parent().kind === 'issue' 257 - ? (parent().record.value as { repo: Did }).repo 258 - : (parent().record.value as { target: { repo: Did } }).target.repo); 259 - return ( 260 - <RepoThreadLink kind={parent().kind} repoDid={repoDid()} threadRef={parent().record.rkey} commentRef={props.hit.rkey}> 261 - comment on {parent().kind} 262 - </RepoThreadLink> 263 - ); 264 - }} 266 + {(parent) => ( 267 + <Switch> 268 + <Match when={parent().kind === 'string'}> 269 + <A 270 + href={`/strings/${(parent() as any).data.owner.handle || (parent() as any).data.owner.did}/${(parent() as any).data.record.rkey}#comment-${props.hit.rkey}`} 271 + class="truncate min-w-0 font-medium" 272 + > 273 + comment on string 274 + </A> 275 + </Match> 276 + <Match when={parent().kind !== 'string'}> 277 + <RepoThreadLink 278 + kind={(parent() as any).kind} 279 + repoDid={(parent() as any).kind === 'issue' 280 + ? ((parent() as any).record.value as { repo: Did }).repo 281 + : ((parent() as any).record.value as { target: { repo: Did } }).target.repo 282 + } 283 + threadRef={(parent() as any).record.rkey} 284 + commentRef={props.hit.rkey} 285 + > 286 + comment on {(parent() as any).kind} 287 + </RepoThreadLink> 288 + </Match> 289 + </Switch> 290 + )} 265 291 </Show> 266 292 ); 267 293 }; ··· 301 327 queryFn: async () => { 302 328 const uri = parentUri()!; 303 329 const parsed = parseAtUri(uri); 330 + if (parsed.collection === STRING_COLLECTION) { 331 + return undefined; 332 + } 304 333 if (parsed.collection === ISSUE_COLLECTION) { 305 334 const issue = await getIssueRecord(uri); 306 335 return (issue.value as { repo: Did }).repo;
+13 -1
src/pages/string.tsx
··· 292 292 </section> 293 293 294 294 {/* Comments section */} 295 - <div class="flex flex-col gap-4 mt-4"> 295 + <div class="flex flex-col gap-4"> 296 296 <Show when={commentsQuery.data}> 297 297 <CommentThreadsSection 298 298 threads={commentThreads()} 299 299 authorDid={owner.did} 300 + onReplySubmit={async (body, parent) => { 301 + const agent = auth.agent(); 302 + if (!agent) return; 303 + await createStringComment( 304 + agent, 305 + record.uri, 306 + record.cid, 307 + body, 308 + { uri: parent.uri as ResourceUri, cid: parent.cid ?? '' } 309 + ); 310 + await queryClient.invalidateQueries({ queryKey: ['string-comments', record.uri] }); 311 + }} 300 312 /> 301 313 </Show> 302 314