johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
<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>