<script lang="ts">
import { refreshAll } from "$app/navigation";
import { page } from "$app/state";
import ArrowRight from "$icon/arrow-right";
import LogIn from "$icon/log-in";
import LogOut from "$icon/log-out";
import RotateCcw from "$icon/rotate-ccw";
import X from "$icon/x";
import { getAuth } from "#lib/auth.svelte.ts";
import { createAction } from "#lib/action.svelte.ts";
import { createBobbinClient } from "#lib/api/client.ts";
import Meta from "#lib/components/seo/Meta.svelte";
import Button from "#lib/components/ui/Button.svelte";
import ConfirmModal from "#lib/components/ui/ConfirmModal.svelte";
import ErrorAlert from "#lib/components/ui/Error.svelte";
import {
importFromLiar,
MigrationScopeError,
unmarkLiarAccount,
type ImportProgress,
type LostBlob
} from "#lib/migration/import.ts";
import { waitForMigration, type MigrationConfig } from "#lib/migration/status.ts";
interface Props {
config: MigrationConfig & { bobbinUrl: string };
onSignOut: () => void;
}
let { config, onSignOut }: Props = $props();
const auth = getAuth();
let progress = $state<ImportProgress | null>(null);
let waiting = $state(false);
let needsGrant = $state(false);
let confirmSkip = $state(false);
let lostBlobs = $state<LostBlob[]>([]);
let migrated = $state(false);
// a retry skips whatever already went through
let imported = false;
let unmarked = false;
const migrate = createAction(async (skip: boolean = false) => {
const agent = auth.agent;
if (!agent) throw new Error("Still signing you in. Try again in a moment.");
needsGrant = false;
confirmSkip = false;
try {
const fetch = config.liarMock
? (await import("#lib/migration/mock.ts")).mockLiarFetch
: undefined;
if (!imported && !skip) {
({ lostBlobs } = await importFromLiar(agent, {
liarPdsUrl: config.liarPdsUrl,
bobbinUrl: config.bobbinUrl,
fetch,
onProgress: (next) => (progress = next)
}));
imported = true;
}
if (!unmarked) {
progress = {
phase: "finalizing",
done: 0,
total: 0,
overall: { done: 1, total: 1 }
};
await unmarkLiarAccount(agent, config.bobbinUrl, { fetch });
unmarked = true;
}
progress = null;
waiting = true;
const settled = await waitForMigration(
createBobbinClient({ serviceUrl: config.bobbinUrl }),
agent.sub,
config
);
if (!settled) {
throw new Error(
"Your data is migrated, but Tangled hasn't switched you over yet. Try again in a minute."
);
}
if (lostBlobs.length > 0) migrated = true;
else await refreshAll();
} catch (cause) {
needsGrant = cause instanceof MigrationScopeError;
throw cause;
} finally {
progress = null;
waiting = false;
}
});
const reauthorize = createAction(async () => {
const did = auth.currentDid;
if (!did) return;
await auth.signIn(did, page.url.pathname + page.url.search);
});
const status = $derived.by(() => {
if (waiting) return "Waiting for Tangled to switch you over…";
if (progress?.resumesAt) {
const at = new Date(progress.resumesAt).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit"
});
return `Your PDS limits how fast records can be written. Continuing at ${at}…`;
}
if (progress?.phase === "reading") return "Reading records…";
if (progress?.phase === "blobs") {
return `Copying files (${progress.done} of ${progress.total})`;
}
if (progress?.phase === "importing") {
return `Migrating records (${progress.done} of ${progress.total})`;
}
if (progress?.phase === "finalizing") return "Finishing up…";
return null;
});
const percent = $derived.by(() => {
if (waiting || progress?.phase === "finalizing") return 100;
if (!progress?.overall.total) return 0;
return Math.round((progress.overall.done / progress.overall.total) * 100);
});
const indeterminate = $derived(waiting || progress?.phase === "reading");
</script>
<Meta noindex title="Migrate your data" />
<section class="mx-auto flex w-full max-w-medium flex-col gap-md px-7 py-5xl">
<h1 class="typography-heading-2 text-foreground-default">Migrate your data</h1>
<p class="typography-paragraph-regular text-foreground-muted">
Tangled now stores your data in a new record format. Migrate it to keep using Tangled
{#if auth.currentUser}as <span class="font-medium text-foreground-default"
>{auth.currentUser.handle}</span
>{/if}. If anything fails, you can safely try again.
</p>
<ErrorAlert label={reauthorize.error ?? migrate.error} />
{#if migrated}
<div
class="flex flex-col gap-xs rounded-md border border-border-default bg-background-warning-subtle px-sm py-xs typography-paragraph-small text-foreground-warning-strong"
role="status"
>
<p>
Your data is migrated, but some files couldn't be copied because they no longer
exist anywhere. These records were migrated without them:
</p>
<ul class="list-disc pl-md">
{#each lostBlobs as lost (`${lost.collection}/${lost.rkey}/${lost.field}`)}
<li class="typography-monospace-regular">
{lost.collection}/{lost.rkey} ({lost.field})
</li>
{/each}
</ul>
</div>
<div>
<Button variant="primary" icon={ArrowRight} onclick={() => refreshAll()}
>Continue</Button
>
</div>
{:else}
<div class="flex flex-wrap items-center gap-sm">
{#if needsGrant}
<Button
variant="primary"
icon={LogIn}
loading={reauthorize.loading}
onclick={reauthorize.run}
>
Sign in again to grant permission
</Button>
{:else}
<Button
variant={migrate.error ? "danger" : "primary"}
icon={migrate.error ? RotateCcw : ArrowRight}
loading={migrate.loading}
disabled={!auth.agent}
onclick={() => migrate.run()}
>
{migrate.error ? "Try again" : "Start migration"}
</Button>
{/if}
<Button
variant="ghost"
icon={X}
disabled={!auth.agent || migrate.loading}
onclick={() => (confirmSkip = true)}
>
Skip migration
</Button>
<Button variant="ghost" icon={LogOut} disabled={migrate.loading} onclick={onSignOut}>
Sign out
</Button>
</div>
{/if}
{#if status}
<div class="flex flex-col gap-xs" role="status">
<p class="typography-paragraph-small text-foreground-muted tabular-nums">{status}</p>
<div
class={[
"h-2 w-full overflow-hidden rounded-full border border-border-default bg-background-inset",
indeterminate && "animate-pulse motion-reduce:animate-none"
]}
role="progressbar"
aria-valuenow={indeterminate ? undefined : percent}
aria-valuemin={0}
aria-valuemax={100}
>
<div
class="h-full rounded-full bg-background-info-emphasis transition-transform duration-250 ease-out-strong motion-reduce:transition-none"
style:transform="translateX(-{100 - percent}%)"
></div>
</div>
</div>
{/if}
</section>
<ConfirmModal
bind:open={confirmSkip}
title="Skip migration?"
description="Your existing Tangled data (sh.tangled.* records) won't be copied to the new format. It stays in your PDS, but Tangled no longer reads it, so none of it will appear on Tangled."
confirmLabel="Skip migration"
loading={migrate.loading}
onconfirm={() => migrate.run(true)}
/>