~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
398 lines · 13.0 KB Raw History
<script lang="ts">
	import type * as getOrg from '#lib/lexicons/dev/knotless/getOrg.ts';
	import type * as inviteToOrg from '#lib/lexicons/dev/knotless/inviteToOrg.ts';
	import type * as removeFromOrg from '#lib/lexicons/dev/knotless/removeFromOrg.ts';
	import type * as setOrgMembership from '#lib/lexicons/dev/knotless/setOrgMembership.ts';
	import type * as endOrgMembership from '#lib/lexicons/dev/knotless/endOrgMembership.ts';
	import type { Answer } from '#lib/data.ts';
	import type { AtIdentifierString, DidString } from '@atproto/lex';
	import { invalidateAll } from '$app/navigation';
	import { call } from '#lib/data.ts';
	import { closeMenus } from '#lib/menus.ts';
	import Icon from '#lib/atoms/Icon.svelte';
	import Delegate from '#lib/molecules/Delegate.svelte';
	import MemberList from '#lib/organisms/MemberList.svelte';
	import HandleInput from '#lib/molecules/HandleInput.svelte';
	import Dialog from '#lib/molecules/Dialog.svelte';
	/**
	 * The Members tab of the account `did`, called `handle`, as `me` sees
	 * it, from what `org` answers: its members and invitations, and what
	 * `me` may do about them, as `knotless org` does. The organization and
	 * its admins invite, change roles and remove; one it invited joins,
	 * publicly or hidden, or declines; a member shows or hides their
	 * membership, or leaves. To the account itself before it is an
	 * organization, `delegated` saying whether this server writes for it,
	 * the form whose first invitation makes it one.
	 */
	let {
		org,
		did,
		handle,
		me,
		delegated
	}: {
		org: Answer<typeof getOrg.main>;
		did: string;
		handle: string;
		me?: string;
		delegated: boolean;
	} = $props();
	type Member = getOrg.MemberView;
	const joined = (m?: Member) => m?.standing === 'public' || m?.standing === 'hidden';
	const invited = (m: Member) => m.standing === 'invited' || m.standing === 'lapsed';
	const mine = $derived(org.members.find((m) => m.actor.did === me));
	const itself = $derived(me === did);
	const admin = $derived(itself || (joined(mine) && mine?.role === 'admin'));
	const members = $derived(org.members.filter((m) => !invited(m)));
	const invitations = $derived(org.members.filter(invited));
	// Its first invitation goes through the gateway it delegated to.
	const undelegated = $derived(itself && !org.gateway && !delegated);
	let gotDelegated = $state(false);
	let who = $state('');
	let role = $state('member');
	// Which action is under way: its button shows it, and the rest wait.
	let busy = $state('');
	let done = $state('');
	let problem = $state('');
	// What waits on the dialog that asks first.
	let asking = $state<{
		title: string;
		body: string;
		ok: string;
		busy: string;
		go: () => Promise<string>;
	} | null>(null);
	const roleName = (r: string) => (r === 'admin' ? 'an admin' : 'a member');

	/** Runs `change`, as `action`, then draws the page again, saying what it did. */
	async function act(action: string, change: () => Promise<string>) {
		busy = action;
		problem = '';
		done = '';
		try {
			done = await change();
			await invalidateAll();
		} catch (err) {
			problem = (err as Error).message;
		}
		busy = '';
	}
	const orgDid = $derived(did as DidString);
	async function invite(e: SubmitEvent) {
		e.preventDefault();
		const member = who.trim().replace(/^@/, '');
		const first = !org.organization;
		await act('invite', async () => {
			await call<typeof inviteToOrg.main>('dev.knotless.inviteToOrg', {
				org: orgDid,
				member: member as AtIdentifierString,
				role
			});
			who = '';
			const made = first ? `${handle} is now an organization. ` : '';
			return `${made}Invited ${member} as ${roleName(role)}. They become a member once they accept.`;
		});
	}
	function setRole(m: Member, to: string) {
		return act(`role ${m.actor.did}`, async () => {
			await call<typeof inviteToOrg.main>('dev.knotless.inviteToOrg', {
				org: orgDid,
				member: m.actor.did as DidString,
				role: to
			});
			return invited(m)
				? `${m.actor.handle} is now invited as ${roleName(to)}.`
				: `${m.actor.handle} is now ${roleName(to)}.`;
		});
	}
	function reinvite(m: Member) {
		return act(`again ${m.actor.did}`, async () => {
			await call<typeof inviteToOrg.main>('dev.knotless.inviteToOrg', {
				org: orgDid,
				member: m.actor.did as DidString,
				role: m.role
			});
			return `Invited ${m.actor.handle} again. They have 7 days to accept.`;
		});
	}
	async function remove(m: Member) {
		await call<typeof removeFromOrg.main>('dev.knotless.removeFromOrg', {
			org: orgDid,
			member: m.actor.did as DidString
		});
		return invited(m)
			? `Withdrew the invitation to ${m.actor.handle}.`
			: `${m.actor.handle} is no longer a member.`;
	}
	function join(visibility: 'public' | 'hidden') {
		return act(visibility, async () => {
			await call<typeof setOrgMembership.main>('dev.knotless.setOrgMembership', {
				org: orgDid,
				visibility
			});
			if (joined(mine))
				return visibility === 'hidden'
					? `Your membership is hidden. Only members of ${handle} see it.`
					: `Anyone can see you're a member of ${handle} now.`;
			return visibility === 'hidden'
				? `You're a hidden member of ${handle}. Only its members see it.`
				: `You're a member of ${handle}, and anyone can see it.`;
		});
	}
	async function leave() {
		const left = await call<typeof endOrgMembership.main>('dev.knotless.endOrgMembership', {
			org: orgDid
		});
		const said = invited(mine!)
			? `You declined the invitation to ${handle}.`
			: `You've left ${handle}.`;
		return left.warning ? `${said} ${left.warning}` : said;
	}
	function ask(title: string, body: string, ok: string, going: string, go: () => Promise<string>) {
		asking = { title, body, ok, busy: going, go };
		closeMenus();
		document.getElementById('org-confirm')?.showPopover();
	}
	async function confirm() {
		await act('confirm', asking!.go);
		document.getElementById('org-confirm')?.hidePopover();
	}
