account actions
This commit is contained in:
parent
29f4219d95
commit
e518e78cef
22 changed files with 1015 additions and 146 deletions
|
@ -12,14 +12,17 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@corvu/otp-field": "^0.1.4",
|
"@corvu/otp-field": "^0.1.4",
|
||||||
"@corvu/resizable": "^0.2.3",
|
"@corvu/resizable": "^0.2.3",
|
||||||
|
"@fuman/io": "0.0.8",
|
||||||
"@fuman/utils": "0.0.4",
|
"@fuman/utils": "0.0.4",
|
||||||
"@kobalte/core": "^0.13.7",
|
"@kobalte/core": "^0.13.7",
|
||||||
|
"@mtcute/convert": "^0.19.4",
|
||||||
|
"@mtcute/web": "^0.19.5",
|
||||||
"@nanostores/persistent": "^0.10.2",
|
"@nanostores/persistent": "^0.10.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"filesize": "^10.1.6",
|
"filesize": "^10.1.6",
|
||||||
"lucide-solid": "^0.445.0",
|
"lucide-solid": "^0.445.0",
|
||||||
|
"md5": "^2.3.0",
|
||||||
"monaco-editor": "0.52.0",
|
"monaco-editor": "0.52.0",
|
||||||
"monaco-editor-core": "0.52.0",
|
"monaco-editor-core": "0.52.0",
|
||||||
"monaco-editor-textmate": "^4.0.0",
|
"monaco-editor-textmate": "^4.0.0",
|
||||||
|
@ -30,10 +33,12 @@
|
||||||
"onigasm": "^2.2.5",
|
"onigasm": "^2.2.5",
|
||||||
"solid-icons": "^1.1.0",
|
"solid-icons": "^1.1.0",
|
||||||
"solid-js": "^1.9.4",
|
"solid-js": "^1.9.4",
|
||||||
|
"solid-sonner": "^0.2.8",
|
||||||
"solid-transition-group": "^0.2.3",
|
"solid-transition-group": "^0.2.3",
|
||||||
"ts-blank-space": "^0.4.4"
|
"ts-blank-space": "^0.4.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/md5": "^2.3.5",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { Runner } from './components/runner/Runner.tsx'
|
||||||
import { SettingsDialog, type SettingsTab } from './components/settings/Settings.tsx'
|
import { SettingsDialog, type SettingsTab } from './components/settings/Settings.tsx'
|
||||||
import { Updater } from './components/Updater.tsx'
|
import { Updater } from './components/Updater.tsx'
|
||||||
import { Resizable, ResizableHandle, ResizablePanel } from './lib/components/ui/resizable.tsx'
|
import { Resizable, ResizableHandle, ResizablePanel } from './lib/components/ui/resizable.tsx'
|
||||||
|
import { Toaster } from './lib/components/ui/sonner.tsx'
|
||||||
|
|
||||||
const Editor = lazy(() => import('./components/editor/Editor.tsx'))
|
const Editor = lazy(() => import('./components/editor/Editor.tsx'))
|
||||||
|
|
||||||
|
@ -23,6 +24,7 @@ export function App() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-screen w-screen flex-col overflow-hidden">
|
<div class="flex h-screen w-screen flex-col overflow-hidden">
|
||||||
|
<Toaster />
|
||||||
<iframe
|
<iframe
|
||||||
ref={workerIframe}
|
ref={workerIframe}
|
||||||
class="invisible size-0"
|
class="invisible size-0"
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { type TelegramAccount, workerInvoke } from 'mtcute-repl-worker/client'
|
import { type TelegramAccount, workerInvoke } from 'mtcute-repl-worker/client'
|
||||||
import { createSignal, onCleanup, onMount } from 'solid-js'
|
import { createEffect, createSignal, onCleanup, untrack } from 'solid-js'
|
||||||
import { Avatar, AvatarFallback, AvatarImage, makeAvatarFallbackText } from '../lib/components/ui/avatar.tsx'
|
import { Avatar, AvatarFallback, AvatarImage, makeAvatarFallbackText } from '../lib/components/ui/avatar.tsx'
|
||||||
|
|
||||||
export function AccountAvatar(props: {
|
export function AccountAvatar(props: {
|
||||||
|
@ -7,16 +7,28 @@ export function AccountAvatar(props: {
|
||||||
account: TelegramAccount
|
account: TelegramAccount
|
||||||
}) {
|
}) {
|
||||||
const [url, setUrl] = createSignal<string | undefined>()
|
const [url, setUrl] = createSignal<string | undefined>()
|
||||||
onMount(async () => {
|
createEffect(() => {
|
||||||
try {
|
const accountId = props.account.id
|
||||||
const buf = await workerInvoke('telegram', 'fetchAvatar', props.account.id)
|
if (!accountId) {
|
||||||
if (!buf) return
|
return
|
||||||
|
|
||||||
const url = URL.createObjectURL(new Blob([buf], { type: 'image/jpeg' }))
|
|
||||||
setUrl(url)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (untrack(url)) {
|
||||||
|
URL.revokeObjectURL(untrack(url)!)
|
||||||
|
}
|
||||||
|
setUrl(undefined)
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const buf = await workerInvoke('telegram', 'fetchAvatar', accountId)
|
||||||
|
if (!buf) return
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(new Blob([buf], { type: 'image/jpeg' }))
|
||||||
|
setUrl(url)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
})()
|
||||||
})
|
})
|
||||||
onCleanup(() => url() && URL.revokeObjectURL(url()!))
|
onCleanup(() => url() && URL.revokeObjectURL(url()!))
|
||||||
|
|
||||||
|
|
|
@ -1,29 +1,39 @@
|
||||||
|
import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu'
|
||||||
|
import type { TooltipTriggerProps } from '@kobalte/core/tooltip'
|
||||||
import type { TelegramAccount } from 'mtcute-repl-worker/client'
|
import type { TelegramAccount } from 'mtcute-repl-worker/client'
|
||||||
import type { LoginStep } from './login/Login.tsx'
|
import type { LoginStep } from './login/Login.tsx'
|
||||||
import { timers } from '@fuman/utils'
|
import { timers, unknownToError } from '@fuman/utils'
|
||||||
import {
|
import {
|
||||||
LucideBot,
|
LucideBot,
|
||||||
|
LucideChevronRight,
|
||||||
LucideEllipsis,
|
LucideEllipsis,
|
||||||
|
LucideFolderUp,
|
||||||
LucideLogIn,
|
LucideLogIn,
|
||||||
LucidePlus,
|
LucidePlus,
|
||||||
|
LucideRefreshCw,
|
||||||
LucideSearch,
|
LucideSearch,
|
||||||
LucideTrash,
|
LucideTrash,
|
||||||
LucideUser,
|
LucideUser,
|
||||||
LucideX,
|
LucideX,
|
||||||
} from 'lucide-solid'
|
} from 'lucide-solid'
|
||||||
import { workerInvoke } from 'mtcute-repl-worker/client'
|
import { workerInvoke } from 'mtcute-repl-worker/client'
|
||||||
|
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from 'solid-js'
|
import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from 'solid-js'
|
||||||
|
import { toast } from 'solid-sonner'
|
||||||
|
import { copyToClipboard } from '../../lib/clipboard.tsx'
|
||||||
import { Badge } from '../../lib/components/ui/badge.tsx'
|
import { Badge } from '../../lib/components/ui/badge.tsx'
|
||||||
|
|
||||||
import { Button } from '../../lib/components/ui/button.tsx'
|
import { Button } from '../../lib/components/ui/button.tsx'
|
||||||
import { Dialog, DialogContent } from '../../lib/components/ui/dialog.tsx'
|
import { Dialog, DialogContent } from '../../lib/components/ui/dialog.tsx'
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../../lib/components/ui/dropdown-menu.tsx'
|
||||||
import { TextField, TextFieldFrame, TextFieldRoot } from '../../lib/components/ui/text-field.tsx'
|
import { TextField, TextFieldFrame, TextFieldRoot } from '../../lib/components/ui/text-field.tsx'
|
||||||
|
import { WithTooltip } from '../../lib/components/ui/tooltip.tsx'
|
||||||
import { cn } from '../../lib/utils.ts'
|
import { cn } from '../../lib/utils.ts'
|
||||||
import { $accounts, $activeAccountId } from '../../store/accounts.ts'
|
import { $accounts, $activeAccountId } from '../../store/accounts.ts'
|
||||||
import { useStore } from '../../store/use-store.ts'
|
import { useStore } from '../../store/use-store.ts'
|
||||||
import { AccountAvatar } from '../AccountAvatar.tsx'
|
import { AccountAvatar } from '../AccountAvatar.tsx'
|
||||||
import { ImportDropdown } from './import/ImportDropdown.tsx'
|
import { ImportDropdown } from './import/ImportDropdown.tsx'
|
||||||
|
import { StringSessionDefs } from './import/StringSessionImportDialog.tsx'
|
||||||
import { LoginForm } from './login/Login.tsx'
|
import { LoginForm } from './login/Login.tsx'
|
||||||
|
|
||||||
function AddAccountDialog(props: {
|
function AddAccountDialog(props: {
|
||||||
|
@ -113,6 +123,18 @@ function AccountRow(props: {
|
||||||
active: boolean
|
active: boolean
|
||||||
onSetActive: () => void
|
onSetActive: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const [deleteConfirming, setDeleteConfirming] = createSignal(false)
|
||||||
|
const [deleting, setDeleting] = createSignal(false)
|
||||||
|
async function handleDelete() {
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await workerInvoke('telegram', 'deleteAccount', { accountId: props.account.id })
|
||||||
|
} catch (e) {
|
||||||
|
toast(unknownToError(e).message)
|
||||||
|
}
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex max-w-full flex-row overflow-hidden rounded-md border border-border p-2">
|
<div class="flex max-w-full flex-row overflow-hidden rounded-md border border-border p-2">
|
||||||
<AccountAvatar
|
<AccountAvatar
|
||||||
|
@ -150,29 +172,110 @@ function AccountRow(props: {
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
<div class="mr-1 flex items-center gap-1">
|
<div class="mr-1 flex items-center gap-1">
|
||||||
<Button
|
<DropdownMenu>
|
||||||
variant="ghost"
|
<DropdownMenuTrigger
|
||||||
size="icon"
|
as={(props: DropdownMenuTriggerProps) => (
|
||||||
class="size-8"
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="size-8"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<LucideEllipsis class="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem
|
||||||
|
class="py-1 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
workerInvoke('telegram', 'updateInfo', { accountId: props.account.id }).then(() => {
|
||||||
|
toast('Account info updated')
|
||||||
|
}).catch((e) => {
|
||||||
|
toast(unknownToError(e).message)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LucideRefreshCw class="mr-2 size-3.5 stroke-[1.5px]" />
|
||||||
|
Update info
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger class="py-1 text-xs">
|
||||||
|
<LucideFolderUp class="mr-2 size-3.5 stroke-[1.5px]" />
|
||||||
|
Export session
|
||||||
|
<LucideChevronRight class="ml-2 size-3.5" />
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<For each={StringSessionDefs}>
|
||||||
|
{def => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
class="py-1 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
workerInvoke('telegram', 'exportStringSession', {
|
||||||
|
accountId: props.account.id,
|
||||||
|
libraryName: def.name,
|
||||||
|
}).then((res) => {
|
||||||
|
copyToClipboard(res)
|
||||||
|
toast('String session copied to clipboard')
|
||||||
|
}).catch((e) => {
|
||||||
|
toast(unknownToError(e).message)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{def.displayName}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<WithTooltip content="Use this account">
|
||||||
|
{(triggerProps: TooltipTriggerProps) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="size-8"
|
||||||
|
disabled={props.active}
|
||||||
|
{...triggerProps}
|
||||||
|
onClick={() => {
|
||||||
|
props.onSetActive()
|
||||||
|
// @ts-expect-error meow
|
||||||
|
triggerProps.onClick?.()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LucideLogIn class="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</WithTooltip>
|
||||||
|
<WithTooltip
|
||||||
|
content="Click again to confirm"
|
||||||
|
enabled={deleteConfirming()}
|
||||||
|
rootProps={{ openDelay: 0 }}
|
||||||
>
|
>
|
||||||
<LucideEllipsis class="size-4" />
|
{(props: TooltipTriggerProps) => (
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
variant={deleteConfirming() ? 'destructive' : 'ghostDestructive'}
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
class="size-8"
|
||||||
class="size-8"
|
{...props}
|
||||||
disabled={props.active}
|
onClick={() => {
|
||||||
onClick={props.onSetActive}
|
if (deleteConfirming()) {
|
||||||
>
|
handleDelete()
|
||||||
<LucideLogIn class="size-4" />
|
setDeleteConfirming(false)
|
||||||
</Button>
|
} else {
|
||||||
<Button
|
setDeleteConfirming(true)
|
||||||
variant="ghostDestructive"
|
}
|
||||||
size="icon"
|
// @ts-expect-error meow
|
||||||
class="size-8"
|
props.onClick?.()
|
||||||
>
|
}}
|
||||||
<LucideTrash class="size-4" />
|
onMouseLeave={() => setDeleteConfirming(false)}
|
||||||
</Button>
|
disabled={deleting()}
|
||||||
|
>
|
||||||
|
<LucideTrash class="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</WithTooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import { hex } from '@fuman/utils'
|
import { workerInvoke } from 'mtcute-repl-worker/client'
|
||||||
import { createEffect, createSignal, on } from 'solid-js'
|
import { createEffect, createSignal, on } from 'solid-js'
|
||||||
import { Button } from '../../../lib/components/ui/button.tsx'
|
import { Button } from '../../../lib/components/ui/button.tsx'
|
||||||
import { Checkbox, CheckboxControl, CheckboxLabel } from '../../../lib/components/ui/checkbox.tsx'
|
import { Checkbox, CheckboxControl, CheckboxLabel } from '../../../lib/components/ui/checkbox.tsx'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '../../../lib/components/ui/dialog.tsx'
|
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '../../../lib/components/ui/dialog.tsx'
|
||||||
import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../../lib/components/ui/text-field.tsx'
|
import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../../lib/components/ui/text-field.tsx'
|
||||||
import { $accounts } from '../../../store/accounts.ts'
|
|
||||||
|
|
||||||
export function AuthKeyImportDialog(props: {
|
export function AuthKeyImportDialog(props: {
|
||||||
open: boolean
|
open: boolean
|
||||||
|
@ -27,36 +26,14 @@ export function AuthKeyImportDialog(props: {
|
||||||
abortController = new AbortController()
|
abortController = new AbortController()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
const oldAccounts = $accounts.get()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const testMode_ = testMode()
|
await workerInvoke('telegram', 'importAuthKey', {
|
||||||
const authKey = hex.decode(authKeyInputRef()!.value)
|
hexAuthKey: authKeyInputRef()!.value,
|
||||||
if (authKey.length !== 256) {
|
dcId: Number(dcId()),
|
||||||
setError('Invalid auth key (must be 256 bytes long)')
|
testMode: testMode(),
|
||||||
setLoading(false)
|
abortSignal: abortController.signal,
|
||||||
return
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const session: InputStringSessionData = {
|
|
||||||
authKey: hex.decode(authKeyInputRef()!.value),
|
|
||||||
testMode: testMode_,
|
|
||||||
primaryDcs: (testMode_ ? DC_MAPPING_TEST : DC_MAPPING_PROD)[Number(dcId())],
|
|
||||||
}
|
|
||||||
const account = await importAccount(session, abortController.signal)
|
|
||||||
|
|
||||||
// check if account already exists
|
|
||||||
if (oldAccounts.some(it => it.telegramId === account.telegramId)) {
|
|
||||||
deleteAccount(account.id)
|
|
||||||
setError(`Account already exists (user ID: ${account.telegramId})`)
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
$accounts.set([
|
|
||||||
...$accounts.get(),
|
|
||||||
account,
|
|
||||||
])
|
|
||||||
props.onClose()
|
props.onClose()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
|
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { workerInvoke } from 'mtcute-repl-worker/client'
|
||||||
|
import { createEffect, createSignal, on } from 'solid-js'
|
||||||
|
import { Button } from '../../../lib/components/ui/button.tsx'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '../../../lib/components/ui/dialog.tsx'
|
||||||
|
import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../../lib/components/ui/text-field.tsx'
|
||||||
|
|
||||||
|
export function BotTokenImportDialog(props: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [botToken, setBotToken] = createSignal('')
|
||||||
|
const [error, setError] = createSignal<string | undefined>()
|
||||||
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
|
||||||
|
let abortController: AbortController | undefined
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
abortController?.abort()
|
||||||
|
abortController = new AbortController()
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await workerInvoke('telegram', 'importBotToken', {
|
||||||
|
botToken: botToken(),
|
||||||
|
abortSignal: abortController.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
props.onClose()
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
setError(e.message)
|
||||||
|
} else {
|
||||||
|
console.error(e)
|
||||||
|
setError('Unknown error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
createEffect(on(() => props.open, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
abortController?.abort()
|
||||||
|
setLoading(false)
|
||||||
|
abortController = undefined
|
||||||
|
setError(undefined)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={props.open}
|
||||||
|
onOpenChange={open => !open && props.onClose()}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
Log in with bot token
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogDescription>
|
||||||
|
<TextFieldRoot validationState={error() ? 'invalid' : 'valid'}>
|
||||||
|
<TextFieldLabel class="text-foreground">
|
||||||
|
Bot token
|
||||||
|
</TextFieldLabel>
|
||||||
|
<TextFieldFrame>
|
||||||
|
<TextField
|
||||||
|
class="w-full"
|
||||||
|
value={botToken()}
|
||||||
|
onInput={e => setBotToken(e.currentTarget.value)}
|
||||||
|
disabled={loading()}
|
||||||
|
/>
|
||||||
|
</TextFieldFrame>
|
||||||
|
<TextFieldErrorMessage>
|
||||||
|
{error()}
|
||||||
|
</TextFieldErrorMessage>
|
||||||
|
</TextFieldRoot>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
class="mt-6 w-full"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading()}
|
||||||
|
>
|
||||||
|
{loading() ? 'Checking...' : 'Import'}
|
||||||
|
</Button>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu'
|
import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu'
|
||||||
import type { StringSessionLibName } from './StringSessionImportDialog.tsx'
|
import type { StringSessionLibName } from 'mtcute-repl-worker/client'
|
||||||
import { LucideChevronRight, LucideDownload, LucideKeyRound, LucideLaptop, LucideTextCursorInput } from 'lucide-solid'
|
import { LucideBot, LucideChevronRight, LucideDownload, LucideKeyRound, LucideLaptop, LucideTextCursorInput } from 'lucide-solid'
|
||||||
import { createSignal, For } from 'solid-js'
|
import { createSignal, For } from 'solid-js'
|
||||||
import { Button } from '../../../lib/components/ui/button.tsx'
|
import { Button } from '../../../lib/components/ui/button.tsx'
|
||||||
import {
|
import {
|
||||||
|
@ -12,14 +12,19 @@ import {
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '../../../lib/components/ui/dropdown-menu.tsx'
|
} from '../../../lib/components/ui/dropdown-menu.tsx'
|
||||||
|
import { WithTooltip } from '../../../lib/components/ui/tooltip.tsx'
|
||||||
import { cn } from '../../../lib/utils.ts'
|
import { cn } from '../../../lib/utils.ts'
|
||||||
import { AuthKeyImportDialog } from './AuthKeyImportDialog.tsx'
|
import { AuthKeyImportDialog } from './AuthKeyImportDialog.tsx'
|
||||||
|
import { BotTokenImportDialog } from './BotTokenImportDialog.tsx'
|
||||||
import { StringSessionDefs, StringSessionImportDialog } from './StringSessionImportDialog.tsx'
|
import { StringSessionDefs, StringSessionImportDialog } from './StringSessionImportDialog.tsx'
|
||||||
|
import { TDATA_IMPORT_AVAILABLE, TdataImportDialog } from './tdata/TdataImportDialog.tsx'
|
||||||
|
|
||||||
export function ImportDropdown(props: { size: 'xs' | 'sm' }) {
|
export function ImportDropdown(props: { size: 'xs' | 'sm' }) {
|
||||||
const [showImportStringSession, setShowImportStringSession] = createSignal(false)
|
const [showImportStringSession, setShowImportStringSession] = createSignal(false)
|
||||||
const [stringSessionLibName, setStringSessionLibName] = createSignal<StringSessionLibName>('mtcute')
|
const [stringSessionLibName, setStringSessionLibName] = createSignal<StringSessionLibName>('mtcute')
|
||||||
const [showImportAuthKey, setShowImportAuthKey] = createSignal(false)
|
const [showImportAuthKey, setShowImportAuthKey] = createSignal(false)
|
||||||
|
const [showImportBotToken, setShowImportBotToken] = createSignal(false)
|
||||||
|
const [showImportTdata, setShowImportTdata] = createSignal(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -31,14 +36,10 @@ export function ImportDropdown(props: { size: 'xs' | 'sm' }) {
|
||||||
size={props.size}
|
size={props.size}
|
||||||
{...triggerProps}
|
{...triggerProps}
|
||||||
>
|
>
|
||||||
<LucideDownload class={
|
<LucideDownload class={{
|
||||||
cn(
|
xs: 'mr-2 size-3',
|
||||||
{
|
sm: 'mr-2 size-3.5',
|
||||||
xs: 'mr-2 size-3',
|
}[props.size]}
|
||||||
sm: 'mr-2 size-3.5',
|
|
||||||
}[props.size],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
Import
|
Import
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -49,6 +50,10 @@ export function ImportDropdown(props: { size: 'xs' | 'sm' }) {
|
||||||
<LucideKeyRound class="mr-2 size-3.5 stroke-[1.5px]" />
|
<LucideKeyRound class="mr-2 size-3.5 stroke-[1.5px]" />
|
||||||
Auth key
|
Auth key
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem class="py-1 text-xs" onClick={() => setShowImportBotToken(true)}>
|
||||||
|
<LucideBot class="mr-2 size-3.5 stroke-[1.5px]" />
|
||||||
|
Bot token
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger class="py-1 text-xs">
|
<DropdownMenuSubTrigger class="py-1 text-xs">
|
||||||
<LucideTextCursorInput class="mr-2 size-3.5 stroke-[1.5px]" />
|
<LucideTextCursorInput class="mr-2 size-3.5 stroke-[1.5px]" />
|
||||||
|
@ -71,10 +76,27 @@ export function ImportDropdown(props: { size: 'xs' | 'sm' }) {
|
||||||
</For>
|
</For>
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
<DropdownMenuItem class="py-1 text-xs">
|
<WithTooltip
|
||||||
<LucideLaptop class="mr-2 size-3.5 stroke-[1.5px]" />
|
enabled={!TDATA_IMPORT_AVAILABLE}
|
||||||
Desktop (tdata)
|
content={(
|
||||||
</DropdownMenuItem>
|
<>
|
||||||
|
Importing tdata is not supported in your browser.
|
||||||
|
<br />
|
||||||
|
Try using a Chromium-based browser instead.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(props: DropdownMenuTriggerProps) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
class={cn('py-1 text-xs', !TDATA_IMPORT_AVAILABLE && 'opacity-50')}
|
||||||
|
onClick={() => setShowImportTdata(true)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<LucideLaptop class="mr-2 size-3.5 stroke-[1.5px]" />
|
||||||
|
Desktop (tdata)
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</WithTooltip>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
@ -89,6 +111,16 @@ export function ImportDropdown(props: { size: 'xs' | 'sm' }) {
|
||||||
open={showImportAuthKey()}
|
open={showImportAuthKey()}
|
||||||
onClose={() => setShowImportAuthKey(false)}
|
onClose={() => setShowImportAuthKey(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<BotTokenImportDialog
|
||||||
|
open={showImportBotToken()}
|
||||||
|
onClose={() => setShowImportBotToken(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TdataImportDialog
|
||||||
|
open={showImportTdata()}
|
||||||
|
onClose={() => setShowImportTdata(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,9 @@
|
||||||
|
import { type StringSessionLibName, workerInvoke } from 'mtcute-repl-worker/client'
|
||||||
import { createEffect, createSignal, on } from 'solid-js'
|
import { createEffect, createSignal, on } from 'solid-js'
|
||||||
import { Button } from '../../../lib/components/ui/button.tsx'
|
import { Button } from '../../../lib/components/ui/button.tsx'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '../../../lib/components/ui/dialog.tsx'
|
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '../../../lib/components/ui/dialog.tsx'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../lib/components/ui/select.tsx'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../lib/components/ui/select.tsx'
|
||||||
import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../../lib/components/ui/text-field.tsx'
|
import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../../lib/components/ui/text-field.tsx'
|
||||||
import { $accounts } from '../../../store/accounts.ts'
|
|
||||||
|
|
||||||
export type StringSessionLibName =
|
|
||||||
| 'mtcute'
|
|
||||||
| 'pyrogram'
|
|
||||||
| 'telethon'
|
|
||||||
| 'mtkruto'
|
|
||||||
| 'gramjs'
|
|
||||||
|
|
||||||
export const StringSessionDefs: {
|
export const StringSessionDefs: {
|
||||||
name: StringSessionLibName
|
name: StringSessionLibName
|
||||||
|
@ -23,30 +16,6 @@ export const StringSessionDefs: {
|
||||||
{ name: 'mtkruto', displayName: 'MTKruto' },
|
{ name: 'mtkruto', displayName: 'MTKruto' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// async function convert(libName: StringSessionLibName, session: string): Promise<StringSessionData> {
|
|
||||||
// switch (libName) {
|
|
||||||
// case 'mtcute': {
|
|
||||||
// return readStringSession(session)
|
|
||||||
// }
|
|
||||||
// case 'telethon': {
|
|
||||||
// const { convertFromTelethonSession } = await import('@mtcute/convert')
|
|
||||||
// return convertFromTelethonSession(session)
|
|
||||||
// }
|
|
||||||
// case 'gramjs': {
|
|
||||||
// const { convertFromGramjsSession } = await import('@mtcute/convert')
|
|
||||||
// return convertFromGramjsSession(session)
|
|
||||||
// }
|
|
||||||
// case 'pyrogram': {
|
|
||||||
// const { convertFromPyrogramSession } = await import('@mtcute/convert')
|
|
||||||
// return convertFromPyrogramSession(session)
|
|
||||||
// }
|
|
||||||
// case 'mtkruto': {
|
|
||||||
// const { convertFromMtkrutoSession } = await import('@mtcute/convert')
|
|
||||||
// return convertFromMtkrutoSession(session)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
export function StringSessionImportDialog(props: {
|
export function StringSessionImportDialog(props: {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
@ -63,32 +32,12 @@ export function StringSessionImportDialog(props: {
|
||||||
abortController = new AbortController()
|
abortController = new AbortController()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
const oldAccounts = $accounts.get()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const converted = await convert(props.chosenLibName, inputRef()!.value)
|
await workerInvoke('telegram', 'importStringSession', {
|
||||||
|
libraryName: props.chosenLibName,
|
||||||
// check if account exists
|
session: inputRef()!.value,
|
||||||
if (converted.self && oldAccounts.some(it => it.telegramId === converted.self!.userId)) {
|
abortSignal: abortController.signal,
|
||||||
setError(`Account already exists (user ID: ${converted.self!.userId})`)
|
})
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const account = await importAccount(converted, abortController.signal)
|
|
||||||
|
|
||||||
// check once again if account already exists
|
|
||||||
if (oldAccounts.some(it => it.telegramId === account.telegramId)) {
|
|
||||||
deleteAccount(account.id)
|
|
||||||
setError(`Account already exists (user ID: ${account.telegramId})`)
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
$accounts.set([
|
|
||||||
...$accounts.get(),
|
|
||||||
account,
|
|
||||||
])
|
|
||||||
props.onClose()
|
props.onClose()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
|
@ -101,6 +50,7 @@ export function StringSessionImportDialog(props: {
|
||||||
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(on(() => props.open, (open) => {
|
createEffect(on(() => props.open, (open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
abortController?.abort()
|
abortController?.abort()
|
||||||
|
|
|
@ -0,0 +1,207 @@
|
||||||
|
import { hex } from '@fuman/utils'
|
||||||
|
import { workerInvoke } from 'mtcute-repl-worker/client'
|
||||||
|
import { createEffect, createSignal, For, on, Show } from 'solid-js'
|
||||||
|
import { Button } from '../../../../lib/components/ui/button.tsx'
|
||||||
|
import { Checkbox, CheckboxControl, CheckboxLabel } from '../../../../lib/components/ui/checkbox.tsx'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '../../../../lib/components/ui/dialog.tsx'
|
||||||
|
import { Spinner } from '../../../../lib/components/ui/spinner.tsx'
|
||||||
|
import { $accounts } from '../../../../store/accounts.ts'
|
||||||
|
|
||||||
|
export const TDATA_IMPORT_AVAILABLE = 'showDirectoryPicker' in window
|
||||||
|
|
||||||
|
interface TdataAccount {
|
||||||
|
telegramId: number
|
||||||
|
index: number
|
||||||
|
authKey: Uint8Array
|
||||||
|
dcId: number
|
||||||
|
toImport: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TdataImportDialog(props: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [reading, setReading] = createSignal(true)
|
||||||
|
const [accounts, setAccounts] = createSignal<TdataAccount[]>([])
|
||||||
|
const [error, setError] = createSignal<string | undefined>('I like penis')
|
||||||
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
|
||||||
|
const accountExists = (id: number) => $accounts.get().some(it => it.telegramId === id)
|
||||||
|
|
||||||
|
let abortController: AbortController | undefined
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
abortController?.abort()
|
||||||
|
abortController = new AbortController()
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const errors: string[] = []
|
||||||
|
for (const account of accounts()) {
|
||||||
|
if (!account.toImport) continue
|
||||||
|
try {
|
||||||
|
await workerInvoke('telegram', 'importAuthKey', {
|
||||||
|
// todo: idk if there is a point in using hex here
|
||||||
|
hexAuthKey: hex.encode(account.authKey),
|
||||||
|
dcId: account.dcId,
|
||||||
|
testMode: false,
|
||||||
|
abortSignal: abortController.signal,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
errors.push(`Failed to import ${account.telegramId}: ${e.message}`)
|
||||||
|
} else {
|
||||||
|
console.error(e)
|
||||||
|
errors.push('Unknown error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
setError(errors.join('\n'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(undefined)
|
||||||
|
setLoading(false)
|
||||||
|
props.onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(on(() => props.open, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
abortController?.abort()
|
||||||
|
setLoading(false)
|
||||||
|
abortController = undefined
|
||||||
|
setError(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('showDirectoryPicker' in window)) {
|
||||||
|
return props.onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
setReading(true)
|
||||||
|
const handle = await (window as any).showDirectoryPicker({
|
||||||
|
id: 'mtcute-repl-tdata-import',
|
||||||
|
mode: 'read',
|
||||||
|
startIn: 'documents',
|
||||||
|
}).catch((e: any) => {
|
||||||
|
if (!(e instanceof DOMException && e.name === 'AbortError')) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!handle) return props.onClose()
|
||||||
|
|
||||||
|
const { Tdata, WebFsInterface, WebExtCryptoProvider } = await import('./tdata-web.ts')
|
||||||
|
const tdata = await Tdata.open({
|
||||||
|
path: '',
|
||||||
|
fs: new WebFsInterface(handle),
|
||||||
|
crypto: new WebExtCryptoProvider(),
|
||||||
|
ignoreVersion: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const keyData = await tdata.readKeyData()
|
||||||
|
const accounts: TdataAccount[] = []
|
||||||
|
for (const idx of keyData.order) {
|
||||||
|
const mtp = await tdata.readMtpAuthorization(idx)
|
||||||
|
accounts.push({
|
||||||
|
telegramId: mtp.userId.toNumber(),
|
||||||
|
index: idx,
|
||||||
|
authKey: mtp.authKeys.find(it => it.dcId === mtp.mainDcId)!.key,
|
||||||
|
dcId: mtp.mainDcId,
|
||||||
|
toImport: !accountExists(mtp.userId.toNumber()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setAccounts(accounts)
|
||||||
|
setReading(false)
|
||||||
|
})().catch((e) => {
|
||||||
|
setReading(false)
|
||||||
|
if (e instanceof Error) {
|
||||||
|
setError(e.message)
|
||||||
|
} else {
|
||||||
|
console.error(e)
|
||||||
|
setError('Unknown error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={props.open}
|
||||||
|
onOpenChange={open => !open && props.onClose()}
|
||||||
|
>
|
||||||
|
<DialogContent class="max-w-[400px] gap-2">
|
||||||
|
<DialogHeader>
|
||||||
|
{reading() || error() ? 'Import tdata' : (
|
||||||
|
<>
|
||||||
|
Found
|
||||||
|
{' '}
|
||||||
|
{accounts().length}
|
||||||
|
{' '}
|
||||||
|
account
|
||||||
|
{accounts().length === 1 ? '' : 's'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogDescription>
|
||||||
|
<Show
|
||||||
|
when={!reading()}
|
||||||
|
fallback={(
|
||||||
|
<div class="flex w-full items-center justify-center">
|
||||||
|
<Spinner indeterminate class="m-4 size-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<For each={accounts()}>
|
||||||
|
{account => (
|
||||||
|
<Checkbox
|
||||||
|
class="ml-1 flex flex-row items-center gap-2"
|
||||||
|
checked={account.toImport}
|
||||||
|
onChange={checked => setAccounts(
|
||||||
|
accounts().map(it => it.index === account.index ? {
|
||||||
|
...it,
|
||||||
|
toImport: checked,
|
||||||
|
} : it),
|
||||||
|
)}
|
||||||
|
disabled={accountExists(account.telegramId)}
|
||||||
|
>
|
||||||
|
<CheckboxControl />
|
||||||
|
<CheckboxLabel class="flex items-center gap-1 text-sm">
|
||||||
|
<div class="text-foreground">
|
||||||
|
ID
|
||||||
|
{' '}
|
||||||
|
{account.telegramId}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
(DC
|
||||||
|
{' '}
|
||||||
|
{account.dcId}
|
||||||
|
, index
|
||||||
|
{' '}
|
||||||
|
{account.index}
|
||||||
|
)
|
||||||
|
</div>
|
||||||
|
</CheckboxLabel>
|
||||||
|
</Checkbox>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
{error() && (
|
||||||
|
<div class="text-sm text-error-foreground">
|
||||||
|
{error()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
class="mt-4 w-full"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading() || reading() || accounts().filter(it => it.toImport).length === 0}
|
||||||
|
>
|
||||||
|
{loading() ? 'Checking...' : 'Import'}
|
||||||
|
</Button>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
// separate file containing everything needed to read tdata in the browser,
|
||||||
|
// so that we can lazily load it
|
||||||
|
import type { INodeFsLike } from '@mtcute/convert'
|
||||||
|
import { Bytes } from '@fuman/io'
|
||||||
|
import { WebCryptoProvider } from '@mtcute/web'
|
||||||
|
import md5 from 'md5'
|
||||||
|
|
||||||
|
export { Tdata } from '@mtcute/convert'
|
||||||
|
|
||||||
|
export class WebFsInterface implements INodeFsLike {
|
||||||
|
constructor(readonly root: FileSystemDirectoryHandle) {}
|
||||||
|
|
||||||
|
async readFile(path: string): Promise<Uint8Array> {
|
||||||
|
path = path.replace(/^\//, '')
|
||||||
|
const fileHandle = await this.root.getFileHandle(path, { create: false })
|
||||||
|
const file = await fileHandle.getFile()
|
||||||
|
return new Uint8Array(await file.arrayBuffer())
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(): Promise<void> {
|
||||||
|
throw new Error('Not implemented')
|
||||||
|
}
|
||||||
|
|
||||||
|
async mkdir(): Promise<void> {
|
||||||
|
throw new Error('Not implemented')
|
||||||
|
}
|
||||||
|
|
||||||
|
async stat(path: string): Promise<{ size: number, lastModified: number }> {
|
||||||
|
path = path.replace(/^\//, '')
|
||||||
|
const fileHandle = await this.root.getFileHandle(path, { create: false })
|
||||||
|
const file = await fileHandle.getFile()
|
||||||
|
return {
|
||||||
|
size: file.size,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebExtCryptoProvider extends WebCryptoProvider {
|
||||||
|
async createHash(algorithm: 'md5' | 'sha512') {
|
||||||
|
const buf = Bytes.alloc()
|
||||||
|
return {
|
||||||
|
update(data: Uint8Array) {
|
||||||
|
buf.writeSync(data.length).set(data)
|
||||||
|
},
|
||||||
|
async digest() {
|
||||||
|
if (algorithm === 'md5') {
|
||||||
|
const hash = md5(buf.result(), { asBytes: true })
|
||||||
|
return new Uint8Array(hash)
|
||||||
|
} else {
|
||||||
|
return new Uint8Array(await crypto.subtle.digest('SHA-512', buf.result()))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
15
packages/repl/src/lib/clipboard.tsx
Normal file
15
packages/repl/src/lib/clipboard.tsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
export function copyToClipboard(text: string) {
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
} else {
|
||||||
|
const el = document.createElement('textarea')
|
||||||
|
el.value = text
|
||||||
|
el.setAttribute('readonly', '')
|
||||||
|
el.style.position = 'absolute'
|
||||||
|
el.style.left = '-9999px'
|
||||||
|
document.body.appendChild(el)
|
||||||
|
el.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(el)
|
||||||
|
}
|
||||||
|
}
|
73
packages/repl/src/lib/components/ui/popover.tsx
Normal file
73
packages/repl/src/lib/components/ui/popover.tsx
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
|
||||||
|
import type {
|
||||||
|
PopoverContentProps,
|
||||||
|
PopoverRootProps,
|
||||||
|
} from '@kobalte/core/popover'
|
||||||
|
import type { ParentProps, ValidComponent } from 'solid-js'
|
||||||
|
import { Popover as PopoverPrimitive } from '@kobalte/core/popover'
|
||||||
|
import { mergeProps, splitProps } from 'solid-js'
|
||||||
|
import { cn } from '../../utils.ts'
|
||||||
|
|
||||||
|
export const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
export const PopoverTitle = PopoverPrimitive.Title
|
||||||
|
export const PopoverDescription = PopoverPrimitive.Description
|
||||||
|
|
||||||
|
export function Popover(props: PopoverRootProps) {
|
||||||
|
const merge = mergeProps<PopoverRootProps[]>(
|
||||||
|
{
|
||||||
|
gutter: 4,
|
||||||
|
flip: false,
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
)
|
||||||
|
|
||||||
|
return <PopoverPrimitive {...merge} />
|
||||||
|
}
|
||||||
|
|
||||||
|
type popoverContentProps<T extends ValidComponent = 'div'> = ParentProps<
|
||||||
|
PopoverContentProps<T> & {
|
||||||
|
class?: string
|
||||||
|
withoutCloseButton?: boolean
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
export function PopoverContent<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, popoverContentProps<T>>) {
|
||||||
|
const [local, rest] = splitProps(props as popoverContentProps, [
|
||||||
|
'class',
|
||||||
|
'children',
|
||||||
|
'withoutCloseButton',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
class={cn(
|
||||||
|
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{local.children}
|
||||||
|
{!props.withoutCloseButton && (
|
||||||
|
<PopoverPrimitive.CloseButton class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-[opacity,box-shadow] hover:opacity-100 focus:outline-none focus:ring-[1.5px] focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="size-4"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M18 6L6 18M6 6l12 12"
|
||||||
|
/>
|
||||||
|
<title>Close</title>
|
||||||
|
</svg>
|
||||||
|
</PopoverPrimitive.CloseButton>
|
||||||
|
)}
|
||||||
|
</PopoverPrimitive.Content>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
21
packages/repl/src/lib/components/ui/sonner.tsx
Normal file
21
packages/repl/src/lib/components/ui/sonner.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { Toaster as Sonner } from 'solid-sonner'
|
||||||
|
|
||||||
|
export function Toaster(props: Parameters<typeof Sonner>[0]) {
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
class="toaster group"
|
||||||
|
toastOptions={{
|
||||||
|
classes: {
|
||||||
|
toast:
|
||||||
|
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||||
|
description: 'group-[.toast]:text-muted-foreground',
|
||||||
|
actionButton:
|
||||||
|
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||||
|
cancelButton:
|
||||||
|
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -3,8 +3,9 @@ import type {
|
||||||
TooltipContentProps,
|
TooltipContentProps,
|
||||||
TooltipRootProps,
|
TooltipRootProps,
|
||||||
} from '@kobalte/core/tooltip'
|
} from '@kobalte/core/tooltip'
|
||||||
|
import type { ComponentProps, JSX, ValidComponent } from 'solid-js'
|
||||||
import { Tooltip as TooltipPrimitive } from '@kobalte/core/tooltip'
|
import { Tooltip as TooltipPrimitive } from '@kobalte/core/tooltip'
|
||||||
import { mergeProps, splitProps, type ValidComponent } from 'solid-js'
|
import { mergeProps, Show, splitProps } from 'solid-js'
|
||||||
import { cn } from '../../utils.ts'
|
import { cn } from '../../utils.ts'
|
||||||
|
|
||||||
export const TooltipTrigger = TooltipPrimitive.Trigger
|
export const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
@ -13,6 +14,8 @@ export function Tooltip(props: TooltipRootProps) {
|
||||||
const merge = mergeProps<TooltipRootProps[]>(
|
const merge = mergeProps<TooltipRootProps[]>(
|
||||||
{
|
{
|
||||||
gutter: 4,
|
gutter: 4,
|
||||||
|
openDelay: 200,
|
||||||
|
closeDelay: 200,
|
||||||
flip: false,
|
flip: false,
|
||||||
},
|
},
|
||||||
props,
|
props,
|
||||||
|
@ -41,3 +44,22 @@ export function TooltipContent<T extends ValidComponent = 'div'>(props: Polymorp
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function WithTooltip(props: {
|
||||||
|
children: (props: ComponentProps<typeof TooltipTrigger>) => JSX.Element
|
||||||
|
content: JSX.Element
|
||||||
|
rootProps?: TooltipRootProps
|
||||||
|
enabled?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={props.enabled ?? true}
|
||||||
|
fallback={props.children({})}
|
||||||
|
>
|
||||||
|
<Tooltip {...props.rootProps}>
|
||||||
|
<TooltipTrigger as={props.children} />
|
||||||
|
<TooltipContent>{props.content}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -12,6 +12,9 @@ export default defineConfig((env): UserConfig => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['@mtcute/wasm'],
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
},
|
},
|
||||||
|
|
|
@ -4,6 +4,7 @@ import type { ReplWorkerEvents } from './worker/utils.ts'
|
||||||
import { Deferred, unknownToError } from '@fuman/utils'
|
import { Deferred, unknownToError } from '@fuman/utils'
|
||||||
|
|
||||||
export type { TelegramAccount } from './store/accounts.ts'
|
export type { TelegramAccount } from './store/accounts.ts'
|
||||||
|
export type { StringSessionLibName } from './worker/telegram.ts'
|
||||||
|
|
||||||
// eslint-disable-next-line ts/no-namespace
|
// eslint-disable-next-line ts/no-namespace
|
||||||
export namespace mtcute {
|
export namespace mtcute {
|
||||||
|
@ -56,6 +57,7 @@ export function workerInit(iframe_: HTMLIFrameElement) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ForceFunction<T> = T extends (...args: any) => any ? T : never
|
type ForceFunction<T> = T extends (...args: any) => any ? T : never
|
||||||
|
type Awaited<T> = T extends Promise<infer U> ? U : T
|
||||||
|
|
||||||
export async function workerInvoke<
|
export async function workerInvoke<
|
||||||
Domain extends keyof ReplWorker,
|
Domain extends keyof ReplWorker,
|
||||||
|
@ -64,7 +66,7 @@ export async function workerInvoke<
|
||||||
domain: Domain,
|
domain: Domain,
|
||||||
method: Method,
|
method: Method,
|
||||||
...params: Parameters<ForceFunction<ReplWorker[Domain][Method]>> extends [infer Params] ? [Params] : []
|
...params: Parameters<ForceFunction<ReplWorker[Domain][Method]>> extends [infer Params] ? [Params] : []
|
||||||
): Promise<ReturnType<ForceFunction<ReplWorker[Domain][Method]>>>
|
): Promise<Awaited<ReturnType<ForceFunction<ReplWorker[Domain][Method]>>>>
|
||||||
|
|
||||||
export async function workerInvoke(domain: string, method: string, params?: any) {
|
export async function workerInvoke(domain: string, method: string, params?: any) {
|
||||||
if (loadedDeferred) {
|
if (loadedDeferred) {
|
||||||
|
|
|
@ -6,13 +6,18 @@ import { getCacheStorage } from './cache.ts'
|
||||||
|
|
||||||
const clients = new Map<string, BaseTelegramClient>()
|
const clients = new Map<string, BaseTelegramClient>()
|
||||||
|
|
||||||
|
export async function clearAvatarCache(accountId: string) {
|
||||||
|
const cacheKey = new URL(`/sw/avatar/${accountId}`, location.origin)
|
||||||
|
await (await getCacheStorage()).delete(cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleAvatarRequest(accountId: string) {
|
export async function handleAvatarRequest(accountId: string) {
|
||||||
const cacheKey = new URL(`/sw/avatar/${accountId}`, location.origin)
|
const cacheKey = new URL(`/sw/avatar/${accountId}`, location.origin)
|
||||||
|
|
||||||
const cache = await getCacheStorage()
|
const cache = await getCacheStorage()
|
||||||
try {
|
try {
|
||||||
const cachedRes = await timeout(cache.match(cacheKey), 10000)
|
const cachedRes = await timeout(cache.match(cacheKey), 10000)
|
||||||
if (cachedRes && cachedRes.ok) {
|
if (cachedRes) {
|
||||||
return cachedRes
|
return cachedRes
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
@ -27,7 +32,10 @@ export async function handleAvatarRequest(accountId: string) {
|
||||||
const self = await getMe(client)
|
const self = await getMe(client)
|
||||||
|
|
||||||
if (!self.photo) {
|
if (!self.photo) {
|
||||||
return new Response('No photo', { status: 404 })
|
const res = new Response('No photo', { status: 404 })
|
||||||
|
await client.close()
|
||||||
|
await cache.put(cacheKey, res.clone())
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
const buf = await downloadAsBuffer(client, self.photo.big)
|
const buf = await downloadAsBuffer(client, self.photo.big)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { unknownToError } from '@fuman/utils'
|
import { unknownToError } from '@fuman/utils'
|
||||||
import { IS_SAFARI } from '../utils/env.ts'
|
import { IS_SAFARI } from '../utils/env.ts'
|
||||||
import { handleAvatarRequest } from './avatar.ts'
|
import { clearAvatarCache, handleAvatarRequest } from './avatar.ts'
|
||||||
import { requestCache } from './cache.ts'
|
import { requestCache } from './cache.ts'
|
||||||
import { clearCache, forgetScript, handleRuntimeRequest, uploadScript } from './runtime.ts'
|
import { clearCache, forgetScript, handleRuntimeRequest, uploadScript } from './runtime.ts'
|
||||||
|
|
||||||
|
@ -55,6 +55,7 @@ self.onoffline = self.ononline = () => {
|
||||||
export type SwMessage =
|
export type SwMessage =
|
||||||
| { event: 'UPLOAD_SCRIPT', name: string, files: Record<string, string> }
|
| { event: 'UPLOAD_SCRIPT', name: string, files: Record<string, string> }
|
||||||
| { event: 'FORGET_SCRIPT', name: string }
|
| { event: 'FORGET_SCRIPT', name: string }
|
||||||
|
| { event: 'CLEAR_AVATAR_CACHE', accountId: string }
|
||||||
| { event: 'CLEAR_CACHE' }
|
| { event: 'CLEAR_CACHE' }
|
||||||
|
|
||||||
function handleMessage(msg: SwMessage) {
|
function handleMessage(msg: SwMessage) {
|
||||||
|
@ -71,6 +72,10 @@ function handleMessage(msg: SwMessage) {
|
||||||
clearCache()
|
clearCache()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'CLEAR_AVATAR_CACHE': {
|
||||||
|
clearAvatarCache(msg.accountId)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -48,6 +48,8 @@ export async function importAccount(
|
||||||
const self = await getMe(client)
|
const self = await getMe(client)
|
||||||
if (abortSignal.aborted) throw abortSignal.reason
|
if (abortSignal.aborted) throw abortSignal.reason
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: accountId,
|
id: accountId,
|
||||||
name: self.displayName,
|
name: self.displayName,
|
||||||
|
|
|
@ -1,13 +1,25 @@
|
||||||
import type { BaseTelegramClient, SentCode, User } from '@mtcute/web'
|
import type { BaseTelegramClient, SentCode, User } from '@mtcute/web'
|
||||||
|
import type { StringSessionData } from '@mtcute/web/utils.js'
|
||||||
import type { TelegramAccount } from '../store/accounts.ts'
|
import type { TelegramAccount } from '../store/accounts.ts'
|
||||||
import { assert } from '@fuman/utils'
|
import { assert, hex } from '@fuman/utils'
|
||||||
|
import { DC_MAPPING_PROD, DC_MAPPING_TEST } from '@mtcute/convert'
|
||||||
import { tl } from '@mtcute/web'
|
import { tl } from '@mtcute/web'
|
||||||
import { checkPassword, resendCode, sendCode, signIn, signInQr } from '@mtcute/web/methods.js'
|
import { checkPassword, getMe, resendCode, sendCode, signIn, signInBot, signInQr } from '@mtcute/web/methods.js'
|
||||||
|
import { readStringSession } from '@mtcute/web/utils.js'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
import { renderSVG } from 'uqr'
|
import { renderSVG } from 'uqr'
|
||||||
import { $accounts, $activeAccountId } from '../store/accounts.ts'
|
import { $accounts, $activeAccountId } from '../store/accounts.ts'
|
||||||
import { createInternalClient, deleteAccount } from '../utils/telegram.ts'
|
import { swInvokeMethod } from '../sw/client.ts'
|
||||||
|
import { createInternalClient, deleteAccount, importAccount } from '../utils/telegram.ts'
|
||||||
import { emitEvent } from './utils.ts'
|
import { emitEvent } from './utils.ts'
|
||||||
|
|
||||||
|
export type StringSessionLibName =
|
||||||
|
| 'mtcute'
|
||||||
|
| 'pyrogram'
|
||||||
|
| 'telethon'
|
||||||
|
| 'mtkruto'
|
||||||
|
| 'gramjs'
|
||||||
|
|
||||||
const clients = new Map<string, BaseTelegramClient>()
|
const clients = new Map<string, BaseTelegramClient>()
|
||||||
function getClient(accountId: string) {
|
function getClient(accountId: string) {
|
||||||
const client = clients.get(accountId)
|
const client = clients.get(accountId)
|
||||||
|
@ -15,12 +27,27 @@ function getClient(accountId: string) {
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTmpClient(accountId: string): [BaseTelegramClient, () => Promise<void>] {
|
||||||
|
const client = clients.get(accountId)
|
||||||
|
if (!client) {
|
||||||
|
const tmpClient = createInternalClient(accountId)
|
||||||
|
return [tmpClient, () => tmpClient.close()]
|
||||||
|
} else {
|
||||||
|
return [client, () => Promise.resolve()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAuthSuccess(accountId: string, user: User) {
|
async function handleAuthSuccess(accountId: string, user: User) {
|
||||||
const client = getClient(accountId)
|
const client = getClient(accountId)
|
||||||
const dcs = await client.mt.storage.dcs.fetch()
|
const dcs = await client.mt.storage.dcs.fetch()
|
||||||
const dcId = dcs?.main.id ?? 2
|
const dcId = dcs?.main.id ?? 2
|
||||||
const testMode = client.params.testMode ?? false
|
const testMode = client.params.testMode ?? false
|
||||||
|
|
||||||
|
if ($accounts.get().some(it => it.telegramId === user.id)) {
|
||||||
|
await deleteAccount(accountId)
|
||||||
|
throw new Error(`Account already exists (user ID: ${user.id})`)
|
||||||
|
}
|
||||||
|
|
||||||
const account: TelegramAccount = {
|
const account: TelegramAccount = {
|
||||||
id: accountId,
|
id: accountId,
|
||||||
name: user.displayName,
|
name: user.displayName,
|
||||||
|
@ -35,6 +62,8 @@ async function handleAuthSuccess(accountId: string, user: User) {
|
||||||
])
|
])
|
||||||
$activeAccountId.set(accountId)
|
$activeAccountId.set(accountId)
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
return account
|
return account
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -194,4 +223,192 @@ export class ReplWorkerTelegram {
|
||||||
return new Uint8Array(await res.arrayBuffer())
|
return new Uint8Array(await res.arrayBuffer())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async importAuthKey(params: {
|
||||||
|
hexAuthKey: string
|
||||||
|
dcId: number
|
||||||
|
testMode: boolean
|
||||||
|
abortSignal: AbortSignal
|
||||||
|
}) {
|
||||||
|
const { hexAuthKey, dcId, testMode, abortSignal } = params
|
||||||
|
|
||||||
|
const authKey = hex.decode(hexAuthKey)
|
||||||
|
if (authKey.length !== 256) {
|
||||||
|
throw new Error('Invalid auth key (must be 256 bytes long)')
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await importAccount({
|
||||||
|
authKey,
|
||||||
|
testMode,
|
||||||
|
primaryDcs: (testMode ? DC_MAPPING_TEST : DC_MAPPING_PROD)[dcId],
|
||||||
|
}, abortSignal)
|
||||||
|
|
||||||
|
if ($accounts.get().some(it => it.telegramId === account.telegramId)) {
|
||||||
|
await deleteAccount(account.id)
|
||||||
|
throw new Error(`Account already exists (user ID: ${account.telegramId})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
$accounts.set([
|
||||||
|
...$accounts.get(),
|
||||||
|
account,
|
||||||
|
])
|
||||||
|
$activeAccountId.set(account.id)
|
||||||
|
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
async importStringSession(params: {
|
||||||
|
libraryName: StringSessionLibName
|
||||||
|
session: string
|
||||||
|
abortSignal: AbortSignal
|
||||||
|
}) {
|
||||||
|
let session: StringSessionData
|
||||||
|
switch (params.libraryName) {
|
||||||
|
case 'mtcute': {
|
||||||
|
session = readStringSession(params.session)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'telethon': {
|
||||||
|
const { convertFromTelethonSession } = await import('@mtcute/convert')
|
||||||
|
session = convertFromTelethonSession(params.session)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'gramjs': {
|
||||||
|
const { convertFromGramjsSession } = await import('@mtcute/convert')
|
||||||
|
session = convertFromGramjsSession(params.session)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'pyrogram': {
|
||||||
|
const { convertFromPyrogramSession } = await import('@mtcute/convert')
|
||||||
|
session = convertFromPyrogramSession(params.session)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'mtkruto': {
|
||||||
|
const { convertFromMtkrutoSession } = await import('@mtcute/convert')
|
||||||
|
session = convertFromMtkrutoSession(params.session)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.self && $accounts.get().some(it => it.telegramId === session.self!.userId)) {
|
||||||
|
throw new Error(`Account already exists (user ID: ${session.self.userId})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await importAccount(session, params.abortSignal)
|
||||||
|
|
||||||
|
// check if account already exists once again
|
||||||
|
if ($accounts.get().some(it => it.telegramId === account.telegramId)) {
|
||||||
|
await deleteAccount(account.id)
|
||||||
|
throw new Error(`Account already exists (user ID: ${account.telegramId})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
$accounts.set([
|
||||||
|
...$accounts.get(),
|
||||||
|
account,
|
||||||
|
])
|
||||||
|
$activeAccountId.set(account.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async importBotToken(params: {
|
||||||
|
botToken: string
|
||||||
|
abortSignal: AbortSignal
|
||||||
|
}) {
|
||||||
|
// todo abort signal
|
||||||
|
const { botToken } = params
|
||||||
|
|
||||||
|
const accountId = nanoid()
|
||||||
|
const client = createInternalClient(accountId)
|
||||||
|
clients.set(accountId, client)
|
||||||
|
const self = await signInBot(client, botToken)
|
||||||
|
|
||||||
|
return await handleAuthSuccess(accountId, self)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccount(params: {
|
||||||
|
accountId: string
|
||||||
|
}) {
|
||||||
|
let client = clients.get(params.accountId)
|
||||||
|
if (!client) {
|
||||||
|
client = createInternalClient(params.accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredAccounts = $accounts.get().filter(it => it.id !== params.accountId)
|
||||||
|
// NB: we change active account first to make sure the runner iframe terminates
|
||||||
|
if ($activeAccountId.get() === params.accountId) {
|
||||||
|
$activeAccountId.set(filteredAccounts[0]?.id ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
clients.delete(params.accountId)
|
||||||
|
await deleteAccount(params.accountId)
|
||||||
|
|
||||||
|
$accounts.set(filteredAccounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportStringSession(params: {
|
||||||
|
accountId: string
|
||||||
|
libraryName: StringSessionLibName
|
||||||
|
}): Promise<string> {
|
||||||
|
const { accountId, libraryName } = params
|
||||||
|
|
||||||
|
const [client, cleanup] = getTmpClient(accountId)
|
||||||
|
|
||||||
|
const session = await client.exportSession()
|
||||||
|
await cleanup()
|
||||||
|
|
||||||
|
let res: string
|
||||||
|
switch (libraryName) {
|
||||||
|
case 'mtcute': {
|
||||||
|
res = session
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'telethon': {
|
||||||
|
const { convertToTelethonSession } = await import('@mtcute/convert')
|
||||||
|
res = convertToTelethonSession(session)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'gramjs': {
|
||||||
|
const { convertToGramjsSession } = await import('@mtcute/convert')
|
||||||
|
res = convertToGramjsSession(session)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'pyrogram': {
|
||||||
|
const { convertToPyrogramSession } = await import('@mtcute/convert')
|
||||||
|
res = convertToPyrogramSession(session)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'mtkruto': {
|
||||||
|
const { convertToMtkrutoSession } = await import('@mtcute/convert')
|
||||||
|
res = convertToMtkrutoSession(session)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateInfo(params: {
|
||||||
|
accountId: string
|
||||||
|
}) {
|
||||||
|
const { accountId } = params
|
||||||
|
|
||||||
|
const [client, cleanup] = getTmpClient(accountId)
|
||||||
|
const self = await getMe(client)
|
||||||
|
await cleanup()
|
||||||
|
|
||||||
|
await swInvokeMethod({ event: 'CLEAR_AVATAR_CACHE', accountId })
|
||||||
|
|
||||||
|
$accounts.set($accounts.get().map((it) => {
|
||||||
|
if (it.id === accountId) {
|
||||||
|
return {
|
||||||
|
...it,
|
||||||
|
name: self.displayName,
|
||||||
|
telegramId: self.id,
|
||||||
|
bot: self.isBot,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,12 +62,21 @@ importers:
|
||||||
'@corvu/resizable':
|
'@corvu/resizable':
|
||||||
specifier: ^0.2.3
|
specifier: ^0.2.3
|
||||||
version: 0.2.3(solid-js@1.9.4)
|
version: 0.2.3(solid-js@1.9.4)
|
||||||
|
'@fuman/io':
|
||||||
|
specifier: 0.0.8
|
||||||
|
version: 0.0.8
|
||||||
'@fuman/utils':
|
'@fuman/utils':
|
||||||
specifier: 0.0.4
|
specifier: 0.0.4
|
||||||
version: 0.0.4
|
version: 0.0.4
|
||||||
'@kobalte/core':
|
'@kobalte/core':
|
||||||
specifier: ^0.13.7
|
specifier: ^0.13.7
|
||||||
version: 0.13.7(solid-js@1.9.4)
|
version: 0.13.7(solid-js@1.9.4)
|
||||||
|
'@mtcute/convert':
|
||||||
|
specifier: ^0.19.4
|
||||||
|
version: 0.19.4
|
||||||
|
'@mtcute/web':
|
||||||
|
specifier: ^0.19.5
|
||||||
|
version: 0.19.5
|
||||||
'@nanostores/persistent':
|
'@nanostores/persistent':
|
||||||
specifier: ^0.10.2
|
specifier: ^0.10.2
|
||||||
version: 0.10.2(nanostores@0.11.3)
|
version: 0.10.2(nanostores@0.11.3)
|
||||||
|
@ -83,6 +92,9 @@ importers:
|
||||||
lucide-solid:
|
lucide-solid:
|
||||||
specifier: ^0.445.0
|
specifier: ^0.445.0
|
||||||
version: 0.445.0(solid-js@1.9.4)
|
version: 0.445.0(solid-js@1.9.4)
|
||||||
|
md5:
|
||||||
|
specifier: ^2.3.0
|
||||||
|
version: 2.3.0
|
||||||
monaco-editor:
|
monaco-editor:
|
||||||
specifier: 0.52.0
|
specifier: 0.52.0
|
||||||
version: 0.52.0
|
version: 0.52.0
|
||||||
|
@ -113,6 +125,9 @@ importers:
|
||||||
solid-js:
|
solid-js:
|
||||||
specifier: ^1.9.4
|
specifier: ^1.9.4
|
||||||
version: 1.9.4
|
version: 1.9.4
|
||||||
|
solid-sonner:
|
||||||
|
specifier: ^0.2.8
|
||||||
|
version: 0.2.8(solid-js@1.9.4)
|
||||||
solid-transition-group:
|
solid-transition-group:
|
||||||
specifier: ^0.2.3
|
specifier: ^0.2.3
|
||||||
version: 0.2.3(solid-js@1.9.4)
|
version: 0.2.3(solid-js@1.9.4)
|
||||||
|
@ -120,6 +135,9 @@ importers:
|
||||||
specifier: ^0.4.4
|
specifier: ^0.4.4
|
||||||
version: 0.4.4
|
version: 0.4.4
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/md5':
|
||||||
|
specifier: ^2.3.5
|
||||||
|
version: 2.3.5
|
||||||
postcss:
|
postcss:
|
||||||
specifier: ^8.4.49
|
specifier: ^8.4.49
|
||||||
version: 8.4.49
|
version: 8.4.49
|
||||||
|
@ -778,6 +796,9 @@ packages:
|
||||||
'@mtcute/core@0.19.6':
|
'@mtcute/core@0.19.6':
|
||||||
resolution: {integrity: sha512-cBm8kdZglHm40nksF2wXn5RkKzlyo+9iJKi/mTk2fRRfCBgUSuP5f7l5OqA7HaJcwVRtbB46k9BLKs2H/jqhZg==}
|
resolution: {integrity: sha512-cBm8kdZglHm40nksF2wXn5RkKzlyo+9iJKi/mTk2fRRfCBgUSuP5f7l5OqA7HaJcwVRtbB46k9BLKs2H/jqhZg==}
|
||||||
|
|
||||||
|
'@mtcute/core@0.19.7':
|
||||||
|
resolution: {integrity: sha512-UX2AbXrA/rkOeEnJyImu4P3FeJr+dmjqE0GFY0QmbRcKCkzwYBAmEZ/LrzwEj1cajcSwn9dZkrjkNKs/XLhuRg==}
|
||||||
|
|
||||||
'@mtcute/file-id@0.19.0':
|
'@mtcute/file-id@0.19.0':
|
||||||
resolution: {integrity: sha512-r9r5JxchoVtYYMLPsf/wSnd5+KB4KmilWHywKQenf0DgKD+LCEN2FJpzY44RFE5dpy+eV5OHZ85zxA6EyYz/mA==}
|
resolution: {integrity: sha512-r9r5JxchoVtYYMLPsf/wSnd5+KB4KmilWHywKQenf0DgKD+LCEN2FJpzY44RFE5dpy+eV5OHZ85zxA6EyYz/mA==}
|
||||||
|
|
||||||
|
@ -1013,6 +1034,9 @@ packages:
|
||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/md5@2.3.5':
|
||||||
|
resolution: {integrity: sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw==}
|
||||||
|
|
||||||
'@types/mdast@4.0.4':
|
'@types/mdast@4.0.4':
|
||||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||||
|
|
||||||
|
@ -1219,6 +1243,9 @@ packages:
|
||||||
character-entities@2.0.2:
|
character-entities@2.0.2:
|
||||||
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
|
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
|
||||||
|
|
||||||
|
charenc@0.0.2:
|
||||||
|
resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||||
engines: {node: '>= 8.10.0'}
|
engines: {node: '>= 8.10.0'}
|
||||||
|
@ -1277,6 +1304,9 @@ packages:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
crypt@0.0.2:
|
||||||
|
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
|
||||||
|
|
||||||
cssesc@3.0.0:
|
cssesc@3.0.0:
|
||||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
@ -1741,6 +1771,9 @@ packages:
|
||||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
is-buffer@1.1.6:
|
||||||
|
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
|
||||||
|
|
||||||
is-builtin-module@3.2.1:
|
is-builtin-module@3.2.1:
|
||||||
resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==}
|
resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
@ -1892,6 +1925,9 @@ packages:
|
||||||
markdown-table@3.0.4:
|
markdown-table@3.0.4:
|
||||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||||
|
|
||||||
|
md5@2.3.0:
|
||||||
|
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
|
||||||
|
|
||||||
mdast-util-find-and-replace@3.0.2:
|
mdast-util-find-and-replace@3.0.2:
|
||||||
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
|
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
|
||||||
|
|
||||||
|
@ -2396,6 +2432,11 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
solid-js: ^1.3
|
solid-js: ^1.3
|
||||||
|
|
||||||
|
solid-sonner@0.2.8:
|
||||||
|
resolution: {integrity: sha512-EQ2EIznvHHpAmkYh2CTu0AdCgmPJRJWLGFRWygE8j+vMEfvIV2wotHU5qgWzqzVTG1SODGsay2Lwq6ENWx/rPA==}
|
||||||
|
peerDependencies:
|
||||||
|
solid-js: ^1.6.0
|
||||||
|
|
||||||
solid-transition-group@0.2.3:
|
solid-transition-group@0.2.3:
|
||||||
resolution: {integrity: sha512-iB72c9N5Kz9ykRqIXl0lQohOau4t0dhel9kjwFvx81UZJbVwaChMuBuyhiZmK24b8aKEK0w3uFM96ZxzcyZGdg==}
|
resolution: {integrity: sha512-iB72c9N5Kz9ykRqIXl0lQohOau4t0dhel9kjwFvx81UZJbVwaChMuBuyhiZmK24b8aKEK0w3uFM96ZxzcyZGdg==}
|
||||||
engines: {node: '>=18.0.0', pnpm: '>=8.6.0'}
|
engines: {node: '>=18.0.0', pnpm: '>=8.6.0'}
|
||||||
|
@ -3226,7 +3267,7 @@ snapshots:
|
||||||
'@fuman/io': 0.0.8
|
'@fuman/io': 0.0.8
|
||||||
'@fuman/net': 0.0.9
|
'@fuman/net': 0.0.9
|
||||||
'@fuman/utils': 0.0.4
|
'@fuman/utils': 0.0.4
|
||||||
'@mtcute/core': 0.19.6
|
'@mtcute/core': 0.19.7
|
||||||
|
|
||||||
'@mtcute/core@0.19.6':
|
'@mtcute/core@0.19.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -3239,6 +3280,17 @@ snapshots:
|
||||||
'@types/events': 3.0.0
|
'@types/events': 3.0.0
|
||||||
long: 5.2.3
|
long: 5.2.3
|
||||||
|
|
||||||
|
'@mtcute/core@0.19.7':
|
||||||
|
dependencies:
|
||||||
|
'@fuman/io': 0.0.8
|
||||||
|
'@fuman/net': 0.0.9
|
||||||
|
'@fuman/utils': 0.0.4
|
||||||
|
'@mtcute/file-id': 0.19.0
|
||||||
|
'@mtcute/tl': 196.0.0
|
||||||
|
'@mtcute/tl-runtime': 0.19.0
|
||||||
|
'@types/events': 3.0.0
|
||||||
|
long: 5.2.3
|
||||||
|
|
||||||
'@mtcute/file-id@0.19.0':
|
'@mtcute/file-id@0.19.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fuman/utils': 0.0.4
|
'@fuman/utils': 0.0.4
|
||||||
|
@ -3457,6 +3509,8 @@ snapshots:
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
|
'@types/md5@2.3.5': {}
|
||||||
|
|
||||||
'@types/mdast@4.0.4':
|
'@types/mdast@4.0.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
|
@ -3693,6 +3747,8 @@ snapshots:
|
||||||
|
|
||||||
character-entities@2.0.2: {}
|
character-entities@2.0.2: {}
|
||||||
|
|
||||||
|
charenc@0.0.2: {}
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
anymatch: 3.1.3
|
anymatch: 3.1.3
|
||||||
|
@ -3751,6 +3807,8 @@ snapshots:
|
||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
crypt@0.0.2: {}
|
||||||
|
|
||||||
cssesc@3.0.0: {}
|
cssesc@3.0.0: {}
|
||||||
|
|
||||||
csstype@3.1.3: {}
|
csstype@3.1.3: {}
|
||||||
|
@ -4314,6 +4372,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
binary-extensions: 2.3.0
|
binary-extensions: 2.3.0
|
||||||
|
|
||||||
|
is-buffer@1.1.6: {}
|
||||||
|
|
||||||
is-builtin-module@3.2.1:
|
is-builtin-module@3.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
builtin-modules: 3.3.0
|
builtin-modules: 3.3.0
|
||||||
|
@ -4437,6 +4497,12 @@ snapshots:
|
||||||
|
|
||||||
markdown-table@3.0.4: {}
|
markdown-table@3.0.4: {}
|
||||||
|
|
||||||
|
md5@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
charenc: 0.0.2
|
||||||
|
crypt: 0.0.2
|
||||||
|
is-buffer: 1.1.6
|
||||||
|
|
||||||
mdast-util-find-and-replace@3.0.2:
|
mdast-util-find-and-replace@3.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
|
@ -5094,6 +5160,10 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
solid-sonner@0.2.8(solid-js@1.9.4):
|
||||||
|
dependencies:
|
||||||
|
solid-js: 1.9.4
|
||||||
|
|
||||||
solid-transition-group@0.2.3(solid-js@1.9.4):
|
solid-transition-group@0.2.3(solid-js@1.9.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@solid-primitives/refs': 1.0.8(solid-js@1.9.4)
|
'@solid-primitives/refs': 1.0.8(solid-js@1.9.4)
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"jsxImportSource": "solid-js",
|
"jsxImportSource": "solid-js",
|
||||||
"lib": [
|
"lib": [
|
||||||
"ES2020",
|
"ES2024",
|
||||||
"DOM",
|
"DOM",
|
||||||
"DOM.Iterable",
|
"DOM.Iterable",
|
||||||
"WebWorker"
|
"WebWorker"
|
||||||
|
|
Loading…
Reference in a new issue