account actions

This commit is contained in:
alina 🌸 2025-01-15 06:35:00 +03:00
parent 29f4219d95
commit e518e78cef
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
22 changed files with 1015 additions and 146 deletions

View file

@ -12,14 +12,17 @@
"dependencies": {
"@corvu/otp-field": "^0.1.4",
"@corvu/resizable": "^0.2.3",
"@fuman/io": "0.0.8",
"@fuman/utils": "0.0.4",
"@kobalte/core": "^0.13.7",
"@mtcute/convert": "^0.19.4",
"@mtcute/web": "^0.19.5",
"@nanostores/persistent": "^0.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"filesize": "^10.1.6",
"lucide-solid": "^0.445.0",
"md5": "^2.3.0",
"monaco-editor": "0.52.0",
"monaco-editor-core": "0.52.0",
"monaco-editor-textmate": "^4.0.0",
@ -30,10 +33,12 @@
"onigasm": "^2.2.5",
"solid-icons": "^1.1.0",
"solid-js": "^1.9.4",
"solid-sonner": "^0.2.8",
"solid-transition-group": "^0.2.3",
"ts-blank-space": "^0.4.4"
},
"devDependencies": {
"@types/md5": "^2.3.5",
"postcss": "^8.4.49",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7"

View file

@ -7,6 +7,7 @@ import { Runner } from './components/runner/Runner.tsx'
import { SettingsDialog, type SettingsTab } from './components/settings/Settings.tsx'
import { Updater } from './components/Updater.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'))
@ -23,6 +24,7 @@ export function App() {
return (
<div class="flex h-screen w-screen flex-col overflow-hidden">
<Toaster />
<iframe
ref={workerIframe}
class="invisible size-0"

View file

@ -1,5 +1,5 @@
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'
export function AccountAvatar(props: {
@ -7,9 +7,20 @@ export function AccountAvatar(props: {
account: TelegramAccount
}) {
const [url, setUrl] = createSignal<string | undefined>()
onMount(async () => {
createEffect(() => {
const accountId = props.account.id
if (!accountId) {
return
}
if (untrack(url)) {
URL.revokeObjectURL(untrack(url)!)
}
setUrl(undefined)
;(async () => {
try {
const buf = await workerInvoke('telegram', 'fetchAvatar', props.account.id)
const buf = await workerInvoke('telegram', 'fetchAvatar', accountId)
if (!buf) return
const url = URL.createObjectURL(new Blob([buf], { type: 'image/jpeg' }))
@ -17,6 +28,7 @@ export function AccountAvatar(props: {
} catch (e) {
console.error(e)
}
})()
})
onCleanup(() => url() && URL.revokeObjectURL(url()!))

View file

@ -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 { LoginStep } from './login/Login.tsx'
import { timers } from '@fuman/utils'
import { timers, unknownToError } from '@fuman/utils'
import {
LucideBot,
LucideChevronRight,
LucideEllipsis,
LucideFolderUp,
LucideLogIn,
LucidePlus,
LucideRefreshCw,
LucideSearch,
LucideTrash,
LucideUser,
LucideX,
} from 'lucide-solid'
import { workerInvoke } from 'mtcute-repl-worker/client'
import { nanoid } from 'nanoid'
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 { Button } from '../../lib/components/ui/button.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 { WithTooltip } from '../../lib/components/ui/tooltip.tsx'
import { cn } from '../../lib/utils.ts'
import { $accounts, $activeAccountId } from '../../store/accounts.ts'
import { useStore } from '../../store/use-store.ts'
import { AccountAvatar } from '../AccountAvatar.tsx'
import { ImportDropdown } from './import/ImportDropdown.tsx'
import { StringSessionDefs } from './import/StringSessionImportDialog.tsx'
import { LoginForm } from './login/Login.tsx'
function AddAccountDialog(props: {
@ -113,6 +123,18 @@ function AccountRow(props: {
active: boolean
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 (
<div class="flex max-w-full flex-row overflow-hidden rounded-md border border-border p-2">
<AccountAvatar
@ -150,29 +172,110 @@ function AccountRow(props: {
</div>
<div class="flex-1" />
<div class="mr-1 flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger
as={(props: DropdownMenuTriggerProps) => (
<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}
onClick={props.onSetActive}
{...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 }}
>
{(props: TooltipTriggerProps) => (
<Button
variant="ghostDestructive"
variant={deleteConfirming() ? 'destructive' : 'ghostDestructive'}
size="icon"
class="size-8"
{...props}
onClick={() => {
if (deleteConfirming()) {
handleDelete()
setDeleteConfirming(false)
} else {
setDeleteConfirming(true)
}
// @ts-expect-error meow
props.onClick?.()
}}
onMouseLeave={() => setDeleteConfirming(false)}
disabled={deleting()}
>
<LucideTrash class="size-4" />
</Button>
)}
</WithTooltip>
</div>
</div>
)

View file

@ -1,10 +1,9 @@
import { hex } from '@fuman/utils'
import { workerInvoke } from 'mtcute-repl-worker/client'
import { createEffect, createSignal, on } 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 { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../../lib/components/ui/text-field.tsx'
import { $accounts } from '../../../store/accounts.ts'
export function AuthKeyImportDialog(props: {
open: boolean
@ -27,36 +26,14 @@ export function AuthKeyImportDialog(props: {
abortController = new AbortController()
setLoading(true)
const oldAccounts = $accounts.get()
try {
const testMode_ = testMode()
const authKey = hex.decode(authKeyInputRef()!.value)
if (authKey.length !== 256) {
setError('Invalid auth key (must be 256 bytes long)')
setLoading(false)
return
}
await workerInvoke('telegram', 'importAuthKey', {
hexAuthKey: authKeyInputRef()!.value,
dcId: Number(dcId()),
testMode: testMode(),
abortSignal: abortController.signal,
})
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()
} catch (e) {
if (e instanceof Error) {

View file

@ -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>
)
}

View file

@ -1,6 +1,6 @@
import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu'
import type { StringSessionLibName } from './StringSessionImportDialog.tsx'
import { LucideChevronRight, LucideDownload, LucideKeyRound, LucideLaptop, LucideTextCursorInput } from 'lucide-solid'
import type { StringSessionLibName } from 'mtcute-repl-worker/client'
import { LucideBot, LucideChevronRight, LucideDownload, LucideKeyRound, LucideLaptop, LucideTextCursorInput } from 'lucide-solid'
import { createSignal, For } from 'solid-js'
import { Button } from '../../../lib/components/ui/button.tsx'
import {
@ -12,14 +12,19 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '../../../lib/components/ui/dropdown-menu.tsx'
import { WithTooltip } from '../../../lib/components/ui/tooltip.tsx'
import { cn } from '../../../lib/utils.ts'
import { AuthKeyImportDialog } from './AuthKeyImportDialog.tsx'
import { BotTokenImportDialog } from './BotTokenImportDialog.tsx'
import { StringSessionDefs, StringSessionImportDialog } from './StringSessionImportDialog.tsx'
import { TDATA_IMPORT_AVAILABLE, TdataImportDialog } from './tdata/TdataImportDialog.tsx'
export function ImportDropdown(props: { size: 'xs' | 'sm' }) {
const [showImportStringSession, setShowImportStringSession] = createSignal(false)
const [stringSessionLibName, setStringSessionLibName] = createSignal<StringSessionLibName>('mtcute')
const [showImportAuthKey, setShowImportAuthKey] = createSignal(false)
const [showImportBotToken, setShowImportBotToken] = createSignal(false)
const [showImportTdata, setShowImportTdata] = createSignal(false)
return (
<>
@ -31,14 +36,10 @@ export function ImportDropdown(props: { size: 'xs' | 'sm' }) {
size={props.size}
{...triggerProps}
>
<LucideDownload class={
cn(
{
<LucideDownload class={{
xs: 'mr-2 size-3',
sm: 'mr-2 size-3.5',
}[props.size],
)
}
}[props.size]}
/>
Import
</Button>
@ -49,6 +50,10 @@ export function ImportDropdown(props: { size: 'xs' | 'sm' }) {
<LucideKeyRound class="mr-2 size-3.5 stroke-[1.5px]" />
Auth key
</DropdownMenuItem>
<DropdownMenuItem class="py-1 text-xs" onClick={() => setShowImportBotToken(true)}>
<LucideBot class="mr-2 size-3.5 stroke-[1.5px]" />
Bot token
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger class="py-1 text-xs">
<LucideTextCursorInput class="mr-2 size-3.5 stroke-[1.5px]" />
@ -71,10 +76,27 @@ export function ImportDropdown(props: { size: 'xs' | 'sm' }) {
</For>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem class="py-1 text-xs">
<WithTooltip
enabled={!TDATA_IMPORT_AVAILABLE}
content={(
<>
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>
</DropdownMenu>
@ -89,6 +111,16 @@ export function ImportDropdown(props: { size: 'xs' | 'sm' }) {
open={showImportAuthKey()}
onClose={() => setShowImportAuthKey(false)}
/>
<BotTokenImportDialog
open={showImportBotToken()}
onClose={() => setShowImportBotToken(false)}
/>
<TdataImportDialog
open={showImportTdata()}
onClose={() => setShowImportTdata(false)}
/>
</>
)
}

View file

@ -1,16 +1,9 @@
import { type StringSessionLibName, 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 { 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 { $accounts } from '../../../store/accounts.ts'
export type StringSessionLibName =
| 'mtcute'
| 'pyrogram'
| 'telethon'
| 'mtkruto'
| 'gramjs'
export const StringSessionDefs: {
name: StringSessionLibName
@ -23,30 +16,6 @@ export const StringSessionDefs: {
{ 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: {
open: boolean
onClose: () => void
@ -63,32 +32,12 @@ export function StringSessionImportDialog(props: {
abortController = new AbortController()
setLoading(true)
const oldAccounts = $accounts.get()
try {
const converted = await convert(props.chosenLibName, inputRef()!.value)
// check if account exists
if (converted.self && oldAccounts.some(it => it.telegramId === converted.self!.userId)) {
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,
])
await workerInvoke('telegram', 'importStringSession', {
libraryName: props.chosenLibName,
session: inputRef()!.value,
abortSignal: abortController.signal,
})
props.onClose()
} catch (e) {
if (e instanceof Error) {
@ -101,6 +50,7 @@ export function StringSessionImportDialog(props: {
setLoading(false)
}
createEffect(on(() => props.open, (open) => {
if (!open) {
abortController?.abort()

View file

@ -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>
)
}

View file

@ -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()))
}
},
}
}
}

View 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)
}
}

View 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>
)
}

View 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}
/>
)
}

View file

@ -3,8 +3,9 @@ import type {
TooltipContentProps,
TooltipRootProps,
} from '@kobalte/core/tooltip'
import type { ComponentProps, JSX, ValidComponent } from 'solid-js'
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'
export const TooltipTrigger = TooltipPrimitive.Trigger
@ -13,6 +14,8 @@ export function Tooltip(props: TooltipRootProps) {
const merge = mergeProps<TooltipRootProps[]>(
{
gutter: 4,
openDelay: 200,
closeDelay: 200,
flip: false,
},
props,
@ -41,3 +44,22 @@ export function TooltipContent<T extends ValidComponent = 'div'>(props: Polymorp
</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>
)
}

View file

@ -12,6 +12,9 @@ export default defineConfig((env): UserConfig => {
}
return {
optimizeDeps: {
exclude: ['@mtcute/wasm'],
},
server: {
port: 3000,
},

View file

@ -4,6 +4,7 @@ import type { ReplWorkerEvents } from './worker/utils.ts'
import { Deferred, unknownToError } from '@fuman/utils'
export type { TelegramAccount } from './store/accounts.ts'
export type { StringSessionLibName } from './worker/telegram.ts'
// eslint-disable-next-line ts/no-namespace
export namespace mtcute {
@ -56,6 +57,7 @@ export function workerInit(iframe_: HTMLIFrameElement) {
}
type ForceFunction<T> = T extends (...args: any) => any ? T : never
type Awaited<T> = T extends Promise<infer U> ? U : T
export async function workerInvoke<
Domain extends keyof ReplWorker,
@ -64,7 +66,7 @@ export async function workerInvoke<
domain: Domain,
method: Method,
...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) {
if (loadedDeferred) {

View file

@ -6,13 +6,18 @@ import { getCacheStorage } from './cache.ts'
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) {
const cacheKey = new URL(`/sw/avatar/${accountId}`, location.origin)
const cache = await getCacheStorage()
try {
const cachedRes = await timeout(cache.match(cacheKey), 10000)
if (cachedRes && cachedRes.ok) {
if (cachedRes) {
return cachedRes
}
} catch {}
@ -27,7 +32,10 @@ export async function handleAvatarRequest(accountId: string) {
const self = await getMe(client)
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)

View file

@ -1,6 +1,6 @@
import { unknownToError } from '@fuman/utils'
import { IS_SAFARI } from '../utils/env.ts'
import { handleAvatarRequest } from './avatar.ts'
import { clearAvatarCache, handleAvatarRequest } from './avatar.ts'
import { requestCache } from './cache.ts'
import { clearCache, forgetScript, handleRuntimeRequest, uploadScript } from './runtime.ts'
@ -55,6 +55,7 @@ self.onoffline = self.ononline = () => {
export type SwMessage =
| { event: 'UPLOAD_SCRIPT', name: string, files: Record<string, string> }
| { event: 'FORGET_SCRIPT', name: string }
| { event: 'CLEAR_AVATAR_CACHE', accountId: string }
| { event: 'CLEAR_CACHE' }
function handleMessage(msg: SwMessage) {
@ -71,6 +72,10 @@ function handleMessage(msg: SwMessage) {
clearCache()
break
}
case 'CLEAR_AVATAR_CACHE': {
clearAvatarCache(msg.accountId)
break
}
}
}

View file

@ -48,6 +48,8 @@ export async function importAccount(
const self = await getMe(client)
if (abortSignal.aborted) throw abortSignal.reason
await client.close()
return {
id: accountId,
name: self.displayName,

View file

@ -1,13 +1,25 @@
import type { BaseTelegramClient, SentCode, User } from '@mtcute/web'
import type { StringSessionData } from '@mtcute/web/utils.js'
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 { 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 { $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'
export type StringSessionLibName =
| 'mtcute'
| 'pyrogram'
| 'telethon'
| 'mtkruto'
| 'gramjs'
const clients = new Map<string, BaseTelegramClient>()
function getClient(accountId: string) {
const client = clients.get(accountId)
@ -15,12 +27,27 @@ function getClient(accountId: string) {
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) {
const client = getClient(accountId)
const dcs = await client.mt.storage.dcs.fetch()
const dcId = dcs?.main.id ?? 2
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 = {
id: accountId,
name: user.displayName,
@ -35,6 +62,8 @@ async function handleAuthSuccess(accountId: string, user: User) {
])
$activeAccountId.set(accountId)
await client.close()
return account
}
@ -194,4 +223,192 @@ export class ReplWorkerTelegram {
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
}
}))
}
}

View file

@ -62,12 +62,21 @@ importers:
'@corvu/resizable':
specifier: ^0.2.3
version: 0.2.3(solid-js@1.9.4)
'@fuman/io':
specifier: 0.0.8
version: 0.0.8
'@fuman/utils':
specifier: 0.0.4
version: 0.0.4
'@kobalte/core':
specifier: ^0.13.7
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':
specifier: ^0.10.2
version: 0.10.2(nanostores@0.11.3)
@ -83,6 +92,9 @@ importers:
lucide-solid:
specifier: ^0.445.0
version: 0.445.0(solid-js@1.9.4)
md5:
specifier: ^2.3.0
version: 2.3.0
monaco-editor:
specifier: 0.52.0
version: 0.52.0
@ -113,6 +125,9 @@ importers:
solid-js:
specifier: ^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:
specifier: ^0.2.3
version: 0.2.3(solid-js@1.9.4)
@ -120,6 +135,9 @@ importers:
specifier: ^0.4.4
version: 0.4.4
devDependencies:
'@types/md5':
specifier: ^2.3.5
version: 2.3.5
postcss:
specifier: ^8.4.49
version: 8.4.49
@ -778,6 +796,9 @@ packages:
'@mtcute/core@0.19.6':
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':
resolution: {integrity: sha512-r9r5JxchoVtYYMLPsf/wSnd5+KB4KmilWHywKQenf0DgKD+LCEN2FJpzY44RFE5dpy+eV5OHZ85zxA6EyYz/mA==}
@ -1013,6 +1034,9 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/md5@2.3.5':
resolution: {integrity: sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw==}
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
@ -1219,6 +1243,9 @@ packages:
character-entities@2.0.2:
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:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@ -1277,6 +1304,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
crypt@0.0.2:
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@ -1741,6 +1771,9 @@ packages:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
is-buffer@1.1.6:
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
is-builtin-module@3.2.1:
resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==}
engines: {node: '>=6'}
@ -1892,6 +1925,9 @@ packages:
markdown-table@3.0.4:
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:
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
@ -2396,6 +2432,11 @@ packages:
peerDependencies:
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:
resolution: {integrity: sha512-iB72c9N5Kz9ykRqIXl0lQohOau4t0dhel9kjwFvx81UZJbVwaChMuBuyhiZmK24b8aKEK0w3uFM96ZxzcyZGdg==}
engines: {node: '>=18.0.0', pnpm: '>=8.6.0'}
@ -3226,7 +3267,7 @@ snapshots:
'@fuman/io': 0.0.8
'@fuman/net': 0.0.9
'@fuman/utils': 0.0.4
'@mtcute/core': 0.19.6
'@mtcute/core': 0.19.7
'@mtcute/core@0.19.6':
dependencies:
@ -3239,6 +3280,17 @@ snapshots:
'@types/events': 3.0.0
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':
dependencies:
'@fuman/utils': 0.0.4
@ -3457,6 +3509,8 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/md5@2.3.5': {}
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
@ -3693,6 +3747,8 @@ snapshots:
character-entities@2.0.2: {}
charenc@0.0.2: {}
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
@ -3751,6 +3807,8 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
crypt@0.0.2: {}
cssesc@3.0.0: {}
csstype@3.1.3: {}
@ -4314,6 +4372,8 @@ snapshots:
dependencies:
binary-extensions: 2.3.0
is-buffer@1.1.6: {}
is-builtin-module@3.2.1:
dependencies:
builtin-modules: 3.3.0
@ -4437,6 +4497,12 @@ snapshots:
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:
dependencies:
'@types/mdast': 4.0.4
@ -5094,6 +5160,10 @@ snapshots:
transitivePeerDependencies:
- 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):
dependencies:
'@solid-primitives/refs': 1.0.8(solid-js@1.9.4)

View file

@ -4,7 +4,7 @@
"jsx": "preserve",
"jsxImportSource": "solid-js",
"lib": [
"ES2020",
"ES2024",
"DOM",
"DOM.Iterable",
"WebWorker"