bump
Some checks failed
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
Bryan1029384756
2026-04-20 17:09:04 -05:00
parent 82f8a12e27
commit b83360db35
48 changed files with 6079 additions and 49 deletions

View File

@@ -0,0 +1,125 @@
.panel {
display: flex;
flex-direction: column;
gap: 14px;
}
.intro {
display: flex;
gap: 12px;
padding: 12px 14px;
background-color: var(--background-tertiary, rgba(255, 255, 255, 0.03));
border-radius: 10px;
}
.introIcon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: color-mix(in srgb, var(--brand-primary, #5865f2) 18%, transparent);
color: var(--brand-primary, #5865f2);
flex-shrink: 0;
}
.introTitle {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
}
.introText {
font-size: 0.8125rem;
line-height: 1.45;
color: var(--text-secondary);
margin: 0;
}
.notice {
padding: 14px;
border-radius: 10px;
background-color: color-mix(in srgb, var(--status-warning, #faa61a) 12%, transparent);
color: var(--text-secondary);
font-size: 0.8125rem;
line-height: 1.45;
}
.errorBanner {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: 10px;
background-color: color-mix(in srgb, var(--status-warning, #faa61a) 14%, transparent);
color: var(--status-warning, #faa61a);
font-size: 0.8125rem;
line-height: 1.4;
}
.status {
font-size: 0.8125rem;
margin: 0;
line-height: 1.4;
}
.statusSuccess {
color: var(--status-positive, #23a55a);
}
.statusError {
color: var(--status-danger, #da373c);
}
.empty {
padding: 22px 14px;
text-align: center;
font-size: 0.8125rem;
color: var(--text-tertiary);
background-color: var(--background-tertiary, rgba(255, 255, 255, 0.03));
border-radius: 10px;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
max-height: 360px;
overflow-y: auto;
}
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px;
border-radius: 8px;
background-color: var(--background-tertiary, rgba(255, 255, 255, 0.03));
}
.rowText {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.rowName {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowSub {
font-size: 0.75rem;
color: var(--text-tertiary);
margin-top: 2px;
}

View File

@@ -0,0 +1,253 @@
/**
* ChannelAccessPanel — admin tool that lists users who don't have a
* `channelKeys` row for a given (non-DM) channel and grants them the
* key one-by-one.
*
* Background: an earlier broken invite flow caused new joiners to only
* receive the key for a single channel instead of every server channel.
* Those users have no row for the missing channels, can't decrypt
* messages there, and don't show up in the member list. Since E2E means
* the server never holds plaintext channel keys, the admin's client
* does the re-encryption: decrypt our own bundle → re-encrypt against
* the target user's RSA public key → hand the ciphertext to the
* `grantChannelAccess` mutation.
*/
import { useCallback, useEffect, useState } from 'react';
import { useConvex, useMutation, useQuery } from 'convex/react';
import { Key, UserPlus, WarningCircle } from '@phosphor-icons/react';
import { Avatar, Button } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import { usePlatform } from '../../platform';
import styles from './ChannelAccessPanel.module.css';
interface ChannelAccessPanelProps {
channelId: Id<'channels'>;
}
export function ChannelAccessPanel({ channelId }: ChannelAccessPanelProps) {
const { crypto } = usePlatform();
const convex = useConvex();
const grantAccess = useMutation(api.channelKeys.grantChannelAccess);
const myUserId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const myPerms = useQuery(
api.roles.getMyPermissions,
myUserId ? { userId: myUserId as Id<'userProfiles'> } : 'skip',
);
const canManage = !!myPerms?.manage_channels;
const missing = useQuery(
api.channelKeys.getUsersMissingChannelKey,
canManage && myUserId
? {
actorId: myUserId as Id<'userProfiles'>,
channelId,
}
: 'skip',
);
// Admin's decrypted key for this channel. `null` = not loaded yet,
// `{ hex: null }` = admin themselves is missing the key and thus
// can't grant it.
const [myKey, setMyKey] = useState<
| { hex: string; version: number }
| { hex: null; version: null }
| null
>(null);
const [loadErr, setLoadErr] = useState<string | null>(null);
const [granting, setGranting] = useState<Id<'userProfiles'> | null>(null);
const [status, setStatus] = useState<
{ type: 'success' | 'error'; message: string } | null
>(null);
// Load + decrypt the admin's own channel-key bundle for this
// channel. One-shot on channel change — not `useQuery`, because
// reactivity would reshuffle the map mid-grant. Pattern mirrors
// InviteModal.tsx:91-123.
useEffect(() => {
let cancelled = false;
(async () => {
if (!canManage || !myUserId) return;
setLoadErr(null);
setMyKey(null);
const privateKey =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('privateKey')
: null;
if (!privateKey) {
setLoadErr(
'No decryption key available. Log out and back in to restore your session.',
);
return;
}
try {
const bundles = await convex.query(api.channelKeys.getKeysForUser, {
userId: myUserId as Id<'userProfiles'>,
});
let found: { hex: string; version: number } | null = null;
for (const b of bundles) {
try {
const plaintext = await crypto.privateDecrypt(
privateKey,
b.encrypted_key_bundle,
);
const parsed = JSON.parse(plaintext) as Record<string, string>;
const hex = parsed[channelId as unknown as string];
if (hex) {
found = { hex, version: b.key_version };
break;
}
} catch {
/* unreadable bundle — keep scanning others */
}
}
if (cancelled) return;
setMyKey(found ?? { hex: null, version: null });
} catch (err: any) {
if (!cancelled) setLoadErr(err?.message ?? 'Failed to load your keys.');
}
})();
return () => {
cancelled = true;
};
}, [canManage, myUserId, channelId, convex, crypto]);
const handleGrant = useCallback(
async (user: {
userId: Id<'userProfiles'>;
userPublicKey: string;
username: string;
}) => {
if (!myUserId || !myKey || myKey.hex === null) return;
setGranting(user.userId);
setStatus(null);
try {
const payload = JSON.stringify({
[channelId as unknown as string]: myKey.hex,
});
const encryptedKeyBundle = await crypto.publicEncrypt(
user.userPublicKey,
payload,
);
await grantAccess({
actorId: myUserId as Id<'userProfiles'>,
channelId,
userId: user.userId,
encryptedKeyBundle,
keyVersion: myKey.version,
});
setStatus({
type: 'success',
message: `Granted access to @${user.username}.`,
});
} catch (err: any) {
setStatus({
type: 'error',
message: err?.message ?? 'Failed to grant access.',
});
} finally {
setGranting(null);
}
},
[channelId, crypto, grantAccess, myKey, myUserId],
);
if (!canManage) {
return (
<div className={styles.notice}>
You need the <strong>Manage Channels</strong> permission to manage
channel access.
</div>
);
}
if (loadErr) {
return (
<div className={styles.errorBanner}>
<WarningCircle size={18} weight="fill" />
<span>{loadErr}</span>
</div>
);
}
const adminHasKey = myKey !== null && myKey.hex !== null;
return (
<div className={styles.panel}>
<div className={styles.intro}>
<div className={styles.introIcon}>
<Key size={18} weight="fill" />
</div>
<div>
<div className={styles.introTitle}>Channel Access</div>
<p className={styles.introText}>
Users listed here don't have the key for this channel — most
likely because they joined via an invite that predated it. Click
<strong> Grant Access </strong>to hand them the key so they can
decrypt messages.
</p>
</div>
</div>
{myKey !== null && !adminHasKey && (
<div className={styles.errorBanner}>
<WarningCircle size={18} weight="fill" />
<span>
Your account is also missing the key for this channel. Ask
another admin to grant access to you first.
</span>
</div>
)}
{status && (
<p
className={`${styles.status} ${
status.type === 'success' ? styles.statusSuccess : styles.statusError
}`}
>
{status.message}
</p>
)}
{missing === undefined ? (
<div className={styles.empty}>Loading users…</div>
) : missing.length === 0 ? (
<div className={styles.empty}>Everyone has access to this channel.</div>
) : (
<ul className={styles.list}>
{missing.map((u) => {
const name = u.displayName?.trim() || u.username;
const isGranting = granting === u.userId;
return (
<li key={u.userId as unknown as string} className={styles.row}>
<Avatar src={u.avatarUrl} fallback={name} size={36} />
<div className={styles.rowText}>
<div className={styles.rowName}>{name}</div>
<div className={styles.rowSub}>@{u.username}</div>
</div>
<Button
variant="primary"
size="sm"
icon={<UserPlus size={14} weight="bold" />}
onClick={() =>
handleGrant({
userId: u.userId,
userPublicKey: u.userPublicKey,
username: u.username,
})
}
loading={isGranting}
disabled={!adminHasKey || granting !== null}
>
Grant Access
</Button>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -57,6 +57,39 @@
margin-top: 2px;
}
/* ── Tab strip ───────────────────────────────────────────────────────── */
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--background-modifier-accent, rgba(255, 255, 255, 0.08));
margin: 0 -2px;
}
.tab {
appearance: none;
border: 0;
background: transparent;
padding: 8px 12px;
margin-bottom: -1px;
font: inherit;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 120ms ease, border-color 120ms ease;
}
.tab:hover {
color: var(--text-primary);
}
.tabActive {
color: var(--text-primary);
border-bottom-color: var(--brand-primary, #5865f2);
}
/* ── Form fields ─────────────────────────────────────────────────────── */
.field {

View File

@@ -10,6 +10,7 @@ import { Hash, SpeakerHigh, Trash } from '@phosphor-icons/react';
import { Button, Modal } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import { ChannelAccessPanel } from './ChannelAccessPanel';
import styles from './ChannelSettingsModal.module.css';
interface ChannelSettingsModalProps {
@@ -51,6 +52,7 @@ export function ChannelSettingsModal({
{ type: 'success' | 'error'; message: string } | null
>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [activeTab, setActiveTab] = useState<'settings' | 'access'>('settings');
useEffect(() => {
if (isOpen && channel) {
@@ -58,6 +60,7 @@ export function ChannelSettingsModal({
setTopic(channel.topic || '');
setStatus(null);
setConfirmDelete(false);
setActiveTab('settings');
}
}, [isOpen, channel?._id, channel?.name, channel?.topic]);
@@ -115,6 +118,7 @@ export function ChannelSettingsModal({
};
const isVoice = channel.type === 'voice';
const showAccessTab = canModify;
return (
<Modal.Root isOpen={isOpen} onClose={onClose} size="medium">
@@ -146,6 +150,37 @@ export function ChannelSettingsModal({
</div>
</div>
{showAccessTab && (
<div className={styles.tabs} role="tablist">
<button
type="button"
role="tab"
aria-selected={activeTab === 'settings'}
className={`${styles.tab} ${
activeTab === 'settings' ? styles.tabActive : ''
}`}
onClick={() => setActiveTab('settings')}
>
Settings
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'access'}
className={`${styles.tab} ${
activeTab === 'access' ? styles.tabActive : ''
}`}
onClick={() => setActiveTab('access')}
>
Access
</button>
</div>
)}
{activeTab === 'access' && showAccessTab ? (
<ChannelAccessPanel channelId={channel._id as Id<'channels'>} />
) : (
<>
<fieldset className={styles.field} disabled={!canModify}>
<label className={styles.label} htmlFor="channel-settings-name">
Channel Name
@@ -254,23 +289,27 @@ export function ChannelSettingsModal({
)}
</div>
)}
</>
)}
</div>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
size="sm"
disabled={
!canModify || !isDirty || !nameValid || !topicValid || saving
}
loading={saving}
onClick={handleSave}
>
Save Changes
{activeTab === 'access' ? 'Close' : 'Cancel'}
</Button>
{activeTab !== 'access' && (
<Button
variant="primary"
size="sm"
disabled={
!canModify || !isDirty || !nameValid || !topicValid || saving
}
loading={saving}
onClick={handleSave}
>
Save Changes
</Button>
)}
</Modal.Footer>
</Modal.Root>
);

View File

@@ -187,15 +187,17 @@ export function MessageActionBar({
>
<Smiley size={20} />
</button>
<button
type="button"
className={styles.button}
onClick={onReply}
aria-label="Reply"
title="Reply"
>
<ArrowBendUpLeft size={20} />
</button>
{onReply && (
<button
type="button"
className={styles.button}
onClick={onReply}
aria-label="Reply"
title="Reply"
>
<ArrowBendUpLeft size={20} />
</button>
)}
{isOwnMessage && onEdit && (
<button
type="button"

View File

@@ -1111,8 +1111,16 @@ export function Messages({ channelId, onReply }: MessagesProps) {
}, [status, loadMore]);
const groups = useMemo(() => {
// Imported messages carry their original Discord timestamp on
// `timestamp` (via the server's `importedCreatedAt` override),
// so sort the window by effective timestamp before grouping —
// otherwise a freshly-imported old message would land next to
// a live message that happens to share an insertion neighbour,
// and the author-merge grouping would conflate the two despite
// a huge display-time gap.
const ordered = decrypted.slice().sort((a, b) => a.timestamp - b.timestamp);
const result: DecryptedMessage[][] = [];
for (const msg of decrypted) {
for (const msg of ordered) {
const last = result[result.length - 1];
if (last && last[last.length - 1].senderId === msg.senderId) {
const gap = msg.timestamp - last[last.length - 1].timestamp;

View File

@@ -63,6 +63,38 @@
cursor: default;
}
.tabs {
display: flex;
gap: 4px;
padding: 0 8px;
border-bottom: 1px solid var(--background-header-secondary, rgba(255, 255, 255, 0.08));
flex-shrink: 0;
}
.tab {
appearance: none;
border: 0;
background: transparent;
padding: 12px 16px;
margin-bottom: -1px;
font: inherit;
font-size: 15px;
font-weight: 600;
color: var(--text-secondary, #b5bac1);
cursor: pointer;
border-bottom: 2px solid transparent;
-webkit-tap-highlight-color: transparent;
}
.tab:active {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.06));
}
.tabActive {
color: var(--text-primary, #fff);
border-bottom-color: var(--brand-primary, #5865f2);
}
.body {
flex: 1;
min-height: 0;

View File

@@ -35,6 +35,7 @@ import { Button, BottomSheet } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import { useBackHandler } from '../../hooks/useBackHandler';
import { ChannelAccessPanel } from './ChannelAccessPanel';
import styles from './MobileChannelSettingsPage.module.css';
const NAME_MAX = 100;
@@ -81,6 +82,7 @@ export function MobileChannelSettingsPage({
>(null);
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [activeTab, setActiveTab] = useState<'settings' | 'access'>('settings');
// Seed local form state whenever the page (re)opens against a
// different channel, so stale edits never leak across switches.
@@ -92,6 +94,7 @@ export function MobileChannelSettingsPage({
setStatus(null);
setConfirmDelete(false);
setShowCategoryPicker(false);
setActiveTab('settings');
}
}, [isOpen, channel?._id, channel?.name, channel?.topic, channel?.categoryId]);
@@ -192,22 +195,53 @@ export function MobileChannelSettingsPage({
<ArrowLeft size={22} weight="bold" />
</button>
<h1 className={styles.headerTitle}>Channel Settings</h1>
<button
type="button"
className={`${styles.headerAction} ${
canModify && isDirty && nameValid && topicValid && !saving
? styles.headerActionActive
: ''
}`}
onClick={handleSave}
disabled={
!canModify || !isDirty || !nameValid || !topicValid || saving
}
>
{saving ? 'Saving…' : 'Save'}
</button>
{activeTab === 'settings' ? (
<button
type="button"
className={`${styles.headerAction} ${
canModify && isDirty && nameValid && topicValid && !saving
? styles.headerActionActive
: ''
}`}
onClick={handleSave}
disabled={
!canModify || !isDirty || !nameValid || !topicValid || saving
}
>
{saving ? 'Saving…' : 'Save'}
</button>
) : (
<span className={styles.headerAction} />
)}
</header>
{canModify && (
<div className={styles.tabs} role="tablist">
<button
type="button"
role="tab"
aria-selected={activeTab === 'settings'}
className={`${styles.tab} ${
activeTab === 'settings' ? styles.tabActive : ''
}`}
onClick={() => setActiveTab('settings')}
>
Settings
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'access'}
className={`${styles.tab} ${
activeTab === 'access' ? styles.tabActive : ''
}`}
onClick={() => setActiveTab('access')}
>
Access
</button>
</div>
)}
<main className={styles.body}>
{!canModify && (
<div className={styles.warning}>
@@ -215,6 +249,10 @@ export function MobileChannelSettingsPage({
</div>
)}
{activeTab === 'access' && canModify ? (
<ChannelAccessPanel channelId={channel._id as Id<'channels'>} />
) : (
<>
<label className={styles.fieldLabel}>Channel Name</label>
<input
type="text"
@@ -276,6 +314,8 @@ export function MobileChannelSettingsPage({
<span>Delete Channel</span>
</button>
)}
</>
)}
</main>
{/* Category picker sheet — tap a row to select, sheet auto-closes. */}

View File

@@ -17,6 +17,7 @@ import { Modal, Button } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import { EmojiPicker, type EmojiPickerValue } from './EmojiPicker';
import { MessageActionBar } from './MessageActionBar';
import { TwemojiImg } from './TwemojiImg';
import { resolveReactionKeyToUnicode } from '../../utils/emojiLookup';
import styles from './PollCard.module.css';
@@ -37,8 +38,21 @@ export function PollCard({ pollId }: PollCardProps) {
const voteMutation = useMutation(api.polls.vote);
const clearVoteMutation = useMutation(api.polls.clearVote);
const closeMutation = useMutation(api.polls.close);
const removePollMutation = useMutation(api.polls.remove);
const addReactionMutation = useMutation(api.polls.addReaction);
const removeReactionMutation = useMutation(api.polls.removeReaction);
// Permission check for "delete any poll" — mirrors how the message
// action bar gates the delete button on own-message or manage_messages.
const myPerms = useQuery(
api.roles.getMyPermissions,
myUserId ? { userId: myUserId as Id<'userProfiles'> } : 'skip',
);
// Right-click / long-press context menu anchor for the action bar
// dropdown. `null` = closed; a point opens the More menu at that spot.
const [contextMenuAt, setContextMenuAt] = useState<
{ x: number; y: number } | null
>(null);
const [forceBarVisible, setForceBarVisible] = useState(false);
// Reaction picker — anchored off the Add Reaction button. `null`
// means the picker is closed.
@@ -79,8 +93,8 @@ export function PollCard({ pollId }: PollCardProps) {
}
};
const openReactPicker = () => {
const btn = addReactionButtonRef.current;
const openReactPicker = (anchor?: HTMLElement | null) => {
const btn = anchor ?? addReactionButtonRef.current;
if (!btn) return;
const rect = btn.getBoundingClientRect();
setReactPickerPos({
@@ -156,8 +170,54 @@ export function PollCard({ pollId }: PollCardProps) {
if (c > maxCount) maxCount = c;
}
const isCreator = !!myUserId && poll.createdBy === myUserId;
const canDeletePoll = isCreator || !!myPerms?.manage_messages;
const handleQuickReact = (emoji: string) => {
if (!myUserId) return;
void addReactionMutation({
pollId: poll._id,
userId: myUserId as Id<'userProfiles'>,
emoji,
});
};
const handleDeletePoll = () => {
if (!myUserId || !canDeletePoll) return;
void removePollMutation({
pollId: poll._id,
userId: myUserId as Id<'userProfiles'>,
}).catch((err) => {
console.error('Failed to delete poll:', err);
});
};
const handleCopyPollText = () => {
const text = [poll.question || 'Poll', ...poll.options.map((o) => `- ${o.text}`)].join(
'\n',
);
void navigator.clipboard?.writeText?.(text).catch(() => {});
};
return (
<div className={styles.card}>
<div
className={`${styles.card} messageHoverable ${forceBarVisible ? 'actionBarForceVisible' : ''}`}
style={{ position: 'relative' }}
onContextMenu={(e) => {
e.preventDefault();
setContextMenuAt({ x: e.clientX, y: e.clientY });
}}
>
<MessageActionBar
isOwnMessage={canDeletePoll}
onQuickReact={handleQuickReact}
onReact={(e) => openReactPicker(e?.currentTarget ?? null)}
onDelete={canDeletePoll ? handleDeletePoll : undefined}
onCopyText={handleCopyPollText}
externalMenuAt={contextMenuAt}
onExternalMenuClose={() => setContextMenuAt(null)}
onMenuOpenChange={(open) => setForceBarVisible(open)}
/>
<div className={styles.question}>{poll.question || 'Poll'}</div>
{!countsVisible && !isEnded && (

View File

@@ -186,6 +186,17 @@ export function AppLayout() {
window.addEventListener('brycord:keybind:navigation.goToDMs', goHome);
window.addEventListener('brycord:keybind:navigation.focusSearch', focusSearch);
window.addEventListener('brycord:keybind:popouts.openUserSettings', openSettings);
// Tray menu → keybind events. We reuse the voice keybind
// dispatch channel (already handled by UserArea) so the tray,
// hotkeys, and UI buttons all converge on the same toggle path.
const unsubTray =
platform?.lifecycle?.onTrayAction?.((action: string) => {
if (action === 'toggle-mute') {
window.dispatchEvent(new CustomEvent('brycord:keybind:voice.toggleMute'));
} else if (action === 'toggle-deafen') {
window.dispatchEvent(new CustomEvent('brycord:keybind:voice.toggleDeafen'));
}
}) ?? null;
return () => {
window.removeEventListener('brycord:keybind:navigation.goToDMs', goHome);
window.removeEventListener(
@@ -196,8 +207,9 @@ export function AppLayout() {
'brycord:keybind:popouts.openUserSettings',
openSettings,
);
if (typeof unsubTray === 'function') unsubTray();
};
}, [navigate]);
}, [navigate, platform]);
const hasSession =
typeof sessionStorage !== 'undefined' &&

View File

@@ -0,0 +1,309 @@
/**
* GhostsTab — lists every placeholder profile created by the
* backup importer and lets an admin merge one into a real user.
*
* Merge flow is paged (`mergeGhostPageAction` returns `done: false`
* until all messages have been rewritten) so even ghosts with
* 50k+ messages complete without tripping Convex's mutation time
* limit. The component drives the loop, tallying the total count
* for the audit metadata.
*/
import { useState } from 'react';
import { useAction, useQuery } from 'convex/react';
import { Ghost, X } from '@phosphor-icons/react';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import { usePlatform } from '../../platform';
export function GhostsTab() {
const platform = usePlatform();
const myUserId =
typeof localStorage !== 'undefined'
? (localStorage.getItem('userId') as Id<'userProfiles'> | null)
: null;
const ghosts =
useQuery(
api.importer.listGhosts,
myUserId ? { actorId: myUserId } : 'skip',
) ?? [];
const candidates =
useQuery(
api.importer.listMappingCandidates,
myUserId ? { actorId: myUserId } : 'skip',
) ?? [];
const mergePage = useAction(api.importerActions.mergeGhostPageAction);
const finalize = useAction(api.importerActions.finalizeMergeAction);
const [activeGhost, setActiveGhost] = useState<string | null>(null);
const [target, setTarget] = useState('');
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const realUsers = (candidates as any[]).filter((c) => !c.isGhost);
const handleMerge = async () => {
if (!myUserId || !activeGhost || !target) return;
const signingKey =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('signingKey')
: null;
if (!signingKey) {
setError('Session signing key missing — please log out and back in.');
return;
}
setBusy(true);
setError(null);
setStatus('Rewriting messages…');
try {
let total = 0;
for (;;) {
const authTimestamp = Date.now();
const canonical = `mergeGhostPage:${myUserId}:${activeGhost}:${target}:${authTimestamp}`;
const authSignature = await platform.crypto.signMessage(
signingKey,
canonical,
);
const page = await mergePage({
actorId: myUserId,
ghostUserId: activeGhost as Id<'userProfiles'>,
targetUserId: target as Id<'userProfiles'>,
authTimestamp,
authSignature,
});
total += page.rewritten;
setStatus(`Rewrote ${total.toLocaleString()} messages…`);
if (page.done) break;
}
const finalTs = Date.now();
const canonical = `finalizeMerge:${myUserId}:${activeGhost}:${target}:${finalTs}`;
const sig = await platform.crypto.signMessage(signingKey, canonical);
await finalize({
actorId: myUserId,
ghostUserId: activeGhost as Id<'userProfiles'>,
targetUserId: target as Id<'userProfiles'>,
totalRewritten: total,
authTimestamp: finalTs,
authSignature: sig,
});
setStatus(`Merged ${total.toLocaleString()} messages.`);
setActiveGhost(null);
setTarget('');
} catch (err: any) {
setError(err?.message ?? 'Merge failed.');
} finally {
setBusy(false);
}
};
return (
<>
<div style={{ marginBottom: 16 }}>
<h2 style={headingStyle}>Ghost profiles</h2>
<p style={descStyle}>
Placeholder authors created by the backup importer. Merge a
ghost into a real user to rewrite every imported message's
author and delete the placeholder.
</p>
</div>
{error && (
<div style={errorStyle}>
<X size={16} weight="bold" /> {error}
</div>
)}
{status && !error && (
<div style={infoStyle}>{status}</div>
)}
{ghosts.length === 0 ? (
<div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>
No ghost profiles. Imported messages will land here if their
Discord author wasn't mapped to a local user.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{ghosts.map((g: any) => (
<div key={g._id} style={rowStyle}>
{g.ghostAvatarUrl ? (
<img
src={g.ghostAvatarUrl}
alt=""
style={{
width: 40,
height: 40,
borderRadius: '50%',
objectFit: 'cover',
}}
/>
) : (
<div style={avatarPlaceholder}>
<Ghost size={18} weight="bold" />
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
color: 'var(--text-primary)',
fontWeight: 600,
fontSize: 14,
}}
>
{g.displayName || g.username}
</div>
<div style={descSmallStyle}>
{g.messageCount.toLocaleString()} messages
{g.discordId ? ` · Discord ${g.discordId}` : ''}
</div>
</div>
<button
type="button"
onClick={() => {
setActiveGhost(g._id);
setTarget('');
setError(null);
setStatus(null);
}}
disabled={busy}
style={primaryBtnStyle}
>
Merge
</button>
</div>
))}
</div>
)}
{activeGhost && (
<div style={{ ...cardStyle, marginTop: 16 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>
Merge into real user
</div>
<select
value={target}
onChange={(e) => setTarget(e.target.value)}
disabled={busy}
style={inputStyle}
>
<option value="">Select a user</option>
{realUsers.map((u: any) => (
<option key={u._id} value={u._id}>
{u.displayName || u.username}
</option>
))}
</select>
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<button
type="button"
onClick={handleMerge}
disabled={!target || busy}
style={primaryBtnStyle}
>
{busy ? 'Merging…' : 'Confirm merge'}
</button>
<button
type="button"
onClick={() => {
setActiveGhost(null);
setTarget('');
}}
disabled={busy}
style={secondaryBtnStyle}
>
Cancel
</button>
</div>
</div>
)}
</>
);
}
const headingStyle: React.CSSProperties = {
fontSize: 20,
fontWeight: 700,
color: 'var(--text-primary)',
margin: 0,
marginBottom: 6,
};
const descStyle: React.CSSProperties = {
fontSize: 14,
color: 'var(--text-secondary)',
margin: 0,
};
const descSmallStyle: React.CSSProperties = {
fontSize: 12,
color: 'var(--text-secondary)',
marginTop: 2,
};
const cardStyle: React.CSSProperties = {
padding: 16,
borderRadius: 8,
background: 'var(--background-secondary)',
border: '1px solid var(--background-tertiary)',
};
const rowStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 12,
padding: 12,
borderRadius: 8,
background: 'var(--background-secondary)',
border: '1px solid var(--background-tertiary)',
};
const avatarPlaceholder: React.CSSProperties = {
width: 40,
height: 40,
borderRadius: '50%',
background: 'var(--background-tertiary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-primary)',
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
background: 'var(--background-tertiary)',
border: '1px solid var(--background-modifier-accent)',
borderRadius: 6,
color: 'var(--text-primary)',
fontSize: 14,
fontFamily: 'inherit',
outline: 'none',
boxSizing: 'border-box',
};
const primaryBtnStyle: React.CSSProperties = {
background: 'var(--brand-primary)',
color: '#fff',
border: 'none',
padding: '10px 18px',
borderRadius: 6,
cursor: 'pointer',
fontWeight: 600,
fontSize: 14,
};
const secondaryBtnStyle: React.CSSProperties = {
...primaryBtnStyle,
background: 'var(--background-tertiary)',
color: 'var(--text-primary)',
};
const errorStyle: React.CSSProperties = {
display: 'flex',
gap: 8,
alignItems: 'center',
padding: 10,
marginBottom: 12,
borderRadius: 6,
background: 'rgba(248, 113, 113, 0.12)',
color: '#f87171',
fontSize: 13,
};
const infoStyle: React.CSSProperties = {
padding: 10,
marginBottom: 12,
borderRadius: 6,
background: 'var(--background-secondary)',
color: 'var(--text-secondary)',
fontSize: 13,
};

View File

@@ -0,0 +1,814 @@
/**
* ImportTab — admin-only Discord backup importer UI.
*
* The actual run state lives in the `importSession` module so that
* closing/reopening the settings modal (which unmounts this
* component) doesn't cancel the run or lose progress. This
* component is a thin view over that session — it picks the backup,
* drives the mapping UI, and renders whatever state the session is
* in.
*/
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
import { useAction, useConvex, useMutation, useQuery } from 'convex/react';
import {
ArrowCounterClockwise,
CheckCircle,
FileArrowUp,
Folder,
Stop,
Trash,
Warning,
} from '@phosphor-icons/react';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import { usePlatform } from '../../platform';
import {
openBackup,
type BackupSummary,
} from '../../utils/importRunner';
import { importSession } from '../../utils/importSession';
interface ChannelKeyBundle {
channelId: string;
keyHex: string;
keyVersion: number;
/** Every key version we hold for this channel. Repair mode needs
* older versions to decrypt rows imported under a prior
* rotation. */
allVersions: Map<number, string>;
}
function useChannelKeyBundles(): Map<string, ChannelKeyBundle> {
const platform = usePlatform();
const userId =
typeof localStorage !== 'undefined'
? (localStorage.getItem('userId') as Id<'userProfiles'> | null)
: null;
const privateKeyPem =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('privateKey')
: null;
const allKeys = useQuery(
api.channelKeys.getKeysForUser,
userId ? { userId: userId as any } : 'skip',
);
const [map, setMap] = useState<Map<string, ChannelKeyBundle>>(new Map());
useEffect(() => {
let cancelled = false;
if (!allKeys || !privateKeyPem) {
setMap(new Map());
return;
}
(async () => {
// First pass: decrypt every bundle and collect all
// versions per channel. A single bundle row carries one
// version but may map to multiple channels in its JSON.
const byChannel = new Map<
string,
{ latestVer: number; latestKey: string; versions: Map<number, string> }
>();
for (const item of allKeys as any[]) {
try {
const json = await platform.crypto.privateDecrypt(
privateKeyPem,
item.encrypted_key_bundle,
);
const parsed = JSON.parse(json) as Record<string, string>;
const ver = Number(item.key_version ?? 1);
for (const [chId, keyHex] of Object.entries(parsed)) {
let entry = byChannel.get(chId);
if (!entry) {
entry = {
latestVer: ver,
latestKey: keyHex,
versions: new Map([[ver, keyHex]]),
};
byChannel.set(chId, entry);
} else {
entry.versions.set(ver, keyHex);
if (ver > entry.latestVer) {
entry.latestVer = ver;
entry.latestKey = keyHex;
}
}
}
} catch (err) {
console.error('Failed to decrypt key bundle', err);
}
}
const next = new Map<string, ChannelKeyBundle>();
for (const [chId, entry] of byChannel) {
next.set(chId, {
channelId: chId,
keyHex: entry.latestKey,
keyVersion: entry.latestVer,
allVersions: entry.versions,
});
}
if (!cancelled) setMap(next);
})();
return () => {
cancelled = true;
};
}, [allKeys, privateKeyPem, platform]);
return map;
}
function useImportSession() {
return useSyncExternalStore(
(cb) => importSession.subscribe(cb),
() => importSession.getState(),
() => importSession.getState(),
);
}
export function ImportTab() {
const platform = usePlatform();
const myUserId =
typeof localStorage !== 'undefined'
? (localStorage.getItem('userId') as Id<'userProfiles'> | null)
: null;
const channels = useQuery(api.channels.list, {}) ?? [];
const candidates =
useQuery(
api.importer.listMappingCandidates,
myUserId ? { actorId: myUserId } : 'skip',
) ?? [];
const prepareGhosts = useAction(api.importerActions.prepareGhostsAction);
const importBatch = useAction(api.importerActions.importBatchAction);
const clearChannelImports = useAction(
api.importerActions.clearChannelImportsAction,
);
const deleteByDiscordIdsAction = useAction(
api.importerActions.deleteImportedByDiscordIdsAction,
);
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const validateUpload = useMutation(api.files.validateUpload);
// `resolveReplyTargets` is a query — we call it imperatively from
// the runner to pre-skip already-imported rows and to remap
// reply-parent IDs in one round-trip. `useQuery` is declarative
// only, so we go through the raw Convex client.
const convexClient = useConvex();
const channelKeyBundles = useChannelKeyBundles();
const session = useImportSession();
const supported = !!platform.features?.hasBackupImporter && !!platform.importer;
const [loading, setLoading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [clearingChannel, setClearingChannel] = useState<string | null>(null);
const isRunning =
session.status === 'running' || session.status === 'cancelling';
// Once a backup is loaded and `candidates` arrives, auto-fill the
// author dropdowns where we can match Discord users to real locals
// by discordId or by case-insensitive username/displayName.
useEffect(() => {
const summary: BackupSummary | null = session.parsed?.summary ?? null;
if (!summary || candidates.length === 0) return;
const next = { ...session.authorMap };
let changed = false;
for (const a of summary.authors) {
if (next[a.discordId] !== undefined && next[a.discordId] !== '') continue;
const byDiscordId = candidates.find(
(c: any) => c.discordId === a.discordId,
);
if (byDiscordId) {
next[a.discordId] = byDiscordId._id;
changed = true;
continue;
}
const byName = candidates.find(
(c: any) =>
!c.isGhost &&
(c.username?.toLowerCase() === a.username.toLowerCase() ||
c.displayName?.toLowerCase() ===
(a.displayName ?? '').toLowerCase()),
);
if (byName) {
next[a.discordId] = byName._id;
changed = true;
}
}
if (changed) importSession.setAuthorMap(next);
}, [session.parsed, candidates, session.authorMap]);
const summary: BackupSummary | null = session.parsed?.summary ?? null;
const handlePick = async () => {
if (!platform.importer) return;
setLocalError(null);
setLoading(true);
try {
const result = await platform.importer.pickDatabase();
if (!result.ok || !result.path) {
setLoading(false);
return;
}
const opened = await openBackup(platform, result.path);
importSession.setParsed(opened, result.path);
const nextChannelMap: Record<string, string> = {};
for (const ch of opened.summary.channels)
nextChannelMap[ch.discordId] = '';
importSession.setChannelMap(nextChannelMap);
} catch (err: any) {
setLocalError(err?.message ?? 'Failed to open backup');
} finally {
setLoading(false);
}
};
const selectedChannels = useMemo(() => {
if (!summary) return [];
return summary.channels.filter((c) => session.channelMap[c.discordId]);
}, [summary, session.channelMap]);
const totalMessages = selectedChannels.reduce(
(n, c) => n + c.messageCount,
0,
);
const totalAttachments = selectedChannels.reduce(
(n, c) => n + c.attachmentCount,
0,
);
const ghostsToCreate = summary
? summary.authors.filter((a) => !session.authorMap[a.discordId]).length
: 0;
// Wipe every imported message in a local channel and reset the
// Discord-channel → cursor so a subsequent run re-imports from
// scratch. Used to repair channels polluted by an earlier run
// that left blank bubbles behind. Keyed by the *Discord* channel
// id so the cursor clearance matches the resume key format.
const handleClearChannel = async (
discordChannelId: string,
convexChannelId: string,
) => {
if (!myUserId) return;
const signingKey =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('signingKey')
: null;
if (!signingKey) {
setLocalError('Session signing key missing — please log out and back in.');
return;
}
const channelLabel =
summary?.channels.find((c) => c.discordId === discordChannelId)?.name ??
'this channel';
if (
typeof window !== 'undefined' &&
!window.confirm(
`Delete ALL imported messages from #${channelLabel}? Live (non-imported) messages are kept. This can't be undone.`,
)
) {
return;
}
setClearingChannel(discordChannelId);
setLocalError(null);
try {
const authTimestamp = Date.now();
const canonical = `clearChannelImports:${myUserId}:${convexChannelId}:${authTimestamp}`;
const authSignature = await platform.crypto.signMessage(
signingKey,
canonical,
);
await clearChannelImports({
actorId: myUserId,
channelId: convexChannelId as any,
authTimestamp,
authSignature,
});
// Drop the client-side resume cursor so the next run walks
// the full channel from the oldest row. Matches the
// `RESUME_KEY_PREFIX` in importRunner.ts.
try {
localStorage.removeItem(
'brycord:importer:cursor:' + discordChannelId,
);
} catch {}
} catch (err: any) {
setLocalError(err?.message ?? 'Failed to clear imports.');
} finally {
setClearingChannel(null);
}
};
const handleRun = async (
opts: { resume?: boolean; repair?: boolean } = {},
) => {
const resume = !!opts.resume;
const repair = !!opts.repair;
if (!session.parsed || !summary || !myUserId) return;
const signingKey =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('signingKey')
: null;
if (!signingKey) {
setLocalError('Session signing key missing — please log out and back in.');
return;
}
const keyMap = new Map<
string,
{ keyHex: string; keyVersion: number; allVersions: Map<number, string> }
>();
for (const mapping of selectedChannels) {
const convexId = session.channelMap[mapping.discordId];
const bundle = channelKeyBundles.get(convexId);
if (!bundle) {
setLocalError(
`No key available for channel "${mapping.name}" — you must be a member of the target channel.`,
);
return;
}
keyMap.set(convexId, {
keyHex: bundle.keyHex,
keyVersion: bundle.keyVersion,
allVersions: bundle.allVersions,
});
}
setLocalError(null);
// Fire-and-forget: the session manages the promise. This
// function returns immediately so unmounting the component
// doesn't reject the awaited call.
void importSession.start(
session.parsed,
{
crypto: platform.crypto as any,
platform,
actorId: myUserId,
signingKey,
channelKeys: keyMap,
convex: {
prepareGhosts: prepareGhosts as any,
importBatch: importBatch as any,
generateUploadUrl: generateUploadUrl as any,
validateUpload: validateUpload as any,
resolveReplyTargets: (args: {
channelId: string;
discordMessageIds: string[];
}) =>
convexClient.query(
api.importer.resolveReplyTargets as any,
args as any,
) as Promise<
Array<{ discordMessageId: string; messageId: string }>
>,
getImportedState: (args) =>
convexClient.query(
api.importer.getImportedState as any,
args as any,
) as Promise<
Array<{
discordMessageId: string;
messageId: string;
ciphertext: string;
nonce: string;
keyVersion: number;
}>
>,
deleteImportedByDiscordIds: (args) =>
deleteByDiscordIdsAction(args as any),
},
},
{
channels: selectedChannels.map((c) => ({
discordId: c.discordId,
convexChannelId: session.channelMap[c.discordId] || null,
})),
authors: summary.authors.map((a) => ({
discordId: a.discordId,
convexUserId: session.authorMap[a.discordId] || null,
})),
resume,
repair,
},
);
};
if (!supported) {
return (
<>
<div style={headerStyle}>
<h2 style={headingStyle}>Import Discord Backup</h2>
<p style={descStyle}>
Pull historical messages + attachments from a Discord Backup Bot database into this server.
</p>
</div>
<div style={warnStyle}>
<Warning size={18} weight="bold" />
<div>
Backup import runs on the <strong>desktop app</strong> only
it needs local filesystem access for the SQLite file and the
attachment folder.
</div>
</div>
</>
);
}
const progress = session.progress;
const effectiveError = localError ?? session.error;
return (
<>
<div style={headerStyle}>
<h2 style={headingStyle}>Import Discord Backup</h2>
<p style={descStyle}>
Pulls historical messages + attachments from a Discord Backup Bot
database into channels on this server. Imported messages are
end-to-end encrypted under the current channel key, the same way
live messages are. The importer runs in the background you can
close this modal and come back to it.
</p>
</div>
{effectiveError && (
<div style={errorStyle}>
<Warning size={16} weight="bold" /> {effectiveError}
</div>
)}
{!session.parsed ? (
<div style={cardStyle}>
<Folder size={28} weight="regular" style={{ marginBottom: 8 }} />
<div style={{ fontWeight: 600, marginBottom: 4 }}>
Choose a backup
</div>
<div style={descSmallStyle}>
Point this at the <code>backup.db</code> written by your
Discord Backup Bot. Attachments are read from the sibling{' '}
<code>attachments/</code> folder.
</div>
<button
type="button"
onClick={handlePick}
disabled={loading}
style={{ ...primaryBtnStyle, marginTop: 14 }}
>
<FileArrowUp size={16} weight="bold" />
{loading ? 'Opening…' : 'Select backup.db'}
</button>
</div>
) : (
<>
<div style={cardStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<CheckCircle size={20} weight="bold" color="#3ba55d" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>{session.dbPath}</div>
<div style={descSmallStyle}>
{summary?.channels.length ?? 0} channels ·{' '}
{summary?.authors.length ?? 0} authors ·{' '}
{summary?.channels.reduce(
(n, c) => n + c.messageCount,
0,
) ?? 0}{' '}
messages
</div>
</div>
<button
type="button"
onClick={() => importSession.reset()}
disabled={isRunning}
style={{ ...secondaryBtnStyle }}
>
Change
</button>
</div>
</div>
<h3 style={sectionHeadingStyle}>Link channels</h3>
<p style={descSmallStyle}>
Every Discord channel in the backup can be pointed at a local
channel here, or skipped.
</p>
<div style={{ display: 'grid', gap: 8, marginTop: 10 }}>
{summary?.channels.map((c) => {
const linkedConvexId = session.channelMap[c.discordId];
const isClearing = clearingChannel === c.discordId;
return (
<div key={c.discordId} style={rowStyle}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, color: 'var(--text-primary)' }}>
#{c.name}
{c.isThread && (
<span
style={{
marginLeft: 6,
fontSize: 11,
background: 'var(--background-tertiary)',
padding: '2px 6px',
borderRadius: 4,
color: 'var(--text-secondary)',
}}
>
thread
</span>
)}
</div>
<div style={descSmallStyle}>
{c.messageCount} messages · {c.attachmentCount} files
</div>
</div>
<select
value={linkedConvexId ?? ''}
onChange={(e) =>
importSession.setChannelMap({
...session.channelMap,
[c.discordId]: e.target.value,
})
}
disabled={isRunning || isClearing}
style={{ ...inputStyle, maxWidth: 240 }}
>
<option value="">Skip</option>
{channels.map((ch: any) => (
<option key={ch._id} value={ch._id}>
#{ch.name}
</option>
))}
</select>
{linkedConvexId && (
<button
type="button"
onClick={() =>
handleClearChannel(c.discordId, linkedConvexId)
}
disabled={isRunning || !!clearingChannel}
title="Delete every imported message in the linked channel and reset the cursor — useful to fix a partial earlier run."
style={{
...secondaryBtnStyle,
padding: '8px 10px',
background: 'transparent',
color: 'var(--status-danger, #da373c)',
border: '1px solid currentColor',
}}
>
<Trash size={14} weight="bold" />
{isClearing ? 'Clearing…' : 'Clear'}
</button>
)}
</div>
);
})}
</div>
<h3 style={sectionHeadingStyle}>Link authors</h3>
<p style={descSmallStyle}>
Discord users not linked here become ghost profiles you can
merge into a real local user later from the <strong>Ghosts</strong>{' '}
tab.
</p>
<div style={{ display: 'grid', gap: 6, marginTop: 10 }}>
{summary?.authors.map((a) => (
<div key={a.discordId} style={rowStyle}>
{a.avatarUrl ? (
<img
src={a.avatarUrl}
alt=""
style={{ width: 32, height: 32, borderRadius: '50%' }}
/>
) : (
<div style={avatarPlaceholder}>
{(a.displayName || a.username || '?')
.slice(0, 1)
.toUpperCase()}
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, color: 'var(--text-primary)' }}>
{a.displayName || a.username}
</div>
<div style={descSmallStyle}>
{a.messageCount} messages
</div>
</div>
<select
value={session.authorMap[a.discordId] ?? ''}
onChange={(e) =>
importSession.setAuthorMap({
...session.authorMap,
[a.discordId]: e.target.value,
})
}
disabled={isRunning}
style={{ ...inputStyle, maxWidth: 240 }}
>
<option value="">Create ghost</option>
{(candidates as any[])
.filter((c) => !c.isGhost)
.map((c) => (
<option key={c._id} value={c._id}>
{c.displayName || c.username}
</option>
))}
</select>
</div>
))}
</div>
<div style={{ ...cardStyle, marginTop: 20 }}>
<div style={{ fontWeight: 600, marginBottom: 6 }}>Summary</div>
<div style={descSmallStyle}>
{selectedChannels.length} channels ·{' '}
{totalMessages.toLocaleString()} messages ·{' '}
{totalAttachments.toLocaleString()} attachments · {ghostsToCreate}{' '}
ghosts to create
</div>
<div
style={{
display: 'flex',
gap: 8,
marginTop: 12,
flexWrap: 'wrap',
}}
>
<button
type="button"
onClick={() => handleRun({ resume: false })}
disabled={isRunning || selectedChannels.length === 0}
style={primaryBtnStyle}
>
<FileArrowUp size={16} weight="bold" /> Start import
</button>
<button
type="button"
onClick={() => handleRun({ resume: true })}
disabled={isRunning || selectedChannels.length === 0}
style={secondaryBtnStyle}
>
<ArrowCounterClockwise size={16} weight="bold" /> Resume
</button>
<button
type="button"
onClick={() => handleRun({ repair: true })}
disabled={isRunning || selectedChannels.length === 0}
title="Surgical fix — walks the backup and only re-imports rows whose attachments are currently missing. Complete messages are left alone."
style={secondaryBtnStyle}
>
<ArrowCounterClockwise size={16} weight="bold" /> Repair
missing attachments
</button>
{isRunning && (
<button
type="button"
onClick={() => importSession.cancel()}
style={dangerBtnStyle}
>
<Stop size={16} weight="bold" />{' '}
{session.status === 'cancelling'
? 'Cancelling…'
: 'Cancel'}
</button>
)}
</div>
</div>
{progress && (
<div style={{ ...cardStyle, marginTop: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{session.status === 'done'
? 'Finished'
: session.status === 'cancelling'
? 'Cancelling…'
: progress.stage === 'preparing'
? 'Preparing…'
: `Importing #${progress.channelName ?? ''}`}
</div>
<div style={descSmallStyle}>
{progress.channelProgress
? `${progress.channelProgress.inserted.toLocaleString()} / ${progress.channelProgress.total.toLocaleString()} messages`
: progress.message ?? ''}
</div>
<div style={descSmallStyle}>
Uploaded {progress.attachmentsUploaded.toLocaleString()}{' '}
files (
{Math.round(
progress.attachmentBytesUploaded / 1024 / 1024,
).toLocaleString()}{' '}
MB)
</div>
</div>
)}
</>
)}
</>
);
}
/* ------------------------------------------------------------------ */
/* Styles — re-declared locally so this file stays self-contained. */
/* ------------------------------------------------------------------ */
const headerStyle: React.CSSProperties = { marginBottom: 16 };
const headingStyle: React.CSSProperties = {
fontSize: 20,
fontWeight: 700,
color: 'var(--text-primary)',
margin: 0,
marginBottom: 6,
};
const descStyle: React.CSSProperties = {
fontSize: 14,
color: 'var(--text-secondary)',
margin: 0,
};
const descSmallStyle: React.CSSProperties = {
fontSize: 12,
color: 'var(--text-secondary)',
};
const sectionHeadingStyle: React.CSSProperties = {
fontSize: 14,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 0.5,
color: 'var(--text-secondary)',
marginTop: 24,
marginBottom: 6,
};
const cardStyle: React.CSSProperties = {
padding: 16,
borderRadius: 8,
background: 'var(--background-secondary)',
border: '1px solid var(--background-tertiary)',
marginBottom: 16,
};
const rowStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 12px',
borderRadius: 6,
background: 'var(--background-secondary)',
border: '1px solid var(--background-tertiary)',
};
const avatarPlaceholder: React.CSSProperties = {
width: 32,
height: 32,
borderRadius: '50%',
background: 'var(--background-tertiary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-primary)',
fontWeight: 700,
fontSize: 13,
};
const inputStyle: React.CSSProperties = {
padding: '8px 10px',
background: 'var(--background-tertiary)',
border: '1px solid var(--background-modifier-accent)',
borderRadius: 6,
color: 'var(--text-primary)',
fontSize: 13,
fontFamily: 'inherit',
outline: 'none',
};
const primaryBtnStyle: React.CSSProperties = {
background: 'var(--brand-primary)',
color: '#fff',
border: 'none',
padding: '10px 18px',
borderRadius: 6,
cursor: 'pointer',
fontWeight: 600,
fontSize: 14,
display: 'inline-flex',
alignItems: 'center',
gap: 6,
};
const secondaryBtnStyle: React.CSSProperties = {
...primaryBtnStyle,
background: 'var(--background-tertiary)',
color: 'var(--text-primary)',
};
const dangerBtnStyle: React.CSSProperties = {
...primaryBtnStyle,
background: 'var(--status-danger, #da373c)',
};
const warnStyle: React.CSSProperties = {
display: 'flex',
gap: 10,
alignItems: 'flex-start',
padding: 14,
borderRadius: 8,
background: 'rgba(250, 166, 26, 0.1)',
color: 'var(--status-warning, #faa61a)',
border: '1px solid rgba(250, 166, 26, 0.3)',
fontSize: 13,
};
const errorStyle: React.CSSProperties = {
display: 'flex',
gap: 8,
alignItems: 'center',
padding: 10,
marginBottom: 12,
borderRadius: 6,
background: 'rgba(248, 113, 113, 0.12)',
color: '#f87171',
fontSize: 13,
};

View File

@@ -17,7 +17,9 @@ import {
CaretLeft,
CaretRight,
ClockCounterClockwise,
FileArrowUp,
Gear,
Ghost,
Prohibit,
ShieldStar,
Smiley,
@@ -31,6 +33,8 @@ import {
OverviewTab,
type ServerSettingsTab,
} from './ServerSettingsModal';
import { GhostsTab } from './GhostsTab';
import { ImportTab } from './ImportTab';
import { useRolesView } from './RolesView';
import rolesStyles from './RolesView.module.css';
import styles from './MobileServerSettings.module.css';
@@ -54,6 +58,8 @@ const TABS: Array<{
{ id: 'emojis', label: 'Custom Emoji', icon: Smiley },
{ id: 'bans', label: 'Bans', icon: Prohibit },
{ id: 'audit', label: 'Audit Log', icon: ClockCounterClockwise },
{ id: 'import', label: 'Import', icon: FileArrowUp },
{ id: 'ghosts', label: 'Ghosts', icon: Ghost },
];
function getInitials(name: string): string {
@@ -235,6 +241,8 @@ export function MobileServerSettings({
{activeTab === 'emojis' && <EmojisTab />}
{activeTab === 'bans' && <BansTab />}
{activeTab === 'audit' && <AuditLogTab />}
{activeTab === 'import' && <ImportTab />}
{activeTab === 'ghosts' && <GhostsTab />}
</div>
)}
</div>,

View File

@@ -11,7 +11,9 @@
import { useMutation, useQuery } from 'convex/react';
import {
ClockCounterClockwise,
FileArrowUp,
Gear,
Ghost,
Plus,
Prohibit,
ShieldStar,
@@ -26,11 +28,20 @@ import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import { useIsMobile } from '../../hooks/useIsMobile';
import { CustomEmojisTab } from './CustomEmojisTab';
import { GhostsTab } from './GhostsTab';
import { ImportTab } from './ImportTab';
import { MobileServerSettings } from './MobileServerSettings';
import { useRolesView } from './RolesView';
import userStyles from './UserSettingsModal.module.css';
export type ServerSettingsTab = 'overview' | 'roles' | 'emojis' | 'bans' | 'audit';
export type ServerSettingsTab =
| 'overview'
| 'roles'
| 'emojis'
| 'bans'
| 'audit'
| 'import'
| 'ghosts';
interface ServerSettingsModalProps {
isOpen: boolean;
@@ -44,6 +55,8 @@ const TABS: Array<{ id: ServerSettingsTab; label: string; icon: typeof Gear }> =
{ id: 'emojis', label: 'Custom Emoji', icon: Smiley },
{ id: 'bans', label: 'Bans', icon: Prohibit },
{ id: 'audit', label: 'Audit Log', icon: ClockCounterClockwise },
{ id: 'import', label: 'Import', icon: FileArrowUp },
{ id: 'ghosts', label: 'Ghosts', icon: Ghost },
];
export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSettingsModalProps) {
@@ -164,6 +177,8 @@ export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSetti
{activeTab === 'emojis' && <CustomEmojisTab />}
{activeTab === 'bans' && <BansTab />}
{activeTab === 'audit' && <AuditLogTab />}
{activeTab === 'import' && <ImportTab />}
{activeTab === 'ghosts' && <GhostsTab />}
</div>
</div>
</div>
@@ -338,10 +353,332 @@ export function OverviewTab() {
</span>
)}
</div>
<DangerZone />
</>
);
}
/* ------------------------------------------------------------------- */
/* Danger Zone — Owner only */
/* ------------------------------------------------------------------- */
function DangerZone() {
const myUserId =
typeof localStorage !== 'undefined'
? (localStorage.getItem('userId') as Id<'userProfiles'> | null)
: null;
const isOwner = useQuery(
api.roles.isOwner,
myUserId ? { userId: myUserId } : 'skip',
);
const purgeAll = useMutation(api.messages.purgeAllMessages);
const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmText, setConfirmText] = useState('');
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState<{
deletedMessages: number;
deletedPolls: number;
remaining: number | null;
} | null>(null);
const [error, setError] = useState<string | null>(null);
if (!isOwner) return null;
const handlePurge = async () => {
if (!myUserId || busy) return;
setBusy(true);
setError(null);
setProgress({ deletedMessages: 0, deletedPolls: 0, remaining: null });
try {
// Loop the mutation until `remaining` is zero — the backend
// caps per-call work so a single wipe on a busy server
// doesn't blow the Convex write budget. For a small server
// this usually completes in one call.
let totalMessages = 0;
let totalPolls = 0;
let remaining = 1;
let guard = 0;
while (remaining > 0 && guard++ < 50) {
const result = await purgeAll({ actorId: myUserId });
totalMessages += result.deletedMessages;
totalPolls += result.deletedPolls;
remaining = result.remaining;
setProgress({
deletedMessages: totalMessages,
deletedPolls: totalPolls,
remaining,
});
if (
result.deletedMessages === 0 &&
result.deletedPolls === 0
) {
break;
}
}
setConfirmOpen(false);
setConfirmText('');
} catch (err: any) {
setError(err?.message ?? 'Failed to clear messages.');
} finally {
setBusy(false);
}
};
return (
<div
style={{
marginTop: 40,
padding: 16,
border: '1px solid var(--status-danger, #da373c)',
borderRadius: 8,
}}
>
<h3
style={{
color: 'var(--status-danger, #da373c)',
fontSize: 16,
fontWeight: 700,
margin: '0 0 4px',
}}
>
Danger Zone
</h3>
<p
style={{
color: 'var(--text-secondary)',
fontSize: 13,
margin: '0 0 16px',
}}
>
These actions are permanent and affect every member of the server.
Only the Owner can see and run them.
</p>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
}}
>
<div style={{ minWidth: 0 }}>
<div
style={{ color: 'var(--text-primary)', fontWeight: 600, fontSize: 14 }}
>
Clear all messages
</div>
<div
style={{
color: 'var(--text-secondary)',
fontSize: 12,
marginTop: 2,
}}
>
Deletes every message and reaction across every channel from
every member. Channels, roles, and settings are left alone.
</div>
</div>
<button
type="button"
onClick={() => {
setConfirmOpen(true);
setError(null);
setProgress(null);
setConfirmText('');
}}
style={dangerBtnStyle}
>
Clear messages
</button>
</div>
{confirmOpen && (
<ConfirmPurgeModal
busy={busy}
confirmText={confirmText}
onConfirmTextChange={setConfirmText}
onCancel={() => {
if (busy) return;
setConfirmOpen(false);
setConfirmText('');
setError(null);
}}
onConfirm={handlePurge}
error={error}
progress={progress}
/>
)}
</div>
);
}
function ConfirmPurgeModal({
busy,
confirmText,
onConfirmTextChange,
onCancel,
onConfirm,
error,
progress,
}: {
busy: boolean;
confirmText: string;
onConfirmTextChange: (v: string) => void;
onCancel: () => void;
onConfirm: () => void;
error: string | null;
progress: {
deletedMessages: number;
deletedPolls: number;
remaining: number | null;
} | null;
}) {
const canConfirm = confirmText.trim().toUpperCase() === 'DELETE' && !busy;
return createPortal(
<div
onClick={onCancel}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 30000,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: 'var(--background-primary)',
padding: 24,
borderRadius: 10,
width: 'calc(100% - 32px)',
maxWidth: 440,
border: '1px solid var(--status-danger, #da373c)',
}}
>
<h2
style={{
margin: 0,
marginBottom: 8,
color: 'var(--text-primary)',
fontSize: 18,
}}
>
Clear all messages?
</h2>
<p
style={{
margin: 0,
marginBottom: 16,
color: 'var(--text-secondary)',
fontSize: 13,
lineHeight: 1.5,
}}
>
This will permanently delete every message and reaction across the
entire server. Channels and roles remain. This cannot be undone.
</p>
<label
style={{
display: 'block',
fontSize: 12,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: 0.4,
color: 'var(--text-tertiary)',
marginBottom: 6,
}}
>
Type <strong>DELETE</strong> to confirm
</label>
<input
type="text"
value={confirmText}
onChange={(e) => onConfirmTextChange(e.target.value)}
autoFocus
disabled={busy}
style={inputStyle}
placeholder="DELETE"
spellCheck={false}
/>
{progress && (
<div
style={{
marginTop: 12,
color: 'var(--text-secondary)',
fontSize: 12,
}}
>
Deleted {progress.deletedMessages} messages
{progress.deletedPolls > 0
? `, ${progress.deletedPolls} polls`
: ''}
{progress.remaining !== null && progress.remaining > 0
? ` · more remaining…`
: ''}
</div>
)}
{error && (
<div
style={{
marginTop: 12,
padding: 8,
borderRadius: 6,
background: 'rgba(248, 113, 113, 0.12)',
color: '#f87171',
fontSize: 13,
}}
>
{error}
</div>
)}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
marginTop: 20,
}}
>
<button
type="button"
onClick={onCancel}
disabled={busy}
style={{
...primaryBtnStyle,
background: 'var(--background-tertiary)',
color: 'var(--text-primary)',
}}
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={!canConfirm}
style={{
...dangerBtnStyle,
opacity: canConfirm ? 1 : 0.5,
cursor: canConfirm ? 'pointer' : 'not-allowed',
}}
>
{busy ? 'Clearing…' : 'Clear all messages'}
</button>
</div>
</div>
</div>,
document.body,
);
}
/* ------------------------------------------------------------------- */
/* Roles */
/* ------------------------------------------------------------------- */
@@ -1165,6 +1502,7 @@ const AUDIT_LABELS: Record<string, string> = {
'server.settings_update': 'updated server settings',
'ban.add': 'banned',
'ban.remove': 'unbanned',
'messages.purge_all': 'cleared all messages',
};
export function AuditLogTab() {

View File

@@ -1052,10 +1052,144 @@ export function AppearanceTab() {
})}
</div>
<LaunchSection />
</>
);
}
/**
* Launch + tray settings — Electron-only. Hidden on web / Android
* via `platform.features.hasLifecycle`. Three toggles:
* - Launch at Startup — registers the app with the OS login items.
* - Start Minimized — only meaningful when Launch at Startup is on;
* asks the OS to open the app hidden so the user can bring it up
* from the tray when they need it.
* - Minimize to Tray on Close — flips the close button from "quit"
* to "hide", with the tray's Quit menu as the explicit exit path.
*/
function LaunchSection() {
const platform = usePlatform() as any;
const lifecycle = platform?.lifecycle ?? null;
const [state, setState] = useState<{
launchAtStartup: boolean;
startMinimized: boolean;
minimizeToTrayOnClose: boolean;
} | null>(null);
useEffect(() => {
if (!platform?.features?.hasLifecycle || !lifecycle?.get) return;
let cancelled = false;
(async () => {
try {
const current = await lifecycle.get();
if (!cancelled && current) setState(current);
} catch {}
})();
return () => {
cancelled = true;
};
}, [platform, lifecycle]);
if (!platform?.features?.hasLifecycle || !lifecycle?.set) return null;
if (!state) return null;
const update = async (patch: Partial<typeof state>) => {
try {
const next = await lifecycle.set(patch);
if (next) setState(next);
} catch (err) {
console.warn('lifecycle.set failed', err);
}
};
return (
<div style={{ marginTop: 32 }}>
<h3 className={styles.profileSubheading} style={{ fontSize: 16 }}>
Launch &amp; Tray
</h3>
<p className={styles.profileDescription}>
Desktop-only options that control how the app starts and what
happens when you press the close button.
</p>
<label
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 0',
borderBottom: '1px solid var(--background-modifier-accent)',
}}
>
<div>
<div style={{ color: 'var(--text-primary)', fontWeight: 600, fontSize: 14 }}>
Launch at Startup
</div>
<div style={{ color: 'var(--text-secondary)', fontSize: 12, marginTop: 2 }}>
Open Brycord automatically when you sign in to your computer.
</div>
</div>
<input
type="checkbox"
checked={state.launchAtStartup}
onChange={(e) => update({ launchAtStartup: e.target.checked })}
/>
</label>
<label
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 0',
borderBottom: '1px solid var(--background-modifier-accent)',
opacity: state.launchAtStartup ? 1 : 0.5,
}}
>
<div>
<div style={{ color: 'var(--text-primary)', fontWeight: 600, fontSize: 14 }}>
Start Minimized
</div>
<div style={{ color: 'var(--text-secondary)', fontSize: 12, marginTop: 2 }}>
When launching at startup, hide the window until you open it
from the tray.
</div>
</div>
<input
type="checkbox"
checked={state.startMinimized}
onChange={(e) => update({ startMinimized: e.target.checked })}
disabled={!state.launchAtStartup}
/>
</label>
<label
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 0',
}}
>
<div>
<div style={{ color: 'var(--text-primary)', fontWeight: 600, fontSize: 14 }}>
Minimize to Tray on Close
</div>
<div style={{ color: 'var(--text-secondary)', fontSize: 12, marginTop: 2 }}>
Hide the window instead of quitting when you close it. Use the
tray icon's Quit menu to exit fully.
</div>
</div>
<input
type="checkbox"
checked={state.minimizeToTrayOnClose}
onChange={(e) => update({ minimizeToTrayOnClose: e.target.checked })}
/>
</label>
</div>
);
}
interface VoiceSettings {
inputDeviceId: string;
outputDeviceId: string;

View File

@@ -61,6 +61,14 @@
* @property {() => Promise<'granted'|'denied'|'default'|'unavailable'>} ensurePermission - Request permission if needed; resolves the current state
*/
/**
* @typedef {Object} PlatformLifecycle
* @property {() => Promise<{launchAtStartup: boolean, startMinimized: boolean, minimizeToTrayOnClose: boolean}>} get
* @property {(patch: {launchAtStartup?: boolean, startMinimized?: boolean, minimizeToTrayOnClose?: boolean}) => Promise<{launchAtStartup: boolean, startMinimized: boolean, minimizeToTrayOnClose: boolean}>} set
* @property {() => void} show - Force-show the window (e.g. in response to a notification click)
* @property {(cb: (action: 'toggle-mute'|'toggle-deafen'|string) => void) => (() => void)} onTrayAction - Subscribe to tray menu actions
*/
/**
* @typedef {Object} PlatformRecording
* @property {() => Promise<string>} getDefaultFolder - Default recording root (e.g. %APPDATA%/Brycord/recordings)
@@ -109,6 +117,13 @@
* @property {(opts: {statusBarColor: string, navigationBarColor: string, isDarkContent: boolean}) => Promise<void>} setColors
*/
/**
* @typedef {Object} PlatformImporter
* @property {() => Promise<{ok: boolean, path?: string|null, error?: string}>} pickDatabase - Opens a native file picker for the backup SQLite file. Returns the absolute path.
* @property {(dbPath: string) => Promise<{ok: boolean, bytes?: ArrayBuffer, dataDir?: string, error?: string}>} readDatabase - Reads the SQLite file as raw bytes plus the parent dir that holds the `attachments/` tree. sql.js in the renderer parses `bytes`.
* @property {(payload: {dataDir: string, localPath: string}) => Promise<{ok: boolean, bytes?: ArrayBuffer, error?: string}>} readAttachment - Read one attachment file. `localPath` is scoped inside `dataDir` — callers above are a no-op.
*/
/**
* @typedef {Object} PlatformFeatures
* @property {boolean} hasWindowControls
@@ -119,6 +134,8 @@
* @property {boolean} hasSystemBars
* @property {boolean} [hasBackButton]
* @property {boolean} [hasNotifications]
* @property {boolean} [hasLifecycle]
* @property {boolean} [hasBackupImporter]
*/
/**
@@ -131,11 +148,13 @@
* @property {PlatformScreenCapture|null} screenCapture
* @property {PlatformWindowControls|null} windowControls
* @property {PlatformNotifications|null} notifications
* @property {PlatformLifecycle|null} lifecycle
* @property {PlatformRecording|null} recording
* @property {PlatformUpdates|null} updates
* @property {PlatformSearchDB|null} searchDB
* @property {PlatformVoiceService|null} voiceService
* @property {PlatformSystemBars|null} systemBars
* @property {PlatformImporter|null} importer
* @property {PlatformFeatures} features
*/

View File

@@ -0,0 +1,911 @@
/**
* Discord backup importer — client pipeline.
*
* Reads a SQLite backup written by the Discord Backup Bot, encrypts
* each message under the target channel's current key, re-uploads
* every attachment through the existing Convex storage flow, and
* submits signed batches via `importerActions.importBatchAction`.
*
* The full flow lives in the renderer so the admin's Ed25519 signing
* key stays local. The Electron main process only exposes filesystem
* helpers (`platform.importer.*`) — DB parsing uses `sql.js`, same
* WASM bundle the search cache already depends on.
*/
// @ts-ignore — sql.js ships no type declarations
import initSqlJsModule from 'sql.js';
// @ts-ignore — ?url is a Vite suffix
import wasmUrl from 'sql.js/dist/sql-wasm.wasm?url';
import type { AttachmentMetadata } from '../components/channel/EncryptedAttachment';
// sql.js types aren't bundled; use `any` for the static factory +
// Database handles. All call sites are local to this file so the
// loose typing doesn't leak.
type SqlJsStatic = any;
type Database = any;
const initSqlJs = (initSqlJsModule as any).default ?? initSqlJsModule;
let sqlPromise: Promise<SqlJsStatic> | null = null;
function getSql(): Promise<SqlJsStatic> {
if (!sqlPromise) {
sqlPromise = initSqlJs({ locateFile: () => wasmUrl });
}
return sqlPromise as Promise<SqlJsStatic>;
}
export interface BackupChannel {
discordId: string;
name: string;
isThread: boolean;
parentChannelId: string | null;
messageCount: number;
authorIds: string[];
attachmentCount: number;
}
export interface BackupAuthor {
discordId: string;
username: string;
displayName: string | null;
avatarUrl: string | null;
messageCount: number;
}
export interface BackupSummary {
channels: BackupChannel[];
authors: BackupAuthor[];
}
export interface ParsedBackup {
db: Database;
dataDir: string;
summary: BackupSummary;
}
export async function openBackup(
platform: any,
dbPath: string,
): Promise<ParsedBackup> {
const read = await platform.importer.readDatabase(dbPath);
if (!read.ok || !read.bytes || !read.dataDir) {
throw new Error(read.error ?? 'Failed to read backup database');
}
const SQL = await getSql();
const db = new SQL.Database(new Uint8Array(read.bytes as ArrayBuffer));
const summary = summarizeBackup(db);
return { db, dataDir: read.dataDir, summary };
}
function rowsToObjects<T = any>(db: Database, sql: string, params: any[] = []): T[] {
const stmt = db.prepare(sql);
try {
stmt.bind(params);
const rows: T[] = [];
while (stmt.step()) rows.push(stmt.getAsObject() as any as T);
return rows;
} finally {
stmt.free();
}
}
export function summarizeBackup(db: Database): BackupSummary {
const channels = rowsToObjects<any>(
db,
`SELECT c.id, c.name, c.is_thread, c.parent_channel_id,
(SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.id) AS msg_count,
(SELECT COUNT(*) FROM attachments a
JOIN messages m ON a.message_id = m.id
WHERE m.channel_id = c.id) AS att_count
FROM channels c
ORDER BY c.is_thread ASC, c.name ASC`,
);
const authorsByChannel = new Map<string, Set<string>>();
for (const row of rowsToObjects<any>(
db,
`SELECT DISTINCT channel_id, author_id FROM messages WHERE author_id IS NOT NULL`,
)) {
if (!authorsByChannel.has(row.channel_id)) {
authorsByChannel.set(row.channel_id, new Set());
}
authorsByChannel.get(row.channel_id)!.add(row.author_id);
}
const authors = rowsToObjects<any>(
db,
`SELECT a.id, a.username, a.display_name, a.avatar_url,
(SELECT COUNT(*) FROM messages m WHERE m.author_id = a.id) AS msg_count
FROM authors a
ORDER BY msg_count DESC`,
);
return {
channels: channels.map((c) => ({
discordId: String(c.id),
name: String(c.name ?? 'unknown'),
isThread: Number(c.is_thread) === 1,
parentChannelId: c.parent_channel_id ? String(c.parent_channel_id) : null,
messageCount: Number(c.msg_count ?? 0),
attachmentCount: Number(c.att_count ?? 0),
authorIds: Array.from(authorsByChannel.get(String(c.id)) ?? []),
})),
authors: authors.map((a) => ({
discordId: String(a.id),
username: String(a.username ?? ''),
displayName: a.display_name ? String(a.display_name) : null,
avatarUrl: a.avatar_url ? String(a.avatar_url) : null,
messageCount: Number(a.msg_count ?? 0),
})),
};
}
interface BackupMessageRow {
id: string;
channel_id: string;
author_id: string | null;
content: string | null;
created_at: number;
replied_to_id: string | null;
}
interface BackupAttachmentRow {
id: string;
message_id: string;
filename: string;
local_path: string | null;
size: number | null;
content_type: string | null;
downloaded: number;
}
export interface ChannelMapping {
/** Discord source channel (from backup.db) */
discordId: string;
/** Target Convex channel._id, or null to skip */
convexChannelId: string | null;
}
export interface AuthorMapping {
/** Discord source author */
discordId: string;
/** Existing Convex userProfiles._id, or null to auto-create a ghost */
convexUserId: string | null;
}
export interface ImportCrypto {
encryptData: (
data: string | Uint8Array,
key: string | Uint8Array,
) => Promise<{ content: string; iv: string; tag: string }>;
decryptData: (
encryptedData: string,
key: string | Uint8Array,
iv: string,
tag: string,
options?: any,
) => Promise<string>;
signMessage: (privateKey: string, message: string) => Promise<string>;
randomBytes: (size: number) => Promise<string>;
}
export interface ImportChannelKey {
keyHex: string;
keyVersion: number;
/** Every key version the importer has access to for this channel.
* Used in repair mode to decrypt rows that were encrypted under
* an older key version than the current one. */
allVersions: Map<number, string>;
}
export interface ImportDeps {
crypto: ImportCrypto;
platform: any;
actorId: string;
signingKey: string;
/** Per-target-channel: the channel key hex + current keyVersion */
channelKeys: Map<string, ImportChannelKey>;
convex: {
prepareGhosts: (args: any) => Promise<
Array<{ discordId: string; userId: string; created: boolean; isGhost: boolean }>
>;
importBatch: (args: any) => Promise<{
inserted: number;
skipped: number;
resolved: Array<{ discordMessageId: string; messageId: string }>;
}>;
generateUploadUrl: () => Promise<string>;
validateUpload: (args: { storageId: string }) => Promise<string>;
/**
* Given a channel and a set of Discord message IDs, return
* the ones that already exist server-side (with their Convex
* IDs). The runner uses this for two things in one round-trip:
* 1. Skip already-imported rows on re-run — no attachment
* re-upload, no redundant encryption, no wasted batch
* call. Saves the vast majority of the re-import cost.
* 2. Resolve reply-parent IDs for Discord messages the
* current batch points at but didn't include itself
* (e.g. reply-to an older message from a prior batch).
*/
resolveReplyTargets: (args: {
channelId: string;
discordMessageIds: string[];
}) => Promise<Array<{ discordMessageId: string; messageId: string }>>;
/**
* Repair-mode companion to `resolveReplyTargets` — returns the
* full decryptable body (ciphertext + nonce + keyVersion) so the
* runner can decrypt locally and detect rows whose attachment
* array is missing entries. Only called in repair mode; normal
* imports never fetch this.
*/
getImportedState?: (args: {
channelId: string;
discordMessageIds: string[];
}) => Promise<
Array<{
discordMessageId: string;
messageId: string;
ciphertext: string;
nonce: string;
keyVersion: number;
}>
>;
/**
* Surgical delete by Discord snowflake list. Used by repair
* mode to drop broken rows immediately before re-inserting
* them through the normal batch path. Expected to be paged at
* <=100 ids per call by the caller.
*/
deleteImportedByDiscordIds?: (args: {
channelId: string;
discordMessageIds: string[];
}) => Promise<{ deleted: number }>;
};
}
export interface ImportProgress {
stage: 'preparing' | 'importing' | 'done';
channelDiscordId: string | null;
channelName: string | null;
channelProgress: { inserted: number; total: number } | null;
attachmentsUploaded: number;
attachmentBytesUploaded: number;
message?: string;
}
export interface ImportOptions {
channels: ChannelMapping[];
authors: AuthorMapping[];
onProgress?: (p: ImportProgress) => void;
/** Set to true externally to request graceful stop after the current batch. */
cancelSignal?: { cancelled: boolean };
/** Starts each channel from its saved cursor if true. */
resume?: boolean;
/**
* Repair-only pass: walk the backup from the start, but for each
* row that (a) already exists server-side and (b) had attachments
* in the backup, decrypt the server copy and compare counts. If
* the server is missing attachments, delete + re-insert. Rows
* that don't exist yet OR already have all their attachments are
* left alone. No cursor is used — repair is always idempotent.
*/
repair?: boolean;
}
// Batch / concurrency knobs. MESSAGE_BATCH matches the server-side
// `MAX_IMPORT_BATCH` cap, ATTACHMENT_CONCURRENCY and PREP_CONCURRENCY
// are empirically-tuned ceilings that balance throughput against
// single-TCP-connection saturation + Convex's per-client rate limit.
const MESSAGE_BATCH = 100;
const ATTACHMENT_CONCURRENCY = 8;
const PREP_CONCURRENCY = 8;
const RESUME_KEY_PREFIX = 'brycord:importer:cursor:';
/**
* Bounded-parallel `map`: runs up to `limit` promises in flight at
* once, preserving input order in the result. Used for attachment
* uploads and per-row message prep so we don't serialize an entire
* batch through a single TCP connection when the server can handle
* real concurrency.
*/
async function mapConcurrent<T, U>(
items: T[],
limit: number,
fn: (item: T, index: number) => Promise<U>,
): Promise<U[]> {
const results: U[] = new Array(items.length);
let cursor = 0;
const workers: Promise<void>[] = [];
const n = Math.min(limit, items.length);
for (let w = 0; w < n; w++) {
workers.push(
(async () => {
for (;;) {
const idx = cursor++;
if (idx >= items.length) return;
results[idx] = await fn(items[idx], idx);
}
})(),
);
}
await Promise.all(workers);
return results;
}
function fromHexString(hex: string): Uint8Array {
const matches = hex.match(/.{1,2}/g) ?? [];
return new Uint8Array(matches.map((b) => parseInt(b, 16)));
}
function loadCursor(channelDiscordId: string): string | null {
try {
return localStorage.getItem(RESUME_KEY_PREFIX + channelDiscordId);
} catch {
return null;
}
}
function saveCursor(channelDiscordId: string, discordMessageId: string): void {
try {
localStorage.setItem(RESUME_KEY_PREFIX + channelDiscordId, discordMessageId);
} catch {}
}
function clearCursor(channelDiscordId: string): void {
try {
localStorage.removeItem(RESUME_KEY_PREFIX + channelDiscordId);
} catch {}
}
/**
* Upload one attachment through the standard encrypt-then-upload
* pipeline and return the `AttachmentMetadata` JSON that would go
* into a normal message's plaintext. Same shape the live send path
* produces, so `EncryptedAttachment` renders imported files exactly
* like live ones.
*
* Returns `null` ONLY for the known-unrecoverable case: the backup
* bot never captured the file (no local_path / downloaded=0). Every
* other failure (disk read, network, Convex upload, validate)
* throws. The caller wraps this with retry + abort-the-batch
* semantics, so a transient failure never leaves a blank row —
* resume will re-enter this row from scratch.
*/
async function uploadOneAttachmentOnce(
deps: ImportDeps,
dataDir: string,
att: BackupAttachmentRow,
): Promise<AttachmentMetadata | null> {
if (!att.local_path || !att.downloaded) return null;
const read = await deps.platform.importer.readAttachment({
dataDir,
localPath: att.local_path,
});
if (!read.ok || !read.bytes) {
throw new Error(
`Can't read ${att.filename} at ${att.local_path}: ${read.error ?? 'no data'}`,
);
}
const fileKey = await deps.crypto.randomBytes(32);
const buf = new Uint8Array(read.bytes as ArrayBuffer);
const encrypted = await deps.crypto.encryptData(buf, fileKey);
const encryptedHex = encrypted.content + encrypted.tag;
const encryptedBytes = fromHexString(encryptedHex);
const blob = new Blob([encryptedBytes as BlobPart], { type: 'application/octet-stream' });
const uploadUrl = await deps.convex.generateUploadUrl();
const res = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': blob.type },
body: blob,
});
if (!res.ok) throw new Error(`Upload failed for ${att.filename}: ${res.status}`);
const { storageId } = (await res.json()) as { storageId: string };
const fileUrl = await deps.convex.validateUpload({ storageId });
if (!fileUrl) throw new Error(`Failed to resolve file URL for ${att.filename}`);
return {
type: 'attachment',
url: fileUrl,
filename: att.filename,
mimeType: att.content_type || 'application/octet-stream',
size: Number(att.size ?? buf.byteLength),
key: fileKey,
iv: encrypted.iv,
};
}
/**
* Retrying wrapper around `uploadOneAttachmentOnce`. Three attempts
* with linear backoff; still returns `null` for the known-missing
* case without consuming retries. Only throws if every attempt
* fails — aborting the enclosing batch so resume redoes this row
* instead of committing a message with missing files.
*/
async function uploadOneAttachment(
deps: ImportDeps,
dataDir: string,
att: BackupAttachmentRow,
maxAttempts = 3,
): Promise<AttachmentMetadata | null> {
let lastErr: any;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await uploadOneAttachmentOnce(deps, dataDir, att);
} catch (err) {
lastErr = err;
if (attempt < maxAttempts) {
await new Promise((r) => setTimeout(r, 500 * attempt));
}
}
}
throw new Error(
`Failed to upload ${att.filename} after ${maxAttempts} attempts: ${lastErr?.message ?? lastErr}`,
);
}
/**
* Ghost-prep pass. For every Discord author that hasn't been
* manually mapped to a real user, call `prepareGhosts` once to
* upsert a placeholder profile. Returns a full `discordId →
* convexUserId` lookup table the batch phase uses.
*/
async function resolveAuthorMap(
deps: ImportDeps,
authors: AuthorMapping[],
backupAuthors: BackupAuthor[],
): Promise<Map<string, string>> {
const map = new Map<string, string>();
const needGhost: AuthorMapping[] = [];
for (const a of authors) {
if (a.convexUserId) {
map.set(a.discordId, a.convexUserId);
} else {
needGhost.push(a);
}
}
if (needGhost.length === 0) return map;
const authTimestamp = Date.now();
const canonical = `prepareGhosts:${deps.actorId}:${needGhost.length}:${authTimestamp}`;
const authSignature = await deps.crypto.signMessage(deps.signingKey, canonical);
const byDiscordId = new Map(backupAuthors.map((a) => [a.discordId, a]));
const payload = needGhost.map((g) => {
const src = byDiscordId.get(g.discordId);
return {
discordId: g.discordId,
username: src?.username ?? g.discordId,
displayName: src?.displayName ?? undefined,
avatarUrl: src?.avatarUrl ?? undefined,
};
});
const result = await deps.convex.prepareGhosts({
actorId: deps.actorId,
authors: payload,
authTimestamp,
authSignature,
});
for (const r of result) {
map.set(r.discordId, r.userId);
}
return map;
}
export async function runImport(
parsed: ParsedBackup,
deps: ImportDeps,
options: ImportOptions,
): Promise<void> {
const { db, dataDir, summary } = parsed;
const onProgress = options.onProgress ?? (() => {});
const cancelSignal = options.cancelSignal ?? { cancelled: false };
onProgress({
stage: 'preparing',
channelDiscordId: null,
channelName: null,
channelProgress: null,
attachmentsUploaded: 0,
attachmentBytesUploaded: 0,
message: 'Preparing author mappings…',
});
const authorMap = await resolveAuthorMap(deps, options.authors, summary.authors);
let attachmentsUploaded = 0;
let attachmentBytesUploaded = 0;
for (const mapping of options.channels) {
if (cancelSignal.cancelled) break;
if (!mapping.convexChannelId) continue;
const channel = summary.channels.find((c) => c.discordId === mapping.discordId);
if (!channel) continue;
const channelKey = deps.channelKeys.get(mapping.convexChannelId);
if (!channelKey) {
throw new Error(
`No channel key for ${channel.name} — make sure you're a member of the target channel.`,
);
}
// Resume cursor: pick up from the last Discord message ID we
// committed, if the user didn't explicitly request a fresh run.
// Repair mode ignores the cursor entirely — it needs to walk
// every row to discover which ones are broken.
const resumeCursor =
options.resume && !options.repair ? loadCursor(mapping.discordId) : null;
const resumeCreatedAt = resumeCursor
? (rowsToObjects<any>(db, `SELECT created_at FROM messages WHERE id = ?`, [
resumeCursor,
])[0]?.created_at ?? 0)
: 0;
// Map Discord message IDs already inserted in this session so
// cross-batch replyTo can resolve without a round-trip when
// the target also exists in this backup. Pre-populated by the
// `resolved` payload of every `importBatch` response.
const discordIdToConvexId = new Map<string, string>();
// Ordered oldest-first so imports slot into the channel history
// by `importedCreatedAt` naturally, and resume cursors work via
// a simple monotonic ">" check.
const allRows = rowsToObjects<BackupMessageRow>(
db,
`SELECT id, channel_id, author_id, content, created_at, replied_to_id
FROM messages
WHERE channel_id = ? AND created_at > ?
ORDER BY created_at ASC`,
[mapping.discordId, resumeCreatedAt],
);
onProgress({
stage: 'importing',
channelDiscordId: channel.discordId,
channelName: channel.name,
channelProgress: { inserted: 0, total: allRows.length },
attachmentsUploaded,
attachmentBytesUploaded,
});
// Walk rows in chunks of MESSAGE_BATCH. Per-chunk we:
// 1. Ask the server which discordIds already exist (dedup +
// reply-parent remap in one query).
// 2. Upload attachments in parallel for the rows that DON'T
// already exist.
// 3. Encrypt + sign ciphertexts in parallel.
// 4. Sign the batch canonical once, submit.
// 5. Update the cursor at the end.
let insertedInChannel = 0;
let skippedInChannel = 0;
for (let i = 0; i < allRows.length; i += MESSAGE_BATCH) {
if (cancelSignal.cancelled) break;
const slice = allRows.slice(i, i + MESSAGE_BATCH);
// ---- Step 0: bulk-fetch attachment rows for the slice -----
// One SQLite query instead of one-per-message inside the
// prep loop. Needed up-front in repair mode too (to know
// which rows had attachments in the backup).
const sliceIds = slice.map((r) => r.id);
const attachmentsByMessageId = new Map<string, BackupAttachmentRow[]>();
if (sliceIds.length > 0) {
const placeholders = sliceIds.map(() => '?').join(',');
const rows = rowsToObjects<BackupAttachmentRow>(
db,
`SELECT id, message_id, filename, local_path, size, content_type, downloaded
FROM attachments WHERE message_id IN (${placeholders})`,
sliceIds,
);
for (const att of rows) {
const key = String(att.message_id);
let list = attachmentsByMessageId.get(key);
if (!list) {
list = [];
attachmentsByMessageId.set(key, list);
}
list.push(att);
}
}
const countCaptured = (id: string): number => {
const list = attachmentsByMessageId.get(id);
if (!list) return 0;
return list.filter((a) => a.downloaded && a.local_path).length;
};
// ---- Step 1: dedup / repair / reply-parent prefetch -------
// Build the union of every discordId referenced by this
// batch — both the rows themselves and their reply targets.
const idsToCheck = new Set<string>();
for (const row of slice) {
idsToCheck.add(row.id);
if (row.replied_to_id && !discordIdToConvexId.has(row.replied_to_id)) {
idsToCheck.add(row.replied_to_id);
}
}
// Tracks rows that were imported before but whose attachment
// count no longer matches the backup — scheduled for delete +
// re-insert so the fresh upload brings them back in sync.
const repairDeleteIds: string[] = [];
if (options.repair) {
// Fetch full decryptable state for every candidate id. The
// runner decrypts each locally and compares attachment
// counts against the backup. Matches are left alone; only
// mismatches get deleted + re-inserted.
if (!deps.convex.getImportedState || !deps.convex.deleteImportedByDiscordIds) {
throw new Error('Repair mode requires getImportedState + deleteImportedByDiscordIds deps');
}
const state = await deps.convex.getImportedState({
channelId: mapping.convexChannelId,
discordMessageIds: Array.from(idsToCheck),
});
const stateByDiscordId = new Map(
state.map((s) => [s.discordMessageId, s]),
);
// Record the ones that look complete so they can still
// serve as reply-parent resolutions for later rows.
for (const s of state) {
discordIdToConvexId.set(s.discordMessageId, s.messageId);
}
for (const row of slice) {
const captured = countCaptured(row.id);
if (captured === 0) continue; // no attachments to verify
const existing = stateByDiscordId.get(row.id);
if (!existing) continue; // missing entirely — normal flow will insert
const keyForVersion = channelKey.allVersions.get(existing.keyVersion);
if (!keyForVersion) {
// We don't hold the key version this row was encrypted
// under (key rotation after import). Leave it alone.
continue;
}
try {
// Ciphertext on disk is the hex-concatenation of
// AES-GCM `content + tag`; split back into the form
// decryptData expects (last 16 bytes / 32 hex chars =
// the auth tag).
const raw = existing.ciphertext;
const content = raw.slice(0, -32);
const tag = raw.slice(-32);
const plaintext = await deps.crypto.decryptData(
content,
keyForVersion,
existing.nonce,
tag,
);
let actualAttachmentCount = 0;
try {
const parsedBody = JSON.parse(plaintext);
if (Array.isArray(parsedBody?.attachments)) {
actualAttachmentCount = parsedBody.attachments.length;
} else if (
parsedBody?.type === 'attachment' &&
parsedBody.url
) {
actualAttachmentCount = 1; // legacy single-attachment shape
}
} catch {
// Plaintext wasn't JSON — pure text message, zero
// attachments. Stays zero.
}
if (actualAttachmentCount < captured) {
repairDeleteIds.push(row.id);
}
} catch (err) {
// Decrypt failure on one row shouldn't abort the whole
// repair pass. Log + skip; the row stays untouched.
console.warn(
`Repair decrypt failed for ${row.id}; leaving as-is`,
err,
);
}
}
// Purge the mismatches so the normal insert path below
// re-adds them from scratch. Chunked to respect the
// backend's per-call page cap.
for (let k = 0; k < repairDeleteIds.length; k += 100) {
const chunk = repairDeleteIds.slice(k, k + 100);
const authTimestamp = Date.now();
const canonical = `deleteImportedByDiscordIds:${deps.actorId}:${mapping.convexChannelId}:${chunk.length}:${authTimestamp}`;
const authSignature = await deps.crypto.signMessage(
deps.signingKey,
canonical,
);
await (deps.convex.deleteImportedByDiscordIds as any)({
actorId: deps.actorId,
channelId: mapping.convexChannelId,
discordMessageIds: chunk,
authTimestamp,
authSignature,
});
for (const id of chunk) discordIdToConvexId.delete(id);
}
} else {
// Normal / resume flow: one round-trip for dedup + reply
// parent resolution.
const existing = await deps.convex.resolveReplyTargets({
channelId: mapping.convexChannelId,
discordMessageIds: Array.from(idsToCheck),
});
for (const e of existing) {
discordIdToConvexId.set(e.discordMessageId, e.messageId);
}
}
const toProcess = slice.filter((row) => {
const senderId = row.author_id ? authorMap.get(row.author_id) : undefined;
if (!senderId) return false; // orphan row with no known author
if (discordIdToConvexId.has(row.id)) return false; // already complete
if (options.repair) {
// In repair mode we only fix rows that (a) were freshly
// deleted as mismatches above, OR (b) never existed
// server-side AND have attachments worth uploading.
// Plain text-only rows that never made it in stay out.
const wasDeleted = repairDeleteIds.includes(row.id);
const missingAndHasAttachments =
countCaptured(row.id) > 0 && !discordIdToConvexId.has(row.id);
if (!wasDeleted && !missingAndHasAttachments) return false;
}
return true;
});
// Count the skips toward total progress so the "N / total"
// readout still advances through an idempotent re-run.
skippedInChannel += slice.length - toProcess.length;
// ---- Step 2-3: parallel per-row prep (uploads + encrypt + sign)
//
// `mapConcurrent` rejects on the first error, which cascades
// up out of the batch loop. That's the desired behaviour: a
// transient upload failure should ABORT the whole batch so
// none of its rows commit — resume then re-enters these rows
// from scratch rather than committing half-empty messages.
const preparedRaw = await mapConcurrent(
toProcess,
PREP_CONCURRENCY,
async (row) => {
const senderId = authorMap.get(row.author_id!)!;
const attachments = attachmentsByMessageId.get(row.id) ?? [];
const attachmentMetas: AttachmentMetadata[] = [];
let expectedAttachments = 0;
let capturedAttachments = 0;
if (attachments.length > 0) {
expectedAttachments = attachments.length;
capturedAttachments = attachments.filter(
(a) => a.downloaded && a.local_path,
).length;
const uploaded = await mapConcurrent(
attachments,
ATTACHMENT_CONCURRENCY,
(att) => uploadOneAttachment(deps, dataDir, att),
);
for (const meta of uploaded) {
if (meta) {
attachmentMetas.push(meta);
attachmentsUploaded += 1;
attachmentBytesUploaded += meta.size;
}
}
}
const bodyText = row.content ?? '';
// Nothing to display: no text, and every attachment was
// either missing on disk or never captured by the backup
// bot. Better to drop the row than insert an invisible
// bubble into the channel. Nothing is thrown — we mark
// it `null` so the batch skips the insert but its cursor
// still advances.
if (!bodyText && attachmentMetas.length === 0) {
return null;
}
const payload =
attachmentMetas.length > 0
? JSON.stringify({ text: bodyText, attachments: attachmentMetas })
: bodyText;
const { content, iv, tag } = await deps.crypto.encryptData(
payload,
channelKey.keyHex,
);
const ciphertext = content + tag;
const signature = await deps.crypto.signMessage(
deps.signingKey,
ciphertext,
);
let replyTo: string | undefined;
if (row.replied_to_id) {
const local = discordIdToConvexId.get(row.replied_to_id);
if (local) replyTo = local;
}
if (
expectedAttachments > 0 &&
attachmentMetas.length < capturedAttachments
) {
// Shouldn't happen — uploadOneAttachment throws on any
// recoverable failure — but surface it loudly if some
// future refactor re-introduces silent skips.
throw new Error(
`Internal: only ${attachmentMetas.length}/${capturedAttachments} attachments uploaded for message ${row.id}`,
);
}
return {
senderId,
ciphertext,
nonce: iv,
signature,
keyVersion: channelKey.keyVersion,
replyTo,
importedCreatedAt: Number(row.created_at),
discordMessageId: row.id,
};
},
);
const prepared = preparedRaw.filter((p): p is NonNullable<typeof p> => p !== null);
// ---- Step 4: submit the batch -----------------------------
if (prepared.length > 0) {
const authTimestamp = Date.now();
const canonical = `importBatch:${deps.actorId}:${mapping.convexChannelId}:${prepared.length}:${authTimestamp}`;
const authSignature = await deps.crypto.signMessage(
deps.signingKey,
canonical,
);
const result = await deps.convex.importBatch({
actorId: deps.actorId,
channelId: mapping.convexChannelId,
messages: prepared,
authTimestamp,
authSignature,
});
for (const r of result.resolved) {
discordIdToConvexId.set(r.discordMessageId, r.messageId);
}
insertedInChannel += result.inserted;
}
// ---- Step 5: resume cursor + progress ---------------------
const lastRow = slice[slice.length - 1];
if (lastRow) saveCursor(mapping.discordId, lastRow.id);
onProgress({
stage: 'importing',
channelDiscordId: channel.discordId,
channelName: channel.name,
channelProgress: {
inserted: insertedInChannel + skippedInChannel,
total: allRows.length,
},
attachmentsUploaded,
attachmentBytesUploaded,
});
}
// Channel finished cleanly — wipe its resume cursor so a fresh
// re-run starts from scratch when the user wants to re-pull
// from a newer backup.
if (!cancelSignal.cancelled) {
clearCursor(mapping.discordId);
}
}
onProgress({
stage: 'done',
channelDiscordId: null,
channelName: null,
channelProgress: null,
attachmentsUploaded,
attachmentBytesUploaded,
});
}

View File

@@ -0,0 +1,155 @@
/**
* Module-scope singleton that owns the in-flight backup import.
*
* Keeping this outside of React state means the ImportTab
* component can unmount (modal close, tab switch) without
* cancelling the run or losing the progress stream. On remount
* the tab re-subscribes and re-renders whatever state the
* session is in.
*/
import type {
ImportDeps,
ImportOptions,
ImportProgress,
ParsedBackup,
} from './importRunner';
import { runImport } from './importRunner';
export type ImportSessionStatus =
| 'idle'
| 'running'
| 'cancelling'
| 'done'
| 'error';
export interface ImportSessionState {
status: ImportSessionStatus;
progress: ImportProgress | null;
error: string | null;
/** The backup currently loaded in memory (sql.js DB + summary). */
parsed: ParsedBackup | null;
/** Absolute path of the backup the user picked, for display. */
dbPath: string | null;
/** Mapping snapshots so the tab can re-render its dropdowns without
* re-querying. */
channelMap: Record<string, string>;
authorMap: Record<string, string>;
}
type Listener = (state: ImportSessionState) => void;
class ImportSession {
private state: ImportSessionState = {
status: 'idle',
progress: null,
error: null,
parsed: null,
dbPath: null,
channelMap: {},
authorMap: {},
};
private listeners = new Set<Listener>();
private cancelSignal = { cancelled: false };
getState(): ImportSessionState {
return this.state;
}
subscribe(cb: Listener): () => void {
this.listeners.add(cb);
return () => {
this.listeners.delete(cb);
};
}
private emit(): void {
for (const cb of this.listeners) cb(this.state);
}
setParsed(parsed: ParsedBackup | null, dbPath: string | null): void {
this.state = { ...this.state, parsed, dbPath };
this.emit();
}
setChannelMap(map: Record<string, string>): void {
this.state = { ...this.state, channelMap: map };
this.emit();
}
setAuthorMap(map: Record<string, string>): void {
this.state = { ...this.state, authorMap: map };
this.emit();
}
clearError(): void {
if (this.state.error === null) return;
this.state = { ...this.state, error: null };
this.emit();
}
async start(
parsed: ParsedBackup,
deps: ImportDeps,
options: Omit<ImportOptions, 'onProgress' | 'cancelSignal'>,
): Promise<void> {
if (this.state.status === 'running' || this.state.status === 'cancelling') {
return;
}
this.cancelSignal = { cancelled: false };
this.state = {
...this.state,
status: 'running',
progress: null,
error: null,
parsed,
};
this.emit();
try {
await runImport(parsed, deps, {
...options,
cancelSignal: this.cancelSignal,
onProgress: (p) => {
this.state = { ...this.state, progress: p };
this.emit();
},
});
this.state = {
...this.state,
status: this.cancelSignal.cancelled ? 'idle' : 'done',
};
this.emit();
} catch (err: any) {
this.state = {
...this.state,
status: 'error',
error: err?.message ?? 'Import failed',
};
this.emit();
}
}
cancel(): void {
if (this.state.status !== 'running') return;
this.cancelSignal.cancelled = true;
this.state = { ...this.state, status: 'cancelling' };
this.emit();
}
reset(): void {
if (this.state.status === 'running' || this.state.status === 'cancelling') {
return;
}
this.state = {
status: 'idle',
progress: null,
error: null,
parsed: null,
dbPath: null,
channelMap: {},
authorMap: {},
};
this.emit();
}
}
export const importSession = new ImportSession();