</script>

{#snippet actions(m: Member)}
	{@const theirs = m.actor.did === me}
	{#if (theirs && joined(m)) || (admin && !theirs)}
		<details class="popover member-menu" data-held={busy || undefined}>
			<summary class="btn" aria-label="What to do about {m.actor.handle}"
				><Icon name="kebab" /></summary
			>
			<div class="menu">
				{#if theirs}
					{#if m.standing === 'hidden'}
						<button
							class="menu-item"
							type="button"
							disabled={!!busy}
							aria-busy={busy === 'public'}
							onclick={() => join('public')}
							><Icon name="eye" />{busy === 'public' ? 'Showing…' : 'Show my membership'}</button
						>
					{:else}
						<button
							class="menu-item"
							type="button"
							disabled={!!busy}
							aria-busy={busy === 'hidden'}
							onclick={() => join('hidden')}
							><Icon name="eyeClosed" />{busy === 'hidden'
								? 'Hiding…'
								: 'Hide my membership'}</button
						>
					{/if}
					<hr />
					<button
						class="menu-item danger"
						type="button"
						disabled={!!busy}
						onclick={() =>
							ask(
								`Leave ${handle}?`,
								'You can no longer push to its repositories or read its private ones. To come back, an admin has to invite you again.',
								'Leave',
								'Leaving…',
								leave
							)}><Icon name="signOut" />{`Leave ${handle}`}</button
					>
				{:else if invited(m)}
					{#if m.standing === 'lapsed'}<button
							class="menu-item"
							type="button"
							disabled={!!busy}
							aria-busy={busy === `again ${m.actor.did}`}
							onclick={() => reinvite(m)}
							><Icon name="personAdd" />{busy === `again ${m.actor.did}`
								? 'Inviting…'
								: 'Invite again'}</button
						>{/if}
					<button
						class="menu-item"
						type="button"
						disabled={!!busy}
						aria-busy={busy === `role ${m.actor.did}`}
						onclick={() => setRole(m, m.role === 'admin' ? 'member' : 'admin')}
						><Icon name="people" />{busy === `role ${m.actor.did}`
							? 'Inviting…'
							: m.role === 'admin'
								? 'Invite as member'
								: 'Invite as admin'}</button
					>
					<hr />
					<button
						class="menu-item danger"
						type="button"
						disabled={!!busy}
						aria-busy={busy === `withdraw ${m.actor.did}`}
						onclick={() => act(`withdraw ${m.actor.did}`, () => remove(m))}
						><Icon name="x" />{busy === `withdraw ${m.actor.did}`
							? 'Withdrawing…'
							: 'Withdraw invitation'}</button
					>
				{:else}
					<button
						class="menu-item"
						type="button"
						disabled={!!busy}
						aria-busy={busy === `role ${m.actor.did}`}
						onclick={() => setRole(m, m.role === 'admin' ? 'member' : 'admin')}
						><Icon name="people" />{busy === `role ${m.actor.did}`
							? 'Saving…'
							: m.role === 'admin'
								? 'Make member'
								: 'Make admin'}</button
					>
					<hr />
					<button
						class="menu-item danger"
						type="button"
						disabled={!!busy}
						onclick={() =>
							ask(
								`Remove ${m.actor.handle} from ${handle}?`,
								'They can no longer push to its repositories or read its private ones. Repositories shared with them directly stay shared.',
								'Remove',
								'Removing…',
								() => remove(m)
							)}><Icon name="x" />{`Remove from ${handle}`}</button
					>
				{/if}
			</div>
		</details>
	{/if}
{/snippet}

{#if mine?.standing === 'invited'}
	<div class="org-invitation" role="region" aria-label="Your invitation">
		<h2>{`${handle} invited you to join as ${roleName(mine.role)}`}</h2>
		<p>
			Members can push to each of its repositories. Choose who can see that you're one. You can
			change it later.
		</p>
		<div class="choices">
			<div class="choice">
				<button
					class="btn primary"
					type="button"
					disabled={!!busy}
					aria-busy={busy === 'public'}
					onclick={() => join('public')}>{busy === 'public' ? 'Joining…' : 'Join publicly'}</button
				>
				<p>
					Anyone can see you're a member. You can merge, close and label on all its repositories.
				</p>
			</div>
			<div class="choice">
				<button
					class="btn"
					type="button"
					disabled={!!busy}
					aria-busy={busy === 'hidden'}
					onclick={() => join('hidden')}>{busy === 'hidden' ? 'Joining…' : 'Join hidden'}</button
				>
				<p>
					Only its members can see it. On its public repositories you can push and open pulls, but
					merging needs a public membership.
				</p>
			</div>
		</div>
		<p class="decline">
			Not for you? <button
				class="text-button"
				type="button"
				disabled={!!busy}
				aria-busy={busy === 'decline'}
				onclick={() => act('decline', leave)}
				>{busy === 'decline' ? 'Declining…' : 'Decline the invitation'}</button
			>
		</p>
	</div>
{/if}

{#if admin}
	<section class="org-invite">
		<div class="section"><h2>Invite someone</h2></div>
		{#if undelegated && !gotDelegated}
			<p class="hint">
				Before your first invitation, let this server write for you. Members join and leave through
				it. You approve it at your PDS, for knotless's records alone.
			</p>
			<Delegate done={() => (gotDelegated = true)} />
		{:else}
			<form class="form" onsubmit={invite}>
				<div class="owned">
					<HandleInput
						id="invite-who"
						name="who"
						label="Handle"
						autocomplete="off"
						bind:value={who}
					/>
					<select name="role" aria-label="Role" bind:value={role}
						><option value="member">Member</option><option value="admin"
							>Admin, can also invite</option
						></select
					>
					<button class="btn primary" type="submit" disabled={!!busy} aria-busy={busy === 'invite'}
						>{busy === 'invite' ? 'Inviting…' : 'Invite'}</button
					>
				</div>
			</form>
			<p class="hint">
				They become a member once they accept. Invitations last 7 days, and only {handle} and its members
				can see them.
			</p>
		{/if}
	</section>
{/if}

{#if problem}<p class="problem" role="alert">{problem}</p>{/if}
{#if done}<p class="org-said" role="status">{done}</p>{/if}

<div class="section"><h2>Members</h2></div>
{#snippet nobody()}
	{#if !org.organization}<b>No members yet</b><span
			>{`Invite someone above. ${handle} becomes an organization with your first invitation. People show here once they accept.`}</span
		>{:else if admin}<b>No members yet</b><span
			>Invite someone above. They show here once they accept.</span
		>{:else}{org.inside ? 'No members yet.' : 'No public members yet.'}{/if}
{/snippet}
<MemberList {members} empty={nobody} {me} {actions} />
{#if invitations.length}
	<div class="section"><h2>Invitations</h2></div>
	{#snippet uninvited()}No invitations.{/snippet}
	<MemberList members={invitations} empty={uninvited} {me} {actions} />
{/if}
{#if !org.organization}
	<p class="feed-note">Only you see this tab until you invite someone.</p>
{:else if itself}
	<p class="feed-note">
		To end the organization, go to <a href="/settings/organization">its settings</a>.
	</p>
{/if}

<Dialog id="org-confirm" title={asking?.title ?? ''}>
	{#if asking}
		<p class="ask">{asking.body}</p>
		<div class="actions">
			<button class="btn" type="button" popovertarget="org-confirm" popovertargetaction="hide"
				>Cancel</button
			>
			<button
				class="btn danger solid"
				type="button"
				disabled={!!busy}
				aria-busy={busy === 'confirm'}
				onclick={confirm}>{busy === 'confirm' ? asking.busy : asking.ok}</button
			>
		</div>
	{/if}
</Dialog>