-
+
{suggestion.handle}
-
+
@@ -724,7 +726,7 @@
Loading activity
-
Checking your repositories.
+
Checking your repos.
);
@@ -733,7 +735,7 @@
const actorUrl = () => props.item.actorHandle ? `/${props.item.actorHandle}` : '#';
const actionText = () => {
- if (props.item.kind === 'repo') return 'published repository';
+ if (props.item.kind === 'repo') return 'published repo';
if (props.item.kind === 'issue') return 'created issue in';
if (props.item.kind === 'pull') return 'opened pull request in';
return 'updated';
diff --git a/src/pages/profile.tsx b/src/pages/profile.tsx
new file mode 100644
--- /dev/null
+++ b/src/pages/profile.tsx
@@ -0,0 +1,1389 @@
+import clsx from 'clsx';
+import {
+ BookMarked,
+ Link,
+ LoaderCircle,
+ MapPin,
+ Rss,
+ Star,
+ UserRoundPlus,
+ UserRoundMinus,
+ Users,
+ GalleryVertical,
+ Shield,
+ ShieldCheck,
+ ShieldAlert,
+ ArrowRight,
+ X,
+ Check,
+ Pencil,
+} from 'lucide-solid';
+import { A, useParams, useSearchParams } from '@solidjs/router';
+import { createQuery, useQueryClient } from '@tanstack/solid-query';
+import { For, Show, Switch, Match, createMemo, createSignal, createEffect, type Component } from 'solid-js';
+import type { Did } from '@atcute/lexicons/syntax';
+import { ErrorState, LoadingState, PlaceholderAvatar, textareaStyles, inputStyles } from '../components/common';
+import { RepoCard } from '../components/repo';
+import {
+ listFollowRecords,
+ createFollow,
+ deleteFollow,
+ getFollowers,
+ getFollowing,
+ getVouchRecord,
+ putVouchRecord,
+ deleteVouchRecord,
+ type FollowerFollowingItem,
+ type FollowRecord,
+ type VouchRecord,
+} from '../lib/api/graph';
+import { resolveActor, getActorProfile, resolveAvatarUrl, putActorProfile } from '../lib/api/identity';
+import { listRepoRecords, getRepoByDid, type RepoContext } from '../lib/api/repos';
+import { useAuth } from '../lib/auth';
+import { listAllAppviewRecords } from '../lib/api/appview';
+import { formatRelativeTime } from '../lib/repo-utils';
+import { ShTangledFeedStar, ShTangledActorProfile } from '@atcute/tangled';
+
+const ProfileTabs: Component<{
+ activeTab: string;
+ reposCount: number;
+ starsCount: number;
+}> = (props) => {
+ const tabs = [
+ { id: 'overview', label: 'overview', icon: () =>
},
+ { id: 'repos', label: 'repos', icon: () =>
, count: () => props.reposCount },
+ { id: 'starred', label: 'starred', icon: () =>
, count: () => props.starsCount },
+ ];
+
+ return (
+
+ );
+};
+
+const ProfileCardComponent: Component<{
+ actor: any;
+ profile?: any;
+ followersCount: number;
+ followingCount: number;
+ loggedInUserDid?: string;
+ isFollowing: boolean;
+ onFollowToggle: () => void;
+ actionLoading: boolean;
+ onTabChange: (tab: string) => void;
+ vouchRecord?: VouchRecord | null;
+ onVouchSubmit: (kind: 'vouch' | 'denounce' | 'none', reason?: string) => Promise
;
+ vouchActionLoading: boolean;
+ onProfileUpdate: (profile: ShTangledActorProfile.Main) => Promise;
+ profileUpdateLoading: boolean;
+ reposCount: number;
+ starsCount: number;
+ mergedPRCount: number;
+ closedPRCount: number;
+ openPRCount: number;
+ openIssueCount: number;
+ closedIssueCount: number;
+}> = (props) => {
+ const userIdent = () => props.actor.handle || props.actor.did;
+
+ const pronouns = () => props.profile?.pronouns;
+ const description = () => props.profile?.description;
+ const location = () => props.profile?.location;
+ const websiteLinks = () => props.profile?.links ?? [];
+ const includeBluesky = () => props.profile?.bluesky;
+
+ const isSelf = () => props.loggedInUserDid === props.actor.did;
+
+ const [kind, setKind] = createSignal<'vouch' | 'denounce' | 'none'>('none');
+ const [reason, setReason] = createSignal('');
+ let popoverRef: HTMLDivElement | undefined;
+
+ createEffect(() => {
+ const record = props.vouchRecord;
+ if (record) {
+ setKind(record.value.kind);
+ setReason(record.value.reason || '');
+ } else {
+ setKind('none');
+ setReason('');
+ }
+ });
+
+ const popoverId = () => `vouch-modal-${props.actor.did.replace(/[^a-zA-Z0-9]/g, '-')}`;
+
+ const isVouched = () => props.vouchRecord?.value.kind === 'vouch';
+ const isDenounced = () => props.vouchRecord?.value.kind === 'denounce';
+
+ const handleFollowClick = () => {
+ if (!props.loggedInUserDid) {
+ alert('Please sign in to follow users.');
+ return;
+ }
+ props.onFollowToggle();
+ };
+
+ const handleVouchClick = (e: MouseEvent) => {
+ if (!props.loggedInUserDid) {
+ e.preventDefault();
+ alert('Please sign in to vouch for users.');
+ }
+ };
+
+ const [isEditing, setIsEditing] = createSignal(false);
+ const [editBioText, setEditBioText] = createSignal('');
+ const [editPronounsText, setEditPronounsText] = createSignal('');
+ const [editLocationText, setEditLocationText] = createSignal('');
+ const [editBluesky, setEditBluesky] = createSignal(false);
+ const [editLinks, setEditLinks] = createSignal(['', '', '', '', '']);
+ const [editStat1, setEditStat1] = createSignal('');
+ const [editStat2, setEditStat2] = createSignal('');
+
+ const startEditing = () => {
+ const p = props.profile;
+ setEditBioText(p?.description || '');
+ setEditPronounsText(p?.pronouns || '');
+ setEditLocationText(p?.location || '');
+ setEditBluesky(!!p?.bluesky);
+
+ const existingLinks = p?.links || [];
+ const newLinks = ['', '', '', '', ''];
+ for (let i = 0; i < 5; i++) {
+ newLinks[i] = existingLinks[i] || '';
+ }
+ setEditLinks(newLinks);
+
+ const existingStats = p?.stats || [];
+ setEditStat1(existingStats[0] || '');
+ setEditStat2(existingStats[1] || '');
+
+ setIsEditing(true);
+ };
+
+ const handleSubmitProfile = async (e: SubmitEvent) => {
+ e.preventDefault();
+ const finalLinks = editLinks().map(l => l.trim()).filter(Boolean);
+ const finalStats = [editStat1(), editStat2()].filter(Boolean);
+
+ const updatedProfile: ShTangledActorProfile.Main = {
+ $type: 'sh.tangled.actor.profile',
+ ...props.profile,
+ description: editBioText().trim() || undefined,
+ pronouns: editPronounsText().trim() || undefined,
+ location: editLocationText().trim() || undefined,
+ bluesky: editBluesky(),
+ links: finalLinks.length > 0 ? finalLinks : undefined,
+ stats: finalStats.length > 0 ? finalStats : undefined,
+ };
+
+ await props.onProfileUpdate(updatedProfile);
+ setIsEditing(false);
+ };
+
+ const getStatValue = (kind: string) => {
+ if (kind === 'repository-count') return props.reposCount;
+ if (kind === 'star-count') return props.starsCount;
+ if (kind === 'merged-pull-request-count') return props.mergedPRCount;
+ if (kind === 'closed-pull-request-count') return props.closedPRCount;
+ if (kind === 'open-pull-request-count') return props.openPRCount;
+ if (kind === 'open-issue-count') return props.openIssueCount;
+ if (kind === 'closed-issue-count') return props.closedIssueCount;
+ return 0;
+ };
+
+ const formatStatLabel = (kind: string) => {
+ const labels: Record = {
+ 'merged-pull-request-count': 'merged prs',
+ 'closed-pull-request-count': 'closed prs',
+ 'open-pull-request-count': 'open prs',
+ 'open-issue-count': 'open issues',
+ 'closed-issue-count': 'closed issues',
+ 'repository-count': 'repos',
+ 'star-count': 'stars received',
+ };
+ return labels[kind] || kind;
+ };
+
+ const avatarQuery = createQuery(() => ({
+ queryKey: ['avatar', props.actor.did],
+ queryFn: async () => resolveAvatarUrl(props.actor.did),
+ staleTime: 300_000,
+ }));
+
+ return (
+
+ {/* Avatar */}
+
+
+
}
+ >
+

+
+
+
+
+ {/* Details */}
+
+
+
+ {userIdent()}
+
+
+ {pronouns()}
+
+
+
+
+
+
+ {/* Bio and links */}
+
+
+
+
+ {description()}
+
+
+
+
+
+
+
+
+ {location()}
+
+
+
+
+
+
+
+
+ {(link) => (
+
+
+
+ )}
+
+
+
+ 0}>
+
+
+ {(statKind) => (
+
+ {getStatValue(statKind)}
+ {formatStatLabel(statKind)}
+
+ )}
+
+
+
+
+
+
+
+ edit
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ }
+ >
+ {/* EDIT FORM */}
+
+
+
+
+
+ );
+};
+
+const FollowCard: Component<{
+ item: FollowerFollowingItem;
+ loggedInUserDid?: string;
+ viewerFollows?: FollowRecord[];
+ onFollowToggle?: (did: Did) => void;
+}> = (props) => {
+ const userIdent = () => props.item.handle || props.item.did;
+
+ const profileQuery = createQuery(() => ({
+ queryKey: ['profile-record', props.item.did],
+ queryFn: () => getActorProfile(props.item.actor),
+ staleTime: 300_000,
+ }));
+
+ const followersQuery = createQuery(() => ({
+ queryKey: ['profile-followers', props.item.did],
+ queryFn: () => getFollowers(props.item.did),
+ staleTime: 300_000,
+ }));
+
+ const followingQuery = createQuery(() => ({
+ queryKey: ['profile-following', props.item.did],
+ queryFn: () => getFollowing(props.item.did),
+ staleTime: 300_000,
+ }));
+
+ const isFollowing = () => {
+ const list = props.viewerFollows;
+ if (!list) return false;
+ return list.some(follow => follow.value.subject === props.item.did);
+ };
+
+ const isSelf = () => props.loggedInUserDid === props.item.did;
+
+ const avatarQuery = createQuery(() => ({
+ queryKey: ['avatar', props.item.did],
+ queryFn: async () => resolveAvatarUrl(props.item.did),
+ staleTime: 300_000,
+ }));
+
+ return (
+
+
+
+
}
+ >
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const ProfilePage: Component = () => {
+ const params = useParams();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const queryClient = useQueryClient();
+ const auth = useAuth();
+
+ const [repoSearch, setRepoSearch] = createSignal('');
+ const [actionLoading, setActionLoading] = createSignal(false);
+
+ const actorParam = () => params.actor as string;
+ const activeTab = () => {
+ const tab = searchParams.tab;
+ return (Array.isArray(tab) ? tab[0] : tab) || 'overview';
+ };
+
+ const actorQuery = createQuery(() => ({
+ queryKey: ['profile-actor', actorParam()],
+ queryFn: () => resolveActor(actorParam()),
+ }));
+
+ const profileQuery = createQuery(() => ({
+ queryKey: ['profile-record', actorQuery.data?.did],
+ enabled: !!actorQuery.data,
+ queryFn: () => getActorProfile(actorQuery.data!),
+ }));
+
+ const followersQuery = createQuery(() => ({
+ queryKey: ['profile-followers', actorQuery.data?.did],
+ enabled: !!actorQuery.data,
+ queryFn: () => getFollowers(actorQuery.data!.did),
+ }));
+
+ const followingQuery = createQuery(() => ({
+ queryKey: ['profile-following', actorQuery.data?.did],
+ enabled: !!actorQuery.data,
+ queryFn: () => getFollowing(actorQuery.data!.did),
+ }));
+
+ const reposQuery = createQuery(() => ({
+ queryKey: ['profile-repos', actorQuery.data?.did],
+ enabled: !!actorQuery.data,
+ queryFn: () => listRepoRecords(actorQuery.data!),
+ }));
+
+ const starsQuery = createQuery(() => ({
+ queryKey: ['profile-stars', actorQuery.data?.did],
+ enabled: !!actorQuery.data,
+ queryFn: async () => {
+ const did = actorQuery.data!.did;
+ const starRecords = await listAllAppviewRecords('sh.tangled.feed.listStarsBy', did);
+ const repos = await Promise.all(
+ starRecords.map(async (star) => {
+ const subject = star.value.subject as { did?: string; subjectDid?: string };
+ const repoDid = (subject.did ?? subject.subjectDid) as Did;
+ try {
+ return await getRepoByDid(repoDid);
+ } catch (e) {
+ console.error('failed to resolve starred repo', repoDid, e);
+ return null;
+ }
+ })
+ );
+ return repos.filter(Boolean) as RepoContext[];
+ },
+ }));
+
+ const viewerFollowsQuery = createQuery(() => ({
+ queryKey: ['viewer-follows', auth.currentDid()],
+ enabled: !!auth.currentDid(),
+ queryFn: () => listFollowRecords(auth.currentDid()!),
+ }));
+
+ const issuesQuery = createQuery(() => ({
+ queryKey: ['profile-issues-stat', actorQuery.data?.did],
+ enabled: !!actorQuery.data,
+ queryFn: async () => {
+ const did = actorQuery.data!.did;
+ try {
+ return await listAllAppviewRecords('sh.tangled.repo.listIssuesBy', did);
+ } catch (e) {
+ console.error('Failed to fetch issues for stat', e);
+ return [];
+ }
+ }
+ }));
+
+ const pullsQuery = createQuery(() => ({
+ queryKey: ['profile-pulls-stat', actorQuery.data?.did],
+ enabled: !!actorQuery.data,
+ queryFn: async () => {
+ const did = actorQuery.data!.did;
+ try {
+ return await listAllAppviewRecords('sh.tangled.repo.listPullsBy', did);
+ } catch (e) {
+ console.error('Failed to fetch pulls for stat', e);
+ return [];
+ }
+ }
+ }));
+
+ const mergedPRCount = () => (pullsQuery.data as any[])?.filter((p) => p.state === 'merged').length ?? 0;
+ const closedPRCount = () => (pullsQuery.data as any[])?.filter((p) => p.state === 'closed').length ?? 0;
+ const openPRCount = () => (pullsQuery.data as any[])?.filter((p) => p.state === 'open').length ?? 0;
+ const openIssueCount = () => (issuesQuery.data as any[])?.filter((i) => i.state === 'open').length ?? 0;
+ const closedIssueCount = () => (issuesQuery.data as any[])?.filter((i) => i.state === 'closed').length ?? 0;
+
+ const isFollowing = () => {
+ const list = viewerFollowsQuery.data;
+ const profileDid = actorQuery.data?.did;
+ if (!list || !profileDid) return false;
+ return list.some((follow) => follow.value.subject === profileDid);
+ };
+
+ const followRecordRkey = () => {
+ const list = viewerFollowsQuery.data;
+ const profileDid = actorQuery.data?.did;
+ if (!list || !profileDid) return undefined;
+ return list.find((follow) => follow.value.subject === profileDid)?.rkey;
+ };
+
+ const handleFollowToggle = async () => {
+ const agent = auth.agent();
+ const profileDid = actorQuery.data?.did;
+ if (!agent || !profileDid) return;
+
+ setActionLoading(true);
+ try {
+ const rkey = followRecordRkey();
+ if (rkey) {
+ await deleteFollow(agent, rkey);
+ } else {
+ await createFollow(agent, profileDid);
+ }
+ queryClient.invalidateQueries({ queryKey: ['viewer-follows', auth.currentDid()] });
+ queryClient.invalidateQueries({ queryKey: ['profile-followers', profileDid] });
+ } catch (e) {
+ console.error('Failed to toggle follow status', e);
+ } finally {
+ setActionLoading(false);
+ }
+ };
+
+ const handleItemFollowToggle = async (did: Did) => {
+ const agent = auth.agent();
+ if (!agent) return;
+
+ try {
+ const viewerFollows = viewerFollowsQuery.data ?? [];
+ const existing = viewerFollows.find((f) => f.value.subject === did);
+ if (existing) {
+ await deleteFollow(agent, existing.rkey);
+ } else {
+ await createFollow(agent, did);
+ }
+ queryClient.invalidateQueries({ queryKey: ['viewer-follows', auth.currentDid()] });
+ if (actorQuery.data?.did === did) {
+ queryClient.invalidateQueries({ queryKey: ['profile-followers', did] });
+ }
+ } catch (e) {
+ console.error('Failed to toggle follow status for list item', e);
+ }
+ };
+
+ const vouchQuery = createQuery(() => ({
+ queryKey: ['vouch-record', auth.currentDid(), actorQuery.data?.did],
+ enabled: !!auth.currentDid() && !!actorQuery.data?.did && auth.currentDid() !== actorQuery.data?.did,
+ queryFn: () => getVouchRecord(auth.currentDid()!, actorQuery.data!.did),
+ }));
+
+ const [vouchActionLoading, setVouchActionLoading] = createSignal(false);
+
+ const handleVouchSubmit = async (kind: 'vouch' | 'denounce' | 'none', reason?: string) => {
+ const agent = auth.agent();
+ const profileDid = actorQuery.data?.did;
+ if (!agent || !profileDid) return;
+
+ setVouchActionLoading(true);
+ try {
+ if (kind === 'none') {
+ await deleteVouchRecord(agent, profileDid);
+ } else {
+ await putVouchRecord(agent, profileDid, kind, reason);
+ }
+ queryClient.invalidateQueries({ queryKey: ['vouch-record', auth.currentDid(), profileDid] });
+ } catch (e) {
+ console.error('Failed to submit vouch status', e);
+ } finally {
+ setVouchActionLoading(false);
+ }
+ };
+
+ const [profileUpdateLoading, setProfileUpdateLoading] = createSignal(false);
+
+ const handleProfileUpdate = async (profile: ShTangledActorProfile.Main) => {
+ const agent = auth.agent();
+ const profileDid = actorQuery.data?.did;
+ if (!agent || !profileDid) return;
+
+ setProfileUpdateLoading(true);
+ try {
+ await putActorProfile(agent, profile);
+ queryClient.invalidateQueries({ queryKey: ['profile-record', profileDid] });
+ } catch (e) {
+ console.error('Failed to update profile record', e);
+ } finally {
+ setProfileUpdateLoading(false);
+ }
+ };
+
+ const pinnedRepos = createMemo(() => {
+ const list = reposQuery.data ?? [];
+ const pinnedDids = profileQuery.data?.pinnedRepositories ?? [];
+ if (pinnedDids.length > 0) {
+ return list.filter((repo) => {
+ const did = repo.value.repoDid;
+ const uri = repo.uri;
+ return (did && pinnedDids.includes(did)) || pinnedDids.includes(uri);
+ });
+ }
+ return list.slice(0, 4);
+ });
+
+ const filteredRepos = createMemo(() => {
+ const q = repoSearch().toLowerCase().trim();
+ const list = reposQuery.data ?? [];
+ if (!q) return list;
+ return list.filter(
+ (repo) =>
+ repo.value.name?.toLowerCase().includes(q) ||
+ repo.value.description?.toLowerCase().includes(q)
+ );
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {(resolvedActor) => (
+
+
+
+
+
+ {(() => {
+ const unresolvedAvatarQuery = createQuery(() => ({
+ queryKey: ['avatar', resolvedActor().did],
+ queryFn: async () => resolveAvatarUrl(resolvedActor().did),
+ staleTime: 300_000,
+ }));
+ return (
+
+
+
}
+ >
+

+
+
+
{resolvedActor().handle || resolvedActor().did}
+
This user hasn't joined Tangled yet.
+
Let them know we're waiting for them!
+
+
+
+ );
+ })()}
+
+
+
+
+
+
+
+ {/* Sidebar */}
+
+
setSearchParams({ tab })}
+ vouchRecord={vouchQuery.data}
+ onVouchSubmit={handleVouchSubmit}
+ vouchActionLoading={vouchActionLoading()}
+ onProfileUpdate={handleProfileUpdate}
+ profileUpdateLoading={profileUpdateLoading()}
+ reposCount={reposQuery.data?.length ?? 0}
+ starsCount={starsQuery.data?.length ?? 0}
+ mergedPRCount={mergedPRCount()}
+ closedPRCount={closedPRCount()}
+ openPRCount={openPRCount()}
+ openIssueCount={openIssueCount()}
+ closedIssueCount={closedIssueCount()}
+ />
+
+
+
+ {/* Overview Tab (split into two columns) */}
+
+ {/* Pinned / Own Repos Column */}
+
+
+
+
+
+
+ This user does not have any pinned repos.
+
+ }
+ >
+ {(repo) => (
+
+ )}
+
+
+
+
+
+
+ {/* Activity Timeline Column */}
+
+
activity
+
+
+ This user does not have any activity yet.
+
+
+
+
+
+ {/* Repositories Tab */}
+
+
+
+
+
+
+
+
+ This user does not have any repos yet.
+
+ }
+ >
+ {(repo) => (
+
+ )}
+
+
+
+
+
+ {/* Starred Tab */}
+
+
+
}
+ >
+
+
+ This user does not have any starred repos yet.
+
+ }
+ >
+ {(repo) => (
+
+ )}
+
+
+
+