This commit is contained in:
@@ -79,11 +79,17 @@ const webPlatform = {
|
||||
},
|
||||
windowControls: null,
|
||||
notifications: makeWebNotifications(),
|
||||
lifecycle: null,
|
||||
recording: null,
|
||||
updates: null,
|
||||
voiceService: null,
|
||||
systemBars: null,
|
||||
searchDB,
|
||||
// Backup importer needs native filesystem access for the SQLite
|
||||
// file + attachment tree, which the browser sandbox doesn't
|
||||
// provide. Null on web/Capacitor; the Import tab renders a
|
||||
// "requires desktop" placeholder in that case.
|
||||
importer: null,
|
||||
features: {
|
||||
hasWindowControls: false,
|
||||
hasScreenCapture: true,
|
||||
@@ -93,6 +99,8 @@ const webPlatform = {
|
||||
hasSystemBars: false,
|
||||
hasRecording: false,
|
||||
hasNotifications: typeof window !== 'undefined' && typeof window.Notification !== 'undefined',
|
||||
hasLifecycle: false,
|
||||
hasBackupImporter: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
253
packages/shared/src/components/channel/ChannelAccessPanel.tsx
Normal file
253
packages/shared/src/components/channel/ChannelAccessPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
309
packages/shared/src/components/settings/GhostsTab.tsx
Normal file
309
packages/shared/src/components/settings/GhostsTab.tsx
Normal 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,
|
||||
};
|
||||
814
packages/shared/src/components/settings/ImportTab.tsx
Normal file
814
packages/shared/src/components/settings/ImportTab.tsx
Normal 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,
|
||||
};
|
||||
@@ -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>,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 & 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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
911
packages/shared/src/utils/importRunner.ts
Normal file
911
packages/shared/src/utils/importRunner.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
155
packages/shared/src/utils/importSession.ts
Normal file
155
packages/shared/src/utils/importSession.ts
Normal 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();
|
||||
Reference in New Issue
Block a user