feat: initial mvp

This commit is contained in:
alina 🌸 2025-01-12 20:20:36 +03:00
commit f21a53f088
84 changed files with 22404 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

28
README.md Normal file
View file

@ -0,0 +1,28 @@
## Usage
```bash
$ npm install # or pnpm install or yarn install
```
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
## Available Scripts
In the project directory, you can run:
### `npm run dev`
Runs the app in the development mode.<br>
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
### `npm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## Deployment
Learn more about deploying your application with the [documentations](https://vitejs.dev/guide/static-deploy.html)

28
eslint.config.js Normal file
View file

@ -0,0 +1,28 @@
import antfu from '@antfu/eslint-config'
import tailwind from 'eslint-plugin-tailwindcss'
export default antfu({
ignores: [
'src/components/Editor/utils/*.json',
'vendor',
],
typescript: true,
solid: true,
yaml: false,
rules: {
'style/multiline-ternary': 'off',
'curly': ['error', 'multi-line'],
'style/brace-style': ['error', '1tbs', { allowSingleLine: true }],
'n/prefer-global/buffer': 'off',
'style/quotes': ['error', 'single', { avoidEscape: true }],
'antfu/if-newline': 'off',
'import/no-relative-packages': 'error',
'style/max-statements-per-line': ['error', { max: 2 }],
'ts/no-redeclare': 'off',
'unused-imports/no-unused-imports': 'error',
'ts/no-empty-object-type': 'off',
},
plugins: {
tw: tailwind,
},
}, tailwind.configs['flat/recommended'])

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@mtcute/repl</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

68
package.json Normal file
View file

@ -0,0 +1,68 @@
{
"name": "mtcute-repl",
"type": "module",
"version": "0.0.0",
"private": true,
"packageManager": "pnpm@9.5.0",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@badrap/valita": "^0.4.2",
"@corvu/otp-field": "^0.1.4",
"@corvu/resizable": "^0.2.3",
"@fuman/fetch": "^0.0.8",
"@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",
"esbuild": "^0.24.2",
"fflate": "^0.8.2",
"filesize": "^10.1.6",
"idb": "^8.0.1",
"lucide-solid": "^0.445.0",
"memfs": "^4.17.0",
"monaco-editor": "0.52.0",
"monaco-editor-core": "0.52.0",
"monaco-editor-textmate": "^4.0.0",
"monaco-textmate": "^3.0.1",
"nanoid": "^5.0.9",
"nanostores": "^0.11.3",
"onigasm": "^2.2.5",
"semver": "^7.6.3",
"solid-icons": "^1.1.0",
"solid-js": "^1.9.4",
"solid-transition-group": "^0.2.3",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"ts-blank-space": "^0.4.4",
"uqr": "^0.1.2"
},
"devDependencies": {
"@antfu/eslint-config": "^3.13.0",
"@catppuccin/vscode": "^3.16.0",
"@types/node": "^22.10.5",
"@types/semver": "^7.5.8",
"autoprefixer": "^10.4.20",
"eslint-plugin-solid": "^0.14.5",
"eslint-plugin-tailwindcss": "^3.17.5",
"monaco-vscode-textmate-theme-converter": "^0.1.7",
"plist2": "^1.1.4",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^5.4.11",
"vite-plugin-solid": "^2.11.0"
},
"pnpm": {
"patchedDependencies": {
"vite-plugin-externalize-dependencies@1.0.1": "patches/vite-plugin-externalize-dependencies@1.0.1.patch"
}
}
}

View file

@ -0,0 +1,16 @@
diff --git a/dist/index.js b/dist/index.js
index 4b5eff9ae46b5fa0af75b16ef21f5b93142a7251..bc1651dfd82210b69ec9b5709c5f6a6320ca31b0 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -14,9 +14,9 @@ const r = /* @__PURE__ */ new Set(), s = (n, e) => e.some((t) => typeof t == "st
if (r.size === 0)
return e;
const t = "@id/", u = new RegExp(
- `${n}${t}(${[...r].join(
+ `${n}${t}(${[...r].map(it => it.replace(/\?/g, "\\?")).join(
"|"
- )})`,
+ )})(?:\\?external)?`,
"g"
);
return u.test(e) ? e.replace(

5815
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -0,0 +1,12 @@
import { writeFile } from 'node:fs/promises'
import { ffetchBase as ffetch } from '@fuman/fetch'
import { plist2js } from 'plist2'
const plist = await ffetch('https://raw.githubusercontent.com/microsoft/TypeScript-TmLanguage/refs/heads/master/TypeScript.tmLanguage').text()
const grammar = plist2js(plist)
await writeFile(
new URL('../src/components/Editor/utils/typescript.tmLanguage.json', import.meta.url),
JSON.stringify(grammar, null, 2),
)

View file

@ -0,0 +1,17 @@
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import latte from '@catppuccin/vscode/themes/latte.json' with { type: 'json' }
import mocha from '@catppuccin/vscode/themes/mocha.json' with { type: 'json' }
import { convertTheme } from 'monaco-vscode-textmate-theme-converter'
const OUT_DIR = fileURLToPath(new URL('./../src/components/Editor/utils', import.meta.url))
const latteConverted = convertTheme(latte as any)
latteConverted.colors['editor.background'] = '#ffffff'
latteConverted.colors['editorGutter.background'] = '#ffffff'
writeFileSync(join(OUT_DIR, 'latte.json'), JSON.stringify(latteConverted, null, 2))
writeFileSync(join(OUT_DIR, 'mocha.json'), JSON.stringify(convertTheme(mocha as any), null, 2))

View file

@ -0,0 +1,193 @@
// based on https://github.com/MilanKovacic/vite-plugin-externalize-dependencies/blob/main/src/index.ts
// but modified to support `?external` imports
import type { Plugin as EsbuildPlugin, OnResolveArgs, PluginBuild } from 'esbuild'
import type { Plugin, ResolvedConfig, UserConfig } from 'vite'
type ExternalCriteria = string | RegExp | ((id: string) => boolean)
interface ModulePrefixTransformPluginOptions {
/** The base path of the vite configuration */
base: string
}
interface PluginOptions {
externals: ExternalCriteria[]
}
const resolvedExternals = new Set<string>()
function isExternal(id: string, externals: ExternalCriteria[]): boolean {
if (id.endsWith('?external')) return true
return externals.some((external) => {
if (typeof external === 'string') {
return id === external || id.startsWith(`${external}/`)
}
if (external instanceof RegExp) {
return external.test(id)
}
if (typeof external === 'function') {
return external(id)
}
return false
})
}
/**
* Creates a plugin for esbuild to externalize specific modules.
* esbuild is used by Vite during development.
* This plugin is injected into optimizeDeps.esbuildOptions.plugins, and runs during the dependency scanning / optimization phase.
*
* @param options - Plugin options
*
* @returns The esbuild plugin
*/
function esbuildPluginExternalize(externals: ExternalCriteria[]): EsbuildPlugin {
return {
name: 'externalize',
setup(build: PluginBuild) {
build.onResolve({ filter: /.*/ }, (args: OnResolveArgs) => {
if (
isExternal(args.path, externals)
&& args.kind === 'import-statement'
) {
resolvedExternals.add(args.path)
return {
path: args.path,
external: true,
}
}
// Supresses the following error:
// The entry point [moduleName] cannot be marked as external
if (isExternal(args.path, externals) && args.kind === 'entry-point') {
resolvedExternals.add(args.path)
return { path: args.path, namespace: 'externalized-modules' }
}
return null
})
// Supresses the following error:
// Do not know how to load path: [namespace:moduleName]
build.onLoad({ filter: /.*/ }, (args) => {
if (isExternal(args.path, externals)) {
return { contents: '' }
}
return null
})
},
}
}
/**
* Creates a plugin to remove prefix from imports injected by Vite.
* If module is externalized, Vite will prefix imports with "/\@id/" during development.
*
* @param options - The plugin options
*
* @returns Vite plugin to remove prefix from imports
*/
function modulePrefixTransform({
base,
}: ModulePrefixTransformPluginOptions): Plugin {
return {
name: 'vite-plugin-remove-prefix',
transform: (code: string): string => {
// Verify if there are any external modules resolved to avoid having /\/@id\/()/g regex
if (resolvedExternals.size === 0) return code
const viteImportAnalysisModulePrefix = '@id/'
const prefixedImportRegex = new RegExp(
`${base}${viteImportAnalysisModulePrefix}(${
[...resolvedExternals]
.map(it => it.replace(/\?/g, '\\?'))
.join('|')})`,
'g',
)
if (prefixedImportRegex.test(code)) {
return code.replace(
prefixedImportRegex,
(_: string, externalName: string) => externalName.replace(/\?external$/g, ''),
)
}
return code
},
}
}
/**
* Creates a Vite plugin to externalize specific modules.
* This plugin is only used during development.
* To externalize modules in production, configure build.rollupOptions.external.
*
* @param externals - The list of modules to externalize.
*
* @returns The Vite plugin.
*/
function vitePluginExternalize(options: PluginOptions): Plugin {
return {
name: 'vite-plugin-externalize',
enforce: 'pre',
apply: 'serve',
config: (config: UserConfig): Omit<UserConfig, 'plugins'> | null | void => {
config.optimizeDeps ??= {}
config.optimizeDeps.esbuildOptions ??= {}
config.optimizeDeps.esbuildOptions.plugins ??= []
// Prevent the plugin from being inserted multiple times
const pluginName = 'externalize'
const isPluginAdded = config.optimizeDeps.esbuildOptions.plugins.some(
(plugin: any) => plugin.name === pluginName,
)
if (!isPluginAdded) {
config.optimizeDeps.esbuildOptions.plugins.push(
esbuildPluginExternalize(options.externals) as any,
)
}
return null
},
configResolved: (resolvedConfig: ResolvedConfig) => {
// Plugins are read-only, and should not be modified,
// however modulePrefixTransformPlugin MUST run after vite:import-analysis (which adds the prefix to imports)
(resolvedConfig.plugins as Plugin[]).push(
modulePrefixTransform({ base: resolvedConfig.base ?? '/' }),
)
},
// Supresses the following warning:
// Failed to resolve import [dependency] from [sourceFile]. Does the file exist?
resolveId: (id: string) => {
if (resolvedExternals.has(id)) {
return { id, external: true }
}
// During subsequent runs after the dependency optimization is completed, esbuild plugin might not be called.
// This will cause the resolvedExternals to be empty, and the plugin will not be able to resolve the external modules, which is why a direct check is required.
if (isExternal(id, options.externals)) {
resolvedExternals.add(id)
return { id, external: true }
}
return null
},
// Supresses the following warning:
// The following dependencies are imported but could not be resolved: [dependency] (imported by [sourceFile])
load: (id: string) => {
if (resolvedExternals.has(id)) {
return { code: 'export default {};' }
}
return null
},
}
}
// Justification: Vite plugins are expected to provide a default export
export default vitePluginExternalize

65
src/App.tsx Normal file
View file

@ -0,0 +1,65 @@
import Resizable from '@corvu/resizable'
import { createSignal, lazy } from 'solid-js'
import { EditorTabs } from './components/editor/EditorTabs.tsx'
import { NavbarMenu } from './components/nav/NavbarMenu.tsx'
import { Runner } from './components/runner/Runner.tsx'
import { SettingsDialog, type SettingsTab } from './components/settings/Settings.tsx'
import { Updater } from './components/Updater.tsx'
import { ResizableHandle, ResizablePanel } from './lib/components/ui/resizable.tsx'
const Editor = lazy(() => import('./components/editor/Editor.tsx'))
export function App() {
const [versions, setVersions] = createSignal<Record<string, string> | undefined>(undefined)
const [showSettings, setShowSettings] = createSignal(false)
const [settingsTab, setSettingsTab] = createSignal<SettingsTab>('accounts')
return (
<div class="flex h-screen w-screen flex-col overflow-hidden">
<nav class="relative flex h-auto w-full shrink-0 flex-row items-center overflow-hidden px-4 py-2">
<h1 class="font-mono text-base">
@mtcute/
<b>playground</b>
</h1>
<NavbarMenu
onShowAccounts={() => {
setShowSettings(true)
setSettingsTab('accounts')
}}
onShowSettings={() => {
setShowSettings(true)
}}
/>
</nav>
<div class="h-px shrink-0 bg-border" />
{versions() === undefined ? (
<Updater
onComplete={setVersions}
/>
) : (
<Resizable orientation="horizontal" class="size-full max-h-[calc(100vh-57px)]">
<ResizablePanel class="h-full overflow-x-auto overflow-y-hidden" minSize={0.2}>
<EditorTabs />
<Editor class="size-full" />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
class="flex max-h-full flex-col overflow-hidden"
minSize={0.2}
>
<Runner />
</ResizablePanel>
</Resizable>
)}
<SettingsDialog
show={showSettings()}
onClose={() => setShowSettings(false)}
tab={settingsTab()}
onTabChange={setSettingsTab}
/>
</div>
)
}

114
src/app.css Normal file
View file

@ -0,0 +1,114 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--info: 204 94% 94%;
--info-foreground: 199 89% 48%;
--success: 149 80% 90%;
--success-foreground: 160 84% 39%;
--warning: 48 96% 89%;
--warning-foreground: 25 95% 53%;
--error: 0 93% 94%;
--error-foreground: 0 84% 60%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
}
.dark,
[data-kb-theme="dark"] {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--info: 204 94% 94%;
--info-foreground: 199 89% 48%;
--success: 149 80% 90%;
--success-foreground: 160 84% 39%;
--warning: 48 96% 89%;
--warning-foreground: 25 95% 53%;
--error: 0 93% 94%;
--error-foreground: 0 84% 60%;
--ring: 240 4.9% 83.9%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
}
}
@media (max-width: 640px) {
.container {
@apply px-4;
}
}

View file

@ -0,0 +1,14 @@
import type { TelegramAccount } from '../store/accounts.ts'
import { Avatar, AvatarFallback, AvatarImage, makeAvatarFallbackText } from '../lib/components/ui/avatar.tsx'
export function AccountAvatar(props: {
class?: string
account: TelegramAccount
}) {
return (
<Avatar class={props.class}>
<AvatarImage src={`/sw/avatar/${props.account.id}/avatar.jpg`} />
<AvatarFallback>{makeAvatarFallbackText(props.account.name)}</AvatarFallback>
</Avatar>
)
}

View file

@ -0,0 +1,99 @@
import { asyncPool } from '@fuman/utils'
import { filesize } from 'filesize'
import { createSignal, onCleanup, onMount } from 'solid-js'
import { Spinner } from '../lib/components/ui/spinner.tsx'
import { downloadNpmPackage, getLatestVersions, getPackagesToDownload } from '../lib/vfs/downloader'
import { VfsStorage } from '../lib/vfs/storage'
import { swClearCache } from '../sw/client.ts'
export interface UpdaterProps {
onComplete: (versions: Record<string, string>) => void
}
export function Updater(props: UpdaterProps) {
const [downloadedBytes, setDownloadedBytes] = createSignal(0)
const [totalBytes, setTotalBytes] = createSignal(Infinity)
const [step, setStep] = createSignal('Idle')
let abortController: AbortController | undefined
async function runUpdater() {
if (abortController) abortController.abort()
abortController = new AbortController()
const signal = abortController.signal
setStep('Checking for updates...')
const vfs = await VfsStorage.create()
const latestVersions = await getLatestVersions()
const versions = await getPackagesToDownload(latestVersions, vfs)
if (Object.keys(versions).length === 0) {
props.onComplete(latestVersions)
return
}
const entries = Object.entries(versions)
setStep('Downloading...')
await swClearCache()
await asyncPool(entries, async ([lib, version]) => {
let isFirst = true
let prevDownloaded = 0
function onProgress(downloaded: number, total: number) {
if (isFirst) {
setTotalBytes(prev => prev === Infinity ? total : prev + total)
isFirst = false
}
const diff = downloaded - prevDownloaded
setDownloadedBytes(prev => prev + diff)
prevDownloaded = downloaded
}
await downloadNpmPackage({
packageName: lib,
version,
storage: vfs,
progress: onProgress,
filterFiles: (file) => {
if (!file.header) return true
const name = file.header.name
return !name.endsWith('.cjs') && !name.endsWith('.d.cts') && name !== 'LICENSE' && name !== 'README.md'
},
signal,
})
})
props.onComplete(latestVersions)
}
onMount(() => {
runUpdater()
})
onCleanup(() => {
abortController?.abort()
})
return (
<div class="flex flex-col items-center gap-2 p-4">
<Spinner
class="size-10"
indeterminate
/>
<div class="text-center text-xs text-muted-foreground">
{step()}
{totalBytes() !== Infinity && (
<div>
{filesize(downloadedBytes())}
{' / '}
{filesize(totalBytes())}
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,3 @@
[data-monaco-root] .squiggly-error {
transform: translateY(4px);
}

View file

@ -0,0 +1,152 @@
import { editor as mEditor, Uri } from 'monaco-editor'
import { createEffect, on, onMount } from 'solid-js'
import { useColorScheme } from '../../lib/use-color-scheme'
import { VfsStorage } from '../../lib/vfs/storage.ts'
import { $activeTab, $tabs, type EditorTab } from '../../store/tabs.ts'
import { useStore } from '../../store/use-store.ts'
import { setupMonaco } from './utils/setup.ts'
import './Editor.css'
export interface EditorProps {
class?: string
}
const DEFAULT_CODE = `
/**
* This playground comes pre-loaded with all @mtcute/* libraries,
* as well as a pre-configured Telegram client (available as \`tg\` global variable).
*
* Exports from this file will become available in the REPL.
*/
export const self = await tg.getMe()
console.log(self)
`.trimStart()
function findChangedTab(a: EditorTab[], b: EditorTab[]) {
const set = new Set(a.map(tab => tab.id))
for (const tab of b) {
if (!set.has(tab.id)) return tab
}
return null
}
export default function Editor(props: EditorProps) {
const tabs = useStore($tabs)
const activeTab = useStore($activeTab)
let ref!: HTMLDivElement
let editor: mEditor.IStandaloneCodeEditor | undefined
const scheme = useColorScheme()
// const monacoTheme = () => scheme() === 'dark' ? 'ayu-dark' : 'ayu-light'
const modelsByTab = new Map<string, mEditor.ITextModel>()
onMount(async () => {
const vfs = await VfsStorage.create()
editor = mEditor.create(ref, {
model: null,
automaticLayout: true,
minimap: {
enabled: false,
},
scrollbar: {
verticalScrollbarSize: 8,
},
lightbulb: {
enabled: 'onCode' as any,
},
quickSuggestions: {
other: true,
comments: true,
strings: true,
},
padding: { top: 8 },
acceptSuggestionOnCommitCharacter: true,
acceptSuggestionOnEnter: 'on',
accessibilitySupport: 'on',
inlayHints: {
enabled: 'on',
},
lineNumbersMinChars: 3,
// theme: monacoTheme(),
theme: 'latte', // todo
scrollBeyondLastLine: false,
})
await setupMonaco(vfs)
for (const tab of tabs()) {
const model = mEditor.createModel(tab.main ? DEFAULT_CODE : '', 'typescript', Uri.parse(`file:///${tab.id}.ts`))
modelsByTab.set(tab.id, model)
}
editor.setModel(modelsByTab.get(activeTab())!)
// editor.onDidChangeModelContent(() => {
// props.onCodeChange(editor?.getValue() ?? '')
// })
return () => editor?.dispose()
})
// createEffect(on(() => monacoTheme(), (theme) => {
// if (!editor) return
// editor.updateOptions({ theme })
// }))
createEffect(on(activeTab, (tabId) => {
if (!editor) return
const model = modelsByTab.get(tabId)
if (!model) return
editor.setModel(model)
}))
createEffect(on(tabs, (tabs, prevTabs) => {
if (!editor || !prevTabs) return
if (tabs.length === prevTabs.length) {
// verify filenames
for (const tab of tabs) {
const oldName = prevTabs.find(prevTab => prevTab.id === tab.id)?.fileName
if (!oldName) continue // weird flex but ok
if (oldName === tab.fileName) continue
// renamed
const oldModel = modelsByTab.get(tab.id)
if (!oldModel) continue
const newModel = mEditor.createModel(oldModel.getValue(), 'typescript', Uri.parse(`file:///${tab.fileName}`))
modelsByTab.set(tab.id, newModel)
if (editor.getModel() === oldModel) {
editor.setModel(newModel)
}
oldModel.dispose()
}
return
}
if (tabs.length > prevTabs.length) {
// a tab was created
const changed = findChangedTab(prevTabs, tabs)
if (!changed) return
const model = mEditor.createModel('', 'typescript', Uri.parse(`file:///${changed.fileName}`))
modelsByTab.set(changed.id, model)
editor.setModel(model)
} else {
// a tab was deleted
const changed = findChangedTab(tabs, prevTabs)
if (!changed) return
modelsByTab.delete(changed.id)
}
}))
return (
<div
data-monaco-root
class={props.class}
ref={ref}
/>
)
}

View file

@ -0,0 +1,187 @@
import type { EditorTab } from '../../store/tabs.ts'
import clsx from 'clsx'
import { LucidePlus, LucideX } from 'lucide-solid'
import { nanoid } from 'nanoid'
import { batch, createSignal, For } from 'solid-js'
import { Button } from '../../lib/components/ui/button.tsx'
import { Tabs, TabsIndicator, TabsList, TabsTrigger } from '../../lib/components/ui/tabs.tsx'
import { cn } from '../../lib/utils.ts'
import { $activeTab, $tabs } from '../../store/tabs.ts'
import { useStore } from '../../store/use-store.ts'
export interface EditorTabsProps {
class?: string
}
export function EditorTabs(props: EditorTabsProps) {
const tabs = useStore($tabs)
const activeTab = useStore($activeTab)
const [renamingTabId, setRenamingTabId] = createSignal<string | undefined>(undefined)
let root!: HTMLDivElement
let indicator!: HTMLDivElement
const updateIndicator = () => {
// crutch to imperatively update the indicator width
// based on https://github.com/kobaltedev/kobalte/issues/273#issuecomment-1741962351
const selected = root.querySelector<HTMLElement>(
'[role=tab][data-selected]',
)!
indicator.style.width = `${selected.clientWidth}px`
indicator.style.transform = `translateX(${selected.offsetLeft}px)`
}
const createNewTab = () => {
batch(() => {
const tabs_ = tabs()
const newTabId = nanoid()
$tabs.set([
...tabs_,
{
id: newTabId,
fileName: `newfile${tabs_.length}.ts`,
main: false,
},
])
$activeTab.set(newTabId)
})
}
const closeTab = (tabId: string) => {
const tabs_ = tabs()
if (tabs_.length === 1) return
const nextTabs = tabs_.filter(tab => tab.id !== tabId)
$tabs.set(nextTabs)
if (tabId === activeTab()) {
$activeTab.set(nextTabs[0].id)
}
queueMicrotask(updateIndicator)
}
const applyRename = (el: HTMLDivElement) => {
let newName = el.textContent
if (!newName) return
if (!newName.endsWith('.ts')) newName += '.ts'
newName = newName.replace(/[/';!@#$%^&*()\s]/g, '')
const tabs_ = tabs()
const ourTabIdx = tabs_.findIndex(tab => tab.id === renamingTabId())
if (ourTabIdx === -1) return
$tabs.set([
...tabs_.slice(0, ourTabIdx),
{
...tabs_[ourTabIdx],
fileName: newName,
},
...tabs_.slice(ourTabIdx + 1),
])
setRenamingTabId(undefined)
setTimeout(updateIndicator, 10)
}
const renderTab = (tab: EditorTab) => {
return (
<TabsTrigger
value={tab.id}
class="flex w-fit flex-row items-center gap-1 pl-2 pr-1"
onClick={() => $activeTab.set(tab.id)}
>
<div
class={clsx(
tab.id === renamingTabId() && 'cursor-text outline-none',
)}
onClick={(event: MouseEvent) => {
if (tab.id === renamingTabId()) {
event.stopPropagation()
return
}
if (tab.id === activeTab() && !tab.main) {
setRenamingTabId(tab.id)
event.stopPropagation()
const target = event.currentTarget as HTMLDivElement
target.focus()
const range = document.createRange()
range.selectNodeContents(target)
// range.collapse(false)
const sel = window.getSelection()!
sel.removeAllRanges()
sel.addRange(range)
}
}}
contentEditable={tab.id === renamingTabId()}
onKeyDown={(event: KeyboardEvent) => {
if (tab.id !== renamingTabId()) return
event.stopPropagation()
const target = event.currentTarget as HTMLDivElement
if (event.key === 'Enter') {
applyRename(target)
target.blur()
}
if (event.key === 'Escape') {
// cancel rename
setRenamingTabId(undefined)
target.textContent = tab.fileName
target.blur()
window.getSelection()?.removeAllRanges()
}
}}
onBlur={(event: FocusEvent) => {
if (tab.id === renamingTabId()) {
applyRename(event.currentTarget as HTMLDivElement)
}
}}
>
{tab.fileName}
</div>
{!tab.main && (
<Button
variant="ghost"
size="icon"
class="size-5"
onClick={(event: MouseEvent) => {
event.stopPropagation()
closeTab(tab.id)
}}
>
<LucideX size="0.75rem" />
</Button>
)}
</TabsTrigger>
)
}
return (
<Tabs
value={activeTab()}
// onChange={tab => setTabs('activeTab', tab)}
class={cn('max-w-full overflow-auto', props.class)}
ref={root}
>
<TabsList class="rounded-none border-b bg-transparent px-2">
<For each={tabs()}>
{renderTab}
</For>
<Button
variant="ghost"
size="icon"
class="ml-1 size-6 shrink-0"
onClick={createNewTab}
>
<LucidePlus size="0.75rem" />
</Button>
<TabsIndicator
variant="underline"
ref={indicator}
/>
</TabsList>
</Tabs>
)
}

View file

@ -0,0 +1,70 @@
import type { Diagnostic, ExportDeclaration, ModifierLike, VariableStatement } from 'typescript'
import { initialize, ts, TypeScriptWorker } from 'monaco-editor/esm/vs/language/typescript/ts.worker'
import { blankSourceFile } from 'ts-blank-space'
class CustomTypeScriptWorker extends TypeScriptWorker {
async processFile(uri: string, withExports?: boolean) {
const sourceFile = this.getLanguageService().getProgram()?.getSourceFile(uri)
if (!sourceFile) throw new Error(`File not found: ${uri}`)
const transformed = blankSourceFile(sourceFile)
const exports: string[] = []
if (withExports) {
for (const statement of sourceFile.statements) {
if (statement.kind === ts.typescript.SyntaxKind.ExportDeclaration) {
const exportDeclaration = statement as ExportDeclaration
const clause = exportDeclaration.exportClause
if (!clause || clause.kind !== ts.typescript.SyntaxKind.NamedExports) {
throw new Error('Invalid export declaration (export * is not supported)')
}
for (const element of clause.elements) {
if (element.kind === ts.typescript.SyntaxKind.ExportSpecifier) {
exports.push(element.name.getText())
}
}
} else if (
statement.kind === ts.typescript.SyntaxKind.VariableStatement
&& statement.modifiers?.some((it: ModifierLike) => it.kind === ts.typescript.SyntaxKind.ExportKeyword)) {
for (const declaration of (statement as VariableStatement).declarationList.declarations) {
exports.push(declaration.name.getText())
}
}
}
}
return {
transformed,
exports,
}
}
async getSyntacticDiagnostics(fileName: string): Promise<Diagnostic[]> {
const parent = await super.getSyntacticDiagnostics(fileName)
const sourceFile = this.getLanguageService().getProgram()?.getSourceFile(fileName)
if (!sourceFile) return parent
// there's probably a better way but ts-blank-space's own playground does this,
// and ts-blank-space is fast enough for this to not really matter (it basically just traverses the AST once)
blankSourceFile(sourceFile, (errorNode) => {
parent.push({
start: errorNode.getStart(),
length: errorNode.getWidth(),
messageText: `[ts-blank-space] Unsupported syntax: ${errorNode.getText()}`,
category: ts.typescript.DiagnosticCategory.Error,
code: 9999,
})
})
return parent
}
}
export type { CustomTypeScriptWorker }
// eslint-disable-next-line no-restricted-globals
self.onmessage = () => {
initialize((ctx: any, createData: any) => {
return new CustomTypeScriptWorker(ctx, createData)
})
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,88 @@
import type { VfsStorage } from '../../../lib/vfs/storage.ts'
import { asNonNull, asyncPool, utf8 } from '@fuman/utils'
import { editor, languages } from 'monaco-editor/esm/vs/editor/editor.api.js'
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import { wireTmGrammars } from 'monaco-editor-textmate'
import { Registry } from 'monaco-textmate'
import { loadWASM } from 'onigasm'
import onigasmWasm from 'onigasm/lib/onigasm.wasm?url'
import TypeScriptWorker from './custom-worker.ts?worker'
import latte from './latte.json'
import mocha from './mocha.json'
import typescriptTM from './typescript.tmLanguage.json'
window.MonacoEnvironment = {
getWorker: (_, label: string) => {
if (label === 'editorWorkerService') {
return new EditorWorker()
}
if (label === 'typescript') {
return new TypeScriptWorker()
}
throw new Error(`Unknown worker: ${label}`)
},
}
let loadingWasm: Promise<void>
const registry = new Registry({
async getGrammarDefinition() {
return {
format: 'json',
content: typescriptTM,
}
},
})
const grammars = new Map()
grammars.set('typescript', 'source.tsx')
grammars.set('javascript', 'source.tsx')
grammars.set('css', 'source.css')
editor.defineTheme('latte', latte as any)
editor.defineTheme('mocha', mocha as any)
const compilerOptions: languages.typescript.CompilerOptions = {
strict: true,
target: languages.typescript.ScriptTarget.ESNext,
module: languages.typescript.ModuleKind.ESNext,
moduleResolution: languages.typescript.ModuleResolutionKind.NodeJs,
moduleDetection: 3, // force
jsx: languages.typescript.JsxEmit.Preserve,
allowNonTsExtensions: true,
allowImportingTsExtensions: true,
}
languages.typescript.typescriptDefaults.setCompilerOptions(compilerOptions)
languages.typescript.javascriptDefaults.setCompilerOptions(compilerOptions)
export async function setupMonaco(vfs: VfsStorage) {
if (!loadingWasm) loadingWasm = loadWASM(onigasmWasm)
await loadingWasm
const libs = await vfs.getAvailableLibs()
const extraLibs: {
content: string
filePath?: string
}[] = []
await asyncPool(libs, async (lib) => {
const { files } = asNonNull(await vfs.readLibrary(lib))
for (const file of files) {
const { path, contents } = file
if (!path.endsWith('.d.ts')) continue
extraLibs.push({ content: utf8.decoder.decode(contents), filePath: `file:///node_modules/${lib}/${path}` })
}
})
extraLibs.push({
content: 'declare const tg: import("@mtcute/web").TelegramClient',
filePath: 'file:///tg.d.ts',
})
languages.typescript.typescriptDefaults.setExtraLibs(extraLibs)
await wireTmGrammars({ languages } as any, registry, grammars)
}

File diff suppressed because one or more lines are too long

13
src/components/editor/utils/worker.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
declare module 'monaco-editor/esm/vs/language/typescript/ts.worker' {
import type typescript from 'typescript'
export class TypeScriptWorker {
constructor(ctx: any, createData: any)
getCompilationSettings(): ts.CompilerOptions
getLanguageService(): ts.LanguageService
getSyntacticDiagnostics(fileName: string): Promise<ts.Diagnostic[]>
}
export function initialize(callback: (ctx: any, createData: any) => TypeScriptWorker): void
export const ts: { typescript: typeof typescript }
}

View file

@ -0,0 +1,547 @@
import type { BaseTelegramClient, SentCode, User } from '@mtcute/web'
import { base64 } from '@fuman/utils'
import { tl } from '@mtcute/web'
import { checkPassword, downloadAsBuffer, resendCode, sendCode, signIn, signInQr } from '@mtcute/web/methods.js'
import { LucideChevronRight } from 'lucide-solid'
import { createEffect, createSignal, For, Match, onCleanup, onMount, Show, Switch } from 'solid-js'
import { renderSVG } from 'uqr'
import { Avatar, AvatarFallback, AvatarImage, makeAvatarFallbackText } from '../../lib/components/ui/avatar.tsx'
import { Button } from '../../lib/components/ui/button.tsx'
import { OTPField, OTPFieldGroup, OTPFieldInput, OTPFieldSlot } from '../../lib/components/ui/otp-field.tsx'
import { Spinner } from '../../lib/components/ui/spinner.tsx'
import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../lib/components/ui/text-field.tsx'
import { TransitionSlideLtr } from '../../lib/components/ui/transition.tsx'
import { cn } from '../../lib/utils.ts'
import { PhoneInput } from './PhoneInput.tsx'
export type LoginStep =
| 'qr'
| 'phone'
| 'otp'
| 'password'
| 'done'
export interface StepContext {
qr: void
phone: void
otp: {
phone: string
code: SentCode
}
password: void
done: { user: User }
}
type StepProps<T extends LoginStep> = {
client: BaseTelegramClient
setStep: <T extends LoginStep>(step: T, data?: StepContext[T]) => void
} & (StepContext[T] extends void ? {} : { ctx: StepContext[T] })
function QrLoginStep(props: StepProps<'qr'>) {
const [qr, setQr] = createSignal('')
const [finalizing, setFinalizing] = createSignal(false)
const abortController = new AbortController()
onMount(() => {
signInQr(props.client, {
abortSignal: abortController.signal,
onUrlUpdated: qr => setQr(renderSVG(qr)),
onQrScanned: () => setFinalizing(true),
}).then((user) => {
props.setStep('done', { user })
}).catch((e) => {
setFinalizing(false)
if (tl.RpcError.is(e, 'SESSION_PASSWORD_NEEDED')) {
props.setStep('password')
} else if (abortController.signal.aborted) {
// ignore
} else {
throw e
}
})
})
onCleanup(() => abortController.abort())
return (
<div class="flex flex-col items-center justify-center gap-4">
<h2 class="mb-2 text-xl font-bold">
Log in with QR code
</h2>
<div class="flex size-40 flex-col items-center justify-center">
{qr() ? (
<div
class="size-40"
// eslint-disable-next-line solid/no-innerhtml
innerHTML={qr()}
/>
) : <Spinner indeterminate class="size-10" />}
</div>
<ol class="mt-4 list-inside list-decimal text-sm text-muted-foreground">
<li>Open Telegram on your phone</li>
<li>
Go to
{' '}
<b>Settings &gt; Devices &gt; Link Desktop Device</b>
</li>
<li>Point your phone at this screen to confirm login</li>
</ol>
<Button
variant="outline"
size="sm"
onClick={() => props.setStep('phone')}
disabled={finalizing()}
>
Log in with phone number
</Button>
</div>
)
}
function PhoneNumberStep(props: StepProps<'phone'>) {
const [phone, setPhone] = createSignal('')
const [error, setError] = createSignal<string | undefined>()
const [loading, setLoading] = createSignal(false)
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
const abortController = new AbortController()
const handleSubmit = () => {
setError(undefined)
setLoading(true)
sendCode(props.client, {
phone: phone(),
abortSignal: abortController.signal,
}).then((code) => {
setLoading(false)
props.setStep('otp', {
code,
phone: phone(),
})
}).catch((e) => {
setLoading(false)
if (abortController.signal.aborted) {
// ignore
} else {
setError(e.message)
}
})
}
onCleanup(() => abortController.abort())
createEffect(() => inputRef()?.focus())
return (
<div class="flex h-full flex-col items-center justify-center">
<div class="flex-1" />
<img
class="size-24"
src="https://mtcute.dev/mtcute-logo.svg"
alt="mtcute logo"
/>
<h2 class="mt-4 text-xl font-bold">
Log in with phone number
</h2>
<div class="mt-2 text-center text-sm text-muted-foreground">
Please confirm your country code
<br />
and enter your phone number
</div>
<TextFieldRoot class="mt-4" validationState={error() ? 'invalid' : 'valid'}>
<TextFieldLabel>Phone</TextFieldLabel>
<div class="flex flex-row">
<PhoneInput
class="w-[300px]"
client={props.client}
onChange={setPhone}
onSubmit={handleSubmit}
disabled={loading()}
ref={setInputRef}
/>
<Button
variant="default"
size="icon"
class="ml-2 size-9"
disabled={!phone() || loading()}
onClick={handleSubmit}
>
<LucideChevronRight class="size-5" />
</Button>
</div>
<TextFieldErrorMessage>{error()}</TextFieldErrorMessage>
</TextFieldRoot>
<div class="flex-1" />
<div class="text-center text-sm text-muted-foreground">
or,
{' '}
<a
href="#"
class="font-medium text-neutral-600 hover:underline"
onClick={() => props.setStep('qr')}
>
log in with QR code
</a>
</div>
</div>
)
}
function OtpStep(props: StepProps<'otp'>) {
const [otp, setOtp] = createSignal('')
const [error, setError] = createSignal<string | undefined>()
const [loading, setLoading] = createSignal(false)
const [countdown, setCountdown] = createSignal(0)
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
const abortController = new AbortController()
const handleSubmit = () => {
setError(undefined)
setLoading(true)
signIn(props.client, {
phone: props.ctx.phone,
phoneCodeHash: props.ctx.code.phoneCodeHash,
phoneCode: otp(),
abortSignal: abortController.signal,
}).then((user) => {
setLoading(false)
props.setStep('done', { user })
}).catch((e) => {
setLoading(false)
if (abortController.signal.aborted) {
// ignore
} else if (tl.RpcError.is(e, 'SESSION_PASSWORD_NEEDED')) {
props.setStep('password')
} else {
setError(e.message)
}
})
}
const handleResend = () => {
setError(undefined)
setLoading(true)
resendCode(props.client, {
phone: props.ctx.phone,
phoneCodeHash: props.ctx.code.phoneCodeHash,
abortSignal: abortController.signal,
}).then((code) => {
setLoading(false)
props.setStep('otp', {
code,
phone: props.ctx.phone,
})
}).catch((e) => {
setLoading(false)
if (abortController.signal.aborted) {
// ignore
} else {
setError(e.message)
}
})
}
const handleSetOtp = (otp: string) => {
setOtp(otp)
if (otp.length === props.ctx.code.length) {
handleSubmit()
}
}
onCleanup(() => abortController.abort())
onMount(() => {
setCountdown(props.ctx.code.timeout)
const interval = setInterval(() => {
setCountdown(countdown() - 1)
if (countdown() <= 0) {
clearInterval(interval)
setCountdown(0)
}
}, 1000)
onCleanup(() => clearInterval(interval))
})
createEffect(() => inputRef()?.focus())
const description = () => {
switch (props.ctx.code.type) {
case 'app':
return 'We have sent you a one-time code to your Telegram app'
case 'sms':
case 'sms_word':
case 'sms_phrase':
return 'We have sent you a one-time code to your phone'
case 'fragment':
return 'We have sent you a one-time code to your Fragment anonymous number'
case 'call':
return 'We are calling you to dictate your one-time code'
case 'flash_call':
case 'missed_call':
return `We are calling you, put the last ${props.ctx.code.length} digits of the number we're calling you from`
case 'email':
return 'We have sent you an email with a one-time code'
case 'email_required':
return 'Email setup is required, please do it in your Telegram app'
default:
return `Unknown code type: ${props.ctx.code.type}`
}
}
return (
<div class="flex flex-col items-center justify-center">
<h2 class="text-xl font-bold">
{props.ctx.phone}
</h2>
<div
class="cursor-pointer text-xs text-neutral-400 hover:underline"
onClick={() => props.setStep('phone')}
>
Wrong number?
</div>
<div class="mt-4 text-center text-sm text-muted-foreground">
{description()}
</div>
<div class="mt-4 flex flex-col">
<Show
when={props.ctx.code.type !== 'sms_phrase' && props.ctx.code.type !== 'sms_word'}
fallback={(
<TextFieldRoot class="mt-2" validationState={error() ? 'invalid' : 'valid'}>
<TextFieldFrame>
<TextField
disabled={loading()}
placeholder={props.ctx.code.beginning ? `${props.ctx.code.beginning}...` : undefined}
autocomplete="one-time-code"
value={otp()}
onInput={e => setOtp(e.currentTarget.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSubmit()
}
}}
ref={setInputRef}
/>
</TextFieldFrame>
<TextFieldErrorMessage>{error()}</TextFieldErrorMessage>
</TextFieldRoot>
)}
>
<OTPField
class="mt-2"
maxLength={props.ctx.code.length}
value={otp()}
onValueChange={handleSetOtp}
>
<OTPFieldInput
disabled={loading()}
ref={setInputRef}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSubmit()
}
}}
/>
<OTPFieldGroup>
<For each={Array.from({ length: props.ctx.code.length })}>
{(_, i) => (
<OTPFieldSlot
class={error() ? 'border-error-foreground' : ''}
index={i()}
/>
)}
</For>
</OTPFieldGroup>
</OTPField>
{error() && (
<div class="mt-1 text-sm text-error-foreground">
{error()}
</div>
)}
</Show>
<div class="mt-2 flex w-full justify-between">
<Button
variant="outline"
size="sm"
onClick={handleResend}
disabled={loading() || countdown() > 0}
>
{props.ctx.code.nextType === 'call' ? 'Call me' : 'Resend'}
{countdown() > 0 && (
` (${countdown()})`
)}
</Button>
<Button
variant="default"
size="sm"
onClick={handleSubmit}
disabled={loading()}
>
Next
</Button>
</div>
</div>
</div>
)
}
function PasswordStep(props: StepProps<'password'>) {
const [password, setPassword] = createSignal('')
const [error, setError] = createSignal<string | undefined>()
const [loading, setLoading] = createSignal(false)
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
// todo abort controller
const handleSubmit = () => {
if (!password()) {
setError('Password is required')
return
}
setError(undefined)
setLoading(true)
checkPassword(props.client, password())
.then((user) => {
setLoading(false)
props.setStep('done', { user })
})
.catch((e) => {
setLoading(false)
if (tl.RpcError.is(e, 'PASSWORD_HASH_INVALID')) {
setError('Incorrect password')
} else {
setError(e.message)
}
})
}
createEffect(() => inputRef()?.focus())
return (
<div class="flex flex-col items-center justify-center">
<h2 class="text-xl font-bold">
2FA password
</h2>
<div class="mt-4 text-center text-sm text-muted-foreground">
Your account is protected with an additional password.
</div>
<div class="mt-4 flex flex-col">
<TextFieldRoot validationState={error() ? 'invalid' : 'valid'}>
<TextFieldLabel>Password</TextFieldLabel>
<TextFieldFrame>
<TextField
type="password"
placeholder="Password"
autocomplete="current-password"
value={password()}
onInput={e => setPassword(e.currentTarget.value)}
disabled={loading()}
ref={setInputRef}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSubmit()
}
}}
/>
</TextFieldFrame>
<TextFieldErrorMessage>{error()}</TextFieldErrorMessage>
</TextFieldRoot>
<div class="mt-2 flex w-full justify-end">
<Button
variant="default"
size="sm"
onClick={handleSubmit}
disabled={loading()}
>
Next
</Button>
</div>
</div>
</div>
)
}
function DoneStep(props: StepProps<'done'>) {
const [avatar, setAvatar] = createSignal<string | null>(null)
onMount(() => {
if (!props.ctx.user.photo) {
props.client.close()
return
}
downloadAsBuffer(props.client, props.ctx.user.photo.big)
.then((buf) => {
const url = URL.createObjectURL(new Blob([buf], { type: 'image/jpeg' }))
setAvatar(url)
})
.catch((e) => {
console.error(e)
})
})
onCleanup(() => {
if (avatar()) {
URL.revokeObjectURL(avatar()!)
}
})
return (
<div class="flex flex-col items-center justify-center">
<Avatar class="mb-4 size-24 shadow-sm">
{props.ctx.user.photo && (
<>
{avatar() && <AvatarImage src={avatar()!} />}
<AvatarImage src={`data:image/jpeg;base64,${base64.encode(props.ctx.user.photo.thumb!)}`} />
</>
)}
<AvatarFallback class="text-xl">
{makeAvatarFallbackText(props.ctx.user.displayName)}
</AvatarFallback>
</Avatar>
<div class="text-center font-medium">
Welcome,
{' '}
{props.ctx.user.displayName}
!
</div>
</div>
)
}
export function LoginForm(props: {
class?: string
client: BaseTelegramClient
onStepChange?: (step: LoginStep, ctx: Partial<StepContext>) => void
}) {
const [step, setStep] = createSignal<LoginStep>('qr')
const [ctx, setCtx] = createSignal<Partial<StepContext>>({})
const setStepWithCtx = <T extends LoginStep>(step: T, data?: StepContext[T]) => {
setCtx(ctx => ({ ...ctx, [step]: data }))
setStep(step as LoginStep)
props.onStepChange?.(step, ctx())
}
return (
<div class={cn('flex h-full flex-col items-center justify-center gap-4', props.class)}>
<TransitionSlideLtr mode="outin">
<Switch>
<Match when={step() === 'qr'}>
<QrLoginStep client={props.client} setStep={setStepWithCtx} />
</Match>
<Match when={step() === 'phone'}>
<PhoneNumberStep client={props.client} setStep={setStepWithCtx} />
</Match>
<Match when={step() === 'otp'}>
<OtpStep client={props.client} setStep={setStepWithCtx} ctx={ctx().otp!} />
</Match>
<Match when={step() === 'password'}>
<PasswordStep client={props.client} setStep={setStepWithCtx} />
</Match>
<Match when={step() === 'done'}>
<DoneStep
client={props.client}
setStep={setStepWithCtx}
ctx={ctx().done!}
/>
</Match>
</Switch>
</TransitionSlideLtr>
</div>
)
}

View file

@ -0,0 +1,193 @@
import type { BaseTelegramClient, tl } from '@mtcute/web'
import { assert } from '@fuman/utils'
import { createSignal, onMount, Show } from 'solid-js'
import { CountryIcon } from '../../lib/components/country-icon.tsx'
import { TextField, TextFieldFrame } from '../../lib/components/ui/text-field.tsx'
import { cn } from '../../lib/utils.ts'
interface ChosenCode {
patterns?: string[]
countryCode: string
iso2: string
}
function mapCountryCode(country: tl.help.RawCountry, code: tl.help.RawCountryCode): ChosenCode {
return {
patterns: code.patterns,
countryCode: code.countryCode,
iso2: country.iso2,
}
}
interface PhoneInputProps {
class?: string
phone?: string
onChange?: (phone: string) => void
onSubmit?: () => void
client: BaseTelegramClient
disabled?: boolean
ref?: (el: HTMLInputElement) => void
}
export function PhoneInput(props: PhoneInputProps) {
const [countriesList, setCountriesList] = createSignal<tl.help.RawCountry[]>([])
const [chosenCode, setChosenCode] = createSignal<ChosenCode | undefined>()
const [inputValue, setInputValue] = createSignal('+')
onMount(() => {
Promise.all([
props.client.call({ _: 'help.getCountriesList', langCode: 'en', hash: 0 }),
props.client.call({ _: 'help.getNearestDc' }),
]).then(([countriesList, nearestDc]) => {
assert(countriesList._ === 'help.countriesList') // todo caching
setCountriesList(countriesList.countries)
if (inputValue() === '+') {
// guess the country code
for (const country of countriesList.countries) {
if (country.iso2 === nearestDc.country.toUpperCase()) {
setChosenCode(mapCountryCode(country, country.countryCodes[0]))
setInputValue(`+${country.countryCodes[0].countryCode} `)
break
}
}
}
})
})
const handleInput = (e: InputEvent) => {
const el = e.currentTarget as HTMLInputElement
const value = el.value
if (value === '' || value === '+') {
// country code was removed
setInputValue('+')
el.value = '+'
setChosenCode(undefined)
props.onChange?.('')
return
} else {
setInputValue(value)
}
// try to find matching country code
// first sanitize input
const rawPhone = value.slice(1).replace(/\D/g, '')
el.value = `+${value.replace(/[^\d ]/g, '')}`
// pass 1: find matching countries by country code
const matching: [tl.help.RawCountry, tl.help.RawCountryCode][] = []
let hasPrefixes = false
for (const country of countriesList()) {
for (const code of country.countryCodes) {
if (rawPhone.startsWith(code.countryCode)) {
matching.push([country, code])
}
if (code.prefixes) {
hasPrefixes = true
}
}
}
let chosenCode: ChosenCode | undefined
// if we have a prefix in some of the items, try to find matching countries by prefix
// (e.g. russia: +7<any>, kazakhstan: +77<any>)
if (hasPrefixes && matching.length > 1) {
// 1: find a match without a prefix
let match = matching.find(it => it[1].prefixes === undefined)
// 2: try to refine the match by prefix
let foundByPrefix = false
for (const item of matching) {
const code = item[1]
if (code.prefixes === undefined) continue
for (const prefix of code.prefixes) {
const fullPrefix = code.countryCode + prefix
if (rawPhone.startsWith(fullPrefix)) {
match = item
foundByPrefix = true
break
}
}
}
// 3: if we couldnt refine and the country code is the same as countryCode, do nothing
if (!foundByPrefix && match && match[1].countryCode === rawPhone) {
match = undefined
}
chosenCode = match ? mapCountryCode(match[0], match[1]) : undefined
} else if (matching.length === 1) {
chosenCode = mapCountryCode(matching[0][0], matching[0][1])
}
setChosenCode(chosenCode)
props.onChange?.(rawPhone)
if (chosenCode && chosenCode.patterns) {
// format the number
const numberWithoutCode = rawPhone.slice(chosenCode.countryCode.length)
for (const pattern of chosenCode.patterns) {
let numberIdx = 0
let formatted = ''
for (let i = 0; i < pattern.length; i++) {
const patternChar = pattern[i]
const numberChar = numberWithoutCode[numberIdx]
if (numberChar === undefined) break
if (patternChar.match(/\d/)) {
// these patterns are not supported (yet?)
break
} else if (patternChar === ' ') {
formatted += '-'
} else if (patternChar === 'X') {
formatted += numberChar
numberIdx++
} else {
console.warn('Unexpected pattern char %s in %s', patternChar, pattern)
break
}
}
if (formatted && numberIdx === numberWithoutCode.length) {
el.value = `+${chosenCode.countryCode} ${formatted}`
break
}
}
}
}
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === 'Enter' && chosenCode() !== undefined) {
props.onSubmit?.()
}
}
return (
<TextFieldFrame class={cn('flex items-center', props.class)}>
<Show
when={chosenCode()}
fallback={(
<div class="w-6">
🏳
</div>
)}
>
<CountryIcon class="mt-0.5 w-6 select-none" country={chosenCode()!.iso2} />
</Show>
<TextField
class="ml-0.5 w-full"
value={inputValue()}
onInput={handleInput}
onKeyPress={handleKeyPress}
autocomplete="off"
disabled={props.disabled}
ref={props.ref}
/>
</TextFieldFrame>
)
}

View file

@ -0,0 +1,98 @@
import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu'
import { ChevronDownIcon, ExternalLinkIcon, LucideCheck, SettingsIcon, UsersIcon } from 'lucide-solid'
import { SiGithub } from 'solid-icons/si'
import { For } from 'solid-js'
import { Button } from '../../lib/components/ui/button.tsx'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuGroupLabel,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../../lib/components/ui/dropdown-menu.tsx'
import { cn } from '../../lib/utils.ts'
import { $accounts, $activeAccount, $activeAccountId } from '../../store/accounts.ts'
import { useStore } from '../../store/use-store.ts'
import { AccountAvatar } from '../AccountAvatar.tsx'
export function NavbarMenu(props: {
onShowAccounts: () => void
onShowSettings: () => void
}) {
const activeAccount = useStore($activeAccount)
const accounts = useStore($accounts)
return (
<DropdownMenu>
<DropdownMenuTrigger
as={(props: DropdownMenuTriggerProps) => (
<Button
class="z-10 ml-auto px-2"
variant="ghost"
{...props}
>
{activeAccount() != null && (
<>
<AccountAvatar class="size-6" account={activeAccount()!} />
<span class="ml-2 max-w-[120px] truncate">
{activeAccount()!.name}
</span>
<ChevronDownIcon class="ml-2 size-4" />
</>
)}
</Button>
)}
/>
<DropdownMenuContent class="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuGroupLabel>Accounts</DropdownMenuGroupLabel>
<For each={accounts()}>
{account => (
<DropdownMenuItem onClick={() => $activeAccountId.set(account.id)}>
<AccountAvatar
class={cn(
'size-4',
account.id !== activeAccount()?.id && 'opacity-50',
)}
account={account}
/>
<span
class={cn(
'ml-2',
account.id === activeAccount()?.id ? 'font-semibold' : 'text-muted-foreground',
)}
>
{account.name}
</span>
{account.id === activeAccount()?.id && <LucideCheck class="ml-auto size-4" />}
</DropdownMenuItem>
)}
</For>
<DropdownMenuItem onClick={props.onShowAccounts}>
<UsersIcon class="mr-2 size-4" />
Manage accounts
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={props.onShowSettings}>
<SettingsIcon class="mr-2 size-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem
as="a"
class="cursor-pointer"
href="https://github.com/mtcute/repl"
target="_blank"
>
<SiGithub class="mr-2 size-4" />
GitHub
<ExternalLinkIcon class="ml-auto size-4" />
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -0,0 +1,19 @@
import { createSignal } from 'solid-js'
export function Actions(props: {
class?: string
devtoolsIframe: HTMLIFrameElement | undefined
}) {
const [running, setRunning] = createSignal(false)
return (
<div class={props.class}>
{/* <iframe
ref={setRunnerIframe}
src="about:blank"
class="size-full"
/> */}
</div>
)
}

View file

@ -0,0 +1,84 @@
import { createEffect, createSignal, onCleanup } from 'solid-js'
import { cn } from '../../lib/utils.ts'
const HTML = `
<!DOCTYPE html>
<html lang="en">
<meta charset="utf-8">
<title>DevTools</title>
<meta name="referrer" content="no-referrer">
<script src="https://unpkg.com/@ungap/custom-elements/es.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/chii@1.12.3/public/front_end/entrypoints/chii_app/chii_app.js"></script>
<body class="undocked" id="-blink-dev-tools">
`
const INJECTED_SCRIPT = `
async function waitForElement(selector, container) {
let tabbedPane;
while(!tabbedPane) {
tabbedPane = container.querySelector(selector);
if (!tabbedPane) await new Promise(resolve => setTimeout(resolve, 50));
}
return tabbedPane;
}
function hideBySelector(root, selector) {
const el = root.shadowRoot.querySelector(selector);
if (!el) return;
el.style.display = 'none';
}
function focusConsole(tabbedPane) {
const consoleTab = tabbedPane.shadowRoot.querySelector('#tab-console');
// tabs get focused on mousedown instead of click
consoleTab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
consoleTab.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
}
(async ()=> {
const tabbedPane = await waitForElement('.tabbed-pane', document.body);
focusConsole(tabbedPane);
hideBySelector(tabbedPane, '.tabbed-pane-header');
const consoleToolbar = await waitForElement('.console-main-toolbar', document.body);
hideBySelector(consoleToolbar, 'devtools-issue-counter');
})();
`
export function Devtools(props: {
class?: string
iframeRef?: (el: HTMLIFrameElement) => void
}) {
const [innerRef, setInnerRef] = createSignal<HTMLIFrameElement | undefined>()
const url = URL.createObjectURL(new Blob([HTML], { type: 'text/html' }))
onCleanup(() => URL.revokeObjectURL(url))
createEffect(() => {
const _innerRef = innerRef()
if (_innerRef) {
props.iframeRef?.(_innerRef)
}
})
function handleLoad() {
const _innerRef = innerRef()
if (!_innerRef) return
const script = document.createElement('script')
script.textContent = INJECTED_SCRIPT
_innerRef.contentWindow?.document.body.appendChild(script)
}
return (
<div class={cn('relative', props.class)}>
<iframe
ref={setInnerRef}
src={`${url}#?embedded=${encodeURIComponent(location.origin)}`}
title="Devtools"
class="absolute inset-0 block size-full"
onLoad={handleLoad}
/>
</div>
)
}

View file

@ -0,0 +1,235 @@
import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu'
import type { ConnectionState } from '@mtcute/web'
import type { CustomTypeScriptWorker } from '../editor/utils/custom-worker.ts'
import { LucideCheck, LucidePlay, LucidePlug, LucideRefreshCw, LucideUnplug } from 'lucide-solid'
import { languages, Uri } from 'monaco-editor/esm/vs/editor/editor.api.js'
import { nanoid } from 'nanoid'
import { createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
import { Button } from '../../lib/components/ui/button.tsx'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuGroupLabel, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '../../lib/components/ui/dropdown-menu.tsx'
import { cn } from '../../lib/utils.ts'
import { $activeAccountId } from '../../store/accounts.ts'
import { $tabs } from '../../store/tabs.ts'
import { useStore } from '../../store/use-store.ts'
import { swForgetScript, swUploadScript } from '../../sw/client.ts'
import { Devtools } from './Devtools.tsx'
export function Runner() {
const [devtoolsIframe, setDevtoolsIframe] = createSignal<HTMLIFrameElement | undefined>()
const [runnerLoaded, setRunnerLoaded] = createSignal(false)
const [running, setRunning] = createSignal(false)
const [connectionState, setConnectionState] = createSignal<ConnectionState>('offline')
const currentAccountId = useStore($activeAccountId)
let currentScriptId: string | undefined
let runnerIframeRef!: HTMLIFrameElement
function handleMessage(e: MessageEvent) {
if (e.source === runnerIframeRef.contentWindow) {
// event from runner iframe
switch (e.data.event) {
case 'TO_DEVTOOLS': {
devtoolsIframe()?.contentWindow!.postMessage(e.data.value, '*')
break
}
case 'SCRIPT_END': {
setRunning(false)
swForgetScript(currentScriptId!)
currentScriptId = undefined
break
}
case 'CONNECTION_STATE': {
setConnectionState(e.data.value)
break
}
}
return
}
if (e.source === devtoolsIframe()?.contentWindow) {
const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data
if (data.method === 'Page.getResourceTree' && !runnerLoaded()) {
// for some reason, responding to this method is required for console.log to work
// and since our runner might not be loaded yet, noone will respond to this message
e.source!.postMessage(JSON.stringify({ id: data.id, result: {} }), '*')
return
}
if (data.method === 'Page.reload') {
location.reload()
return
}
runnerIframeRef.contentWindow!.postMessage({ event: 'FROM_DEVTOOLS', value: e.data }, '*')
}
}
onMount(async () => {
window.addEventListener('message', handleMessage)
})
onCleanup(() => {
window.removeEventListener('message', handleMessage)
})
createEffect(on(currentAccountId, (accountId) => {
if (!runnerLoaded()) return
runnerIframeRef.contentWindow!.postMessage({
event: 'ACCOUNT_CHANGED',
accountId,
}, '*')
}, { defer: true }))
async function handleRun() {
const getWorker = await languages.typescript.getTypeScriptWorker()
const worker = await getWorker(Uri.parse('file:///main.ts')) as unknown as CustomTypeScriptWorker
const tabs = $tabs.get()
const processed = await Promise.all(tabs.map(tab => worker.processFile(`file:///${tab.fileName}`, tab.main)))
const files: Record<string, string> = {}
let exports: string[] | undefined
for (let i = 0; i < processed.length; i++) {
const processedFile = processed[i]
const tab = tabs[i]
files[tab.fileName.replace(/\.ts$/, '.js')] = processedFile.transformed
if (tab.main) {
exports = processedFile.exports
}
}
currentScriptId = nanoid()
await swUploadScript(currentScriptId, files)
runnerIframeRef.contentWindow!.postMessage({
event: 'RUN',
scriptId: currentScriptId,
exports,
}, '*')
setRunning(true)
}
function handleDisconnect() {
runnerIframeRef.contentWindow!.postMessage({ event: 'DISCONNECT' }, '*')
}
function handleConnect() {
runnerIframeRef.contentWindow!.postMessage({ event: 'RECONNECT' }, '*')
}
function handleRestart() {
runnerIframeRef.contentWindow!.location.reload()
}
return (
<>
<div class="flex shrink-0 flex-row p-1">
<iframe
class="invisible size-0"
src="/sw/runtime/_iframe.html"
ref={runnerIframeRef}
onLoad={() => {
runnerIframeRef.contentWindow!.postMessage({
event: 'INIT',
accountId: currentAccountId(),
}, '*')
setRunnerLoaded(true)
}}
/>
<div class="flex w-full grow-0 flex-row">
<Button
variant="ghost"
size="xs"
onClick={handleRun}
disabled={!runnerLoaded() || running()}
>
<LucidePlay
class="mr-2 size-3"
/>
Run
</Button>
<div class="flex-1" />
<DropdownMenu>
<DropdownMenuTrigger
as={(props: DropdownMenuTriggerProps) => (
<Button
variant="ghost"
size="xs"
{...props}
>
{{
offline: 'Disconnected',
connecting: 'Connecting...',
updating: 'Updating...',
connected: 'Ready',
}[connectionState()]}
<div class={cn(
'ml-2 size-2 rounded-full',
{
connected: 'bg-green-500',
updating: 'bg-yellow-500',
connecting: 'bg-yellow-500',
offline: 'bg-neutral-400',
}[connectionState()],
)}
/>
</Button>
)}
/>
<DropdownMenuContent>
<DropdownMenuItem
onClick={connectionState() === 'offline' ? handleConnect : handleDisconnect}
class="text-xs"
>
{connectionState() === 'offline' ? (
<LucidePlug
class="mr-2 size-3"
/>
) : (
<LucideUnplug
class="mr-2 size-3"
/>
)}
{connectionState() === 'offline' ? 'Connect' : 'Disconnect'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleRestart}
class="text-xs"
>
<LucideRefreshCw
class="mr-2 size-3"
/>
Restart runner
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuGroupLabel class="text-xs">
Auto-disconnect after
</DropdownMenuGroupLabel>
<DropdownMenuItem class="text-xs">
1 minute
<LucideCheck class="ml-auto size-3" />
</DropdownMenuItem>
<DropdownMenuItem class="text-xs">
5 minutes
</DropdownMenuItem>
<DropdownMenuItem class="text-xs">
15 minutes
</DropdownMenuItem>
<DropdownMenuItem class="text-xs">
Never
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div class="h-px shrink-0 bg-border" />
<Devtools
class="size-full grow-0"
iframeRef={setDevtoolsIframe}
/>
</>
)
}

View file

@ -0,0 +1,132 @@
import { TelegramClient } from '@mtcute/web?external'
type ConnectionState = import('@mtcute/web').ConnectionState
type TelegramClientOptions = import('@mtcute/web').TelegramClientOptions
declare const chobitsu: any
declare const window: typeof globalThis & {
__currentScript: any
__handleScriptEnd: (error: any) => void
tg: import('@mtcute/web').TelegramClient
}
function sendToDevtools(message: any) {
window.parent.postMessage({ event: 'TO_DEVTOOLS', value: message }, '*')
}
function sendToChobitsu(message: any) {
message.id = `__chobitsu_manual__${Date.now()}`
chobitsu.sendRawMessage(JSON.stringify(message))
}
chobitsu.setOnMessage((message: string) => {
if (message.includes('__chobitsu_manual__')) return
sendToDevtools(message)
})
let lastAccountId: string | undefined
let lastConnectionState: ConnectionState | undefined
function initClient(accountId: string) {
lastAccountId = accountId
let extraConfig: Partial<TelegramClientOptions> | undefined
const storedAccounts = localStorage.getItem('repl:accounts')
if (storedAccounts) {
const accounts = JSON.parse(storedAccounts)
const ourAccount = accounts.find((it: any) => it.id === accountId)
if (ourAccount && ourAccount.testMode) {
extraConfig = {
testMode: true,
}
}
}
window.tg = new TelegramClient({
apiId: import.meta.env.VITE_API_ID,
apiHash: import.meta.env.VITE_API_HASH,
storage: `mtcute:${accountId}`,
...extraConfig,
})
window.tg.onConnectionState.add((state) => {
lastConnectionState = state
window.parent.postMessage({ event: 'CONNECTION_STATE', value: state }, '*')
})
}
window.addEventListener('message', ({ data }) => {
if (data.event === 'INIT') {
sendToDevtools({
method: 'Page.frameNavigated',
params: {
frame: {
id: '1',
mimeType: 'text/html',
securityOrigin: location.origin,
url: location.href,
},
type: 'Navigation',
},
})
sendToDevtools({ method: 'Runtime.executionContextsCleared' })
sendToChobitsu({ method: 'Runtime.enable' })
sendToChobitsu({ method: 'DOMStorage.enable' })
sendToDevtools({ method: 'DOM.documentUpdated' })
initClient(data.accountId)
window.tg.connect()
} else if (data.event === 'RUN') {
const el = document.createElement('script')
el.type = 'module'
let script = `import * as result from "/sw/runtime/script/${data.scriptId}/main.js";`
for (const exportName of data.exports ?? []) {
script += `window.${exportName} = result.${exportName};`
}
if (data.exports?.length) {
script += `console.log("[mtcute-repl] Script ended, exported variables: ${data.exports.join(', ')}");`
} else {
script += 'console.log("[mtcute-repl] Script ended");'
}
script += 'window.__handleScriptEnd();'
el.textContent = script
el.addEventListener('error', e => window.__handleScriptEnd(e.error))
window.__currentScript = el
document.body.appendChild(el)
} else if (data.event === 'FROM_DEVTOOLS') {
chobitsu.sendRawMessage(data.value)
} else if (data.event === 'ACCOUNT_CHANGED') {
window.tg?.close()
initClient(data.accountId)
if (lastConnectionState !== 'offline') {
window.parent.postMessage({ event: 'CONNECTION_STATE', value: 'offline' }, '*')
window.tg.connect()
}
} else if (data.event === 'DISCONNECT') {
// todo: we dont have a clean way to disconnect i think
window.tg?.close()
if (lastAccountId) {
initClient(lastAccountId)
}
window.parent.postMessage({ event: 'CONNECTION_STATE', value: 'offline' }, '*')
} else if (data.event === 'RECONNECT') {
window.tg.connect()
}
})
window.__handleScriptEnd = (error) => {
if (!window.__currentScript) return
window.parent.postMessage({ event: 'SCRIPT_END', error }, '*')
window.__currentScript.remove()
window.__currentScript = undefined
}
window.addEventListener('error', (e) => {
if (window.__currentScript) {
window.__handleScriptEnd(e.error)
}
})

View file

@ -0,0 +1,287 @@
import type { BaseTelegramClient, User } from '@mtcute/web'
import type { TelegramAccount } from '../../store/accounts.ts'
import type { LoginStep, StepContext } from '../login/Login.tsx'
import { timers } from '@fuman/utils'
import {
LucideBot,
LucideEllipsis,
LucideLogIn,
LucidePlus,
LucideSearch,
LucideTrash,
LucideUser,
LucideX,
} from 'lucide-solid'
import { nanoid } from 'nanoid'
import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from 'solid-js'
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 { TextField, TextFieldFrame, TextFieldRoot } from '../../lib/components/ui/text-field.tsx'
import { createInternalClient, deleteAccount } from '../../lib/telegram.ts'
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 { LoginForm } from '../login/Login.tsx'
import { ImportDropdown } from './import/ImportDropdown.tsx'
function AddAccountDialog(props: {
show: boolean
testMode: boolean
onClose: () => void
onAccountCreated: (accountId: string, user: User, dcId: number) => void
}) {
const [client, setClient] = createSignal<BaseTelegramClient | undefined>(undefined)
let accountId: string
let closeTimeout: timers.Timer | undefined
let finished = false
function handleOpenChange(open: boolean) {
if (open) {
finished = false
} else {
props.onClose()
client()?.close()
timers.clearTimeout(closeTimeout)
}
}
async function handleStepChange(step: LoginStep, ctx: Partial<StepContext>) {
if (step === 'done') {
finished = true
const client_ = client()!
const dcs = await client_.mt.storage.dcs.fetch()
props.onAccountCreated?.(accountId, ctx.done!.user, dcs?.main.id ?? 2)
closeTimeout = timers.setTimeout(() => {
props.onClose()
client_.close()
}, 2500)
}
}
createEffect(on(() => props.show, async (show) => {
if (!show) {
if (!finished && accountId) {
await client()?.close()
await deleteAccount(accountId)
}
}
accountId = nanoid()
setClient(createInternalClient(accountId, props.testMode))
}))
onCleanup(() => {
timers.clearTimeout(closeTimeout)
client()?.close()
})
return (
<Dialog
open={props.show}
onOpenChange={handleOpenChange}
>
<DialogContent>
{props.testMode && (
<Badge class="absolute left-4 top-4" variant="secondary">
Test server
</Badge>
)}
<div class="flex h-[420px] flex-col justify-center">
{client() && (
<LoginForm
client={client()!}
onStepChange={handleStepChange}
/>
)}
</div>
</DialogContent>
</Dialog>
)
}
function AccountRow(props: {
account: TelegramAccount
active: boolean
onSetActive: () => void
}) {
return (
<div class="flex max-w-full flex-row overflow-hidden rounded-md border border-border p-2">
<AccountAvatar
class="mr-2 size-9 rounded-sm shadow-sm"
account={props.account}
/>
<div class="flex max-w-full flex-col overflow-hidden">
<div class="flex items-center gap-1 truncate text-sm font-medium">
{props.account.bot ? <LucideBot class="size-4 shrink-0" /> : <LucideUser class="size-4 shrink-0" />}
{props.account.name}
{props.account.testMode && (
<Badge class="h-4 text-xs font-normal" variant="secondary">
Test server
</Badge>
)}
{props.active && (
<Badge
size="sm"
variant="secondary"
class="ml-1"
>
Active
</Badge>
)}
</div>
<div class="flex items-center text-xs text-muted-foreground">
ID:
{' '}
{props.account.telegramId}
{' • '}
DC:
{' '}
{props.account.dcId}
</div>
</div>
<div class="flex-1" />
<div class="mr-1 flex items-center gap-1">
<Button
variant="ghost"
size="icon"
class="size-8"
>
<LucideEllipsis class="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-8"
disabled={props.active}
onClick={props.onSetActive}
>
<LucideLogIn class="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-8 hover:bg-error"
>
<LucideTrash class="size-4 text-error-foreground" />
</Button>
</div>
</div>
)
}
export function AccountsTab() {
const accounts = useStore($accounts)
const activeAccountId = useStore($activeAccountId)
const [showAddAccount, setShowAddAccount] = createSignal(false)
const [addAccountTestMode, setAddAccountTestMode] = createSignal(false)
const [searchQuery, setSearchQuery] = createSignal('')
function handleAddAccount(e: MouseEvent) {
setShowAddAccount(true)
setAddAccountTestMode(e.ctrlKey || e.metaKey)
}
function handleAccountCreated(accountId: string, user: User, dcId: number) {
$accounts.set([
...$accounts.get(),
{
id: accountId,
name: user.displayName,
telegramId: user.id,
bot: user.isBot,
testMode: addAccountTestMode(),
dcId,
},
])
$activeAccountId.set(accountId)
}
const filteredAccounts = createMemo(() => {
const query = searchQuery().toLowerCase().trim()
if (query === '') {
return accounts()
}
return accounts().filter((account) => {
return account.name.toLowerCase().includes(query) || account.telegramId.toString().includes(query)
})
})
return (
<>
<Show
when={accounts().length !== 0}
fallback={(
<div class="flex h-full flex-col items-center justify-center gap-4 text-muted-foreground">
No accounts yet
<Button
variant="outline"
size="sm"
onClick={handleAddAccount}
>
<LucidePlus class="mr-2 size-4" />
Log in
</Button>
</div>
)}
>
<div class="max-w-full overflow-hidden p-2">
<div class="mb-2 flex h-8 items-center gap-2">
<TextFieldRoot>
<TextFieldFrame class="h-7 items-center shadow-none">
<LucideSearch class="mr-2 size-3 shrink-0 text-muted-foreground" />
<TextField
placeholder="Search"
value={searchQuery()}
onInput={e => setSearchQuery(e.currentTarget.value)}
/>
<LucideX
class={cn(
'shrink-0 text-muted-foreground size-3 cursor-pointer hover:text-foreground',
searchQuery() === '' && 'opacity-0 pointer-events-none',
)}
onClick={() => setSearchQuery('')}
/>
</TextFieldFrame>
</TextFieldRoot>
<Button
variant="outline"
size="xs"
onClick={handleAddAccount}
>
<LucidePlus class="mr-2 size-3" />
Log in
</Button>
<ImportDropdown />
</div>
<div class="flex max-w-full flex-col gap-1 overflow-hidden">
<For each={filteredAccounts()}>
{account => (
<AccountRow
account={account}
active={account.id === activeAccountId()}
onSetActive={() => $activeAccountId.set(account.id)}
/>
)}
</For>
</div>
</div>
</Show>
<AddAccountDialog
show={showAddAccount()}
testMode={addAccountTestMode()}
onClose={() => setShowAddAccount(false)}
onAccountCreated={handleAccountCreated}
/>
</>
)
}

View file

@ -0,0 +1,83 @@
import type { JSX } from 'solid-js'
import { LucideCode, LucideLibrary, LucideUsers } from 'lucide-solid'
import { For } from 'solid-js'
import { Button } from '../../lib/components/ui/button.tsx'
import {
Dialog,
DialogContent,
} from '../../lib/components/ui/dialog.tsx'
import { cn } from '../../lib/utils.ts'
import { AccountsTab } from './AccountsTab.tsx'
export type SettingsTab =
| 'accounts'
| 'libraries'
| 'about'
interface TabDefinition {
id: SettingsTab
title: string
icon: (props: { class?: string }) => JSX.Element
content: () => JSX.Element
}
const tabs: Array<TabDefinition> = [
{
id: 'accounts',
title: 'Accounts',
icon: LucideUsers,
content: AccountsTab,
},
{
id: 'libraries',
title: 'Libraries',
icon: LucideLibrary,
content: () => <div>asd</div>,
},
{
id: 'about',
title: 'About',
icon: LucideCode,
content: () => <div>asd</div>,
},
]
export function SettingsDialog(props: {
show: boolean
onClose: () => void
tab: SettingsTab
onTabChange: (tab: SettingsTab) => void
}) {
return (
<Dialog
open={props.show}
onOpenChange={open => !open && props.onClose()}
>
<DialogContent class="h-[calc(100vh-96px)] w-[calc(100vw-96px)] max-w-[960px] overflow-auto p-0">
<div class="flex max-w-full flex-row overflow-hidden">
<div class="flex h-full w-60 flex-col gap-1 p-2">
<For each={tabs}>
{tab => (
<Button
variant="ghost"
size="sm"
onClick={() => props.onTabChange(tab.id)}
class={cn(
'text-left justify-start text-sm h-8 text-muted-foreground',
props.tab === tab.id && 'bg-accent text-accent-foreground',
)}
>
<tab.icon class="mr-2 size-4" />
{tab.title}
</Button>
)}
</For>
</div>
<div class="w-px bg-border" />
<div class="size-full max-w-full overflow-hidden">
{tabs.find(tab => tab.id === props.tab)?.content()}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,160 @@
import type { InputStringSessionData } from '@mtcute/web/utils.js'
import { hex } from '@fuman/utils'
import { DC_MAPPING_PROD, DC_MAPPING_TEST } from '@mtcute/convert'
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 { deleteAccount, importAccount } from '../../../lib/telegram.ts'
import { $accounts } from '../../../store/accounts.ts'
export function AuthKeyImportDialog(props: {
open: boolean
onClose: () => void
}) {
const [authKeyInputRef, setAuthKeyInputRef] = createSignal<HTMLTextAreaElement | undefined>()
const [dcId, setDcId] = createSignal<string>('2')
const [testMode, setTestMode] = createSignal(false)
const [error, setError] = createSignal<string | undefined>()
const [loading, setLoading] = createSignal(false)
let abortController: AbortController | undefined
const handleSubmit = async () => {
if (!['1', '2', '4', '5'].includes(dcId())) {
setError('Invalid datacenter ID (must be 1, 2, 4 or 5)')
return
}
abortController?.abort()
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
}
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) {
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>
Import auth key
</DialogHeader>
<DialogDescription>
<TextFieldRoot>
<TextFieldLabel class="text-foreground">
Datacenter ID
</TextFieldLabel>
<TextFieldFrame>
<TextField
class="w-full"
value={dcId()}
onInput={e => setDcId(e.currentTarget.value.replace(/\D/g, ''))}
/>
</TextFieldFrame>
</TextFieldRoot>
<TextFieldRoot class="mt-2" validationState={error() ? 'invalid' : 'valid'}>
<TextFieldLabel class="flex flex-row items-center justify-between text-foreground">
Hex-encoded auth key
<a
href="#"
class="text-xs font-normal text-muted-foreground hover:text-neutral-900"
onClick={() => {
navigator.clipboard.readText().then((text) => {
const input = authKeyInputRef()!
input.value = text
input.focus()
})
}}
>
paste
</a>
</TextFieldLabel>
<TextFieldFrame class="h-auto">
<TextField
class="size-full h-40 resize-none font-mono"
as="textarea"
ref={setAuthKeyInputRef}
onInput={() => setError(undefined)}
/>
</TextFieldFrame>
<TextFieldErrorMessage>
{error()}
</TextFieldErrorMessage>
</TextFieldRoot>
<Checkbox
class="mt-2 flex flex-row items-center gap-2"
checked={testMode()}
onChange={setTestMode}
>
<CheckboxControl />
<CheckboxLabel class="text-foreground">
Use test servers
</CheckboxLabel>
</Checkbox>
<Button
class="mt-6 w-full"
size="sm"
onClick={handleSubmit}
disabled={loading()}
>
{loading() ? 'Checking...' : 'Import'}
</Button>
</DialogDescription>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,85 @@
import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu'
import type { StringSessionLibName } from './StringSessionImportDialog.tsx'
import { LucideChevronRight, LucideDownload, LucideKeyRound, LucideLaptop, LucideTextCursorInput } from 'lucide-solid'
import { createSignal, For } from 'solid-js'
import { Button } from '../../../lib/components/ui/button.tsx'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '../../../lib/components/ui/dropdown-menu.tsx'
import { AuthKeyImportDialog } from './AuthKeyImportDialog.tsx'
import { StringSessionDefs, StringSessionImportDialog } from './StringSessionImportDialog.tsx'
export function ImportDropdown() {
const [showImportStringSession, setShowImportStringSession] = createSignal(false)
const [stringSessionLibName, setStringSessionLibName] = createSignal<StringSessionLibName>('mtcute')
const [showImportAuthKey, setShowImportAuthKey] = createSignal(false)
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
as={(props: DropdownMenuTriggerProps) => (
<Button
variant="outline"
size="xs"
{...props}
>
<LucideDownload class="mr-2 size-3" />
Import
</Button>
)}
/>
<DropdownMenuContent>
<DropdownMenuItem class="py-1 text-xs" onClick={() => setShowImportAuthKey(true)}>
<LucideKeyRound class="mr-2 size-3.5 stroke-[1.5px]" />
Auth key
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger class="py-1 text-xs">
<LucideTextCursorInput class="mr-2 size-3.5 stroke-[1.5px]" />
String session
<LucideChevronRight class="ml-2 size-3.5" />
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<For each={StringSessionDefs}>
{def => (
<DropdownMenuItem
class="py-1 text-xs"
onClick={() => {
setStringSessionLibName(def.name)
setShowImportStringSession(true)
}}
>
{def.displayName}
</DropdownMenuItem>
)}
</For>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem class="py-1 text-xs">
<LucideLaptop class="mr-2 size-3.5 stroke-[1.5px]" />
Desktop (tdata)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<StringSessionImportDialog
open={showImportStringSession()}
onClose={() => setShowImportStringSession(false)}
chosenLibName={stringSessionLibName()}
onChosenLibName={setStringSessionLibName}
/>
<AuthKeyImportDialog
open={showImportAuthKey()}
onClose={() => setShowImportAuthKey(false)}
/>
</>
)
}

View file

@ -0,0 +1,187 @@
import type { StringSessionData } from '@mtcute/web/utils.js'
import { readStringSession } from '@mtcute/web/utils.js'
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 { deleteAccount, importAccount } from '../../../lib/telegram.ts'
import { $accounts } from '../../../store/accounts.ts'
export type StringSessionLibName =
| 'mtcute'
| 'pyrogram'
| 'telethon'
| 'mtkruto'
| 'gramjs'
export const StringSessionDefs: {
name: StringSessionLibName
displayName: string
}[] = [
{ name: 'mtcute', displayName: 'mtcute' },
{ name: 'telethon', displayName: 'Telethon v1.x' },
{ name: 'gramjs', displayName: 'GramJS' },
{ name: 'pyrogram', displayName: 'Pyrogram' },
{ 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
chosenLibName: StringSessionLibName
onChosenLibName: (name: StringSessionLibName) => void
}) {
const [inputRef, setInputRef] = createSignal<HTMLTextAreaElement | undefined>()
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)
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,
])
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>
Import string session
</DialogHeader>
<DialogDescription>
<div class="mb-1 font-medium text-primary">
Library
</div>
<Select
options={StringSessionDefs}
optionValue="name"
optionTextValue="displayName"
value={StringSessionDefs.find(def => def.name === props.chosenLibName)}
onChange={e => e && props.onChosenLibName(e.name)}
itemComponent={props => <SelectItem item={props.item}>{props.item.rawValue.displayName}</SelectItem>}
>
<SelectTrigger>
<SelectValue<typeof StringSessionDefs[number]>>
{state => state.selectedOption()?.displayName}
</SelectValue>
</SelectTrigger>
<SelectContent />
</Select>
<TextFieldRoot class="mt-4" validationState={error() ? 'invalid' : 'valid'}>
<TextFieldLabel class="flex flex-row items-center justify-between text-foreground">
Session string
<a
href="#"
class="text-xs font-normal text-muted-foreground hover:text-neutral-900"
onClick={() => {
navigator.clipboard.readText().then((text) => {
const input = inputRef()!
input.value = text
input.focus()
})
}}
>
paste
</a>
</TextFieldLabel>
<TextFieldFrame class="h-auto">
<TextField
class="size-full h-40 resize-none font-mono"
as="textarea"
ref={setInputRef}
onInput={() => setError(undefined)}
/>
</TextFieldFrame>
<TextFieldErrorMessage>
{error()}
</TextFieldErrorMessage>
</TextFieldRoot>
<Button
class="mt-6 w-full"
size="sm"
onClick={handleSubmit}
disabled={loading()}
>
{loading() ? 'Checking...' : 'Import'}
</Button>
</DialogDescription>
</DialogContent>
</Dialog>
)
}

12
src/index.tsx Normal file
View file

@ -0,0 +1,12 @@
/* @refresh reload */
import { render } from 'solid-js/web'
import { App } from './App'
import { registerServiceWorker } from './sw/register.ts'
import './app.css'
const root = document.getElementById('root')
registerServiceWorker()
render(() => <App />, root!)

View file

@ -0,0 +1,48 @@
import clsx from 'clsx'
import { onMount } from 'solid-js'
const HAS_NATIVE_EMOJI = navigator.userAgent.includes('Apple')
const EMOJI_POLYFILL_FONT = 'https://cdn.jsdelivr.net/npm/country-flag-emoji-polyfill@0.1/dist/TwemojiCountryFlags.woff2'
let hasAddedEmojiFont = false
export function CountryIcon(props: { class?: string, country: string }) {
onMount(() => {
if (HAS_NATIVE_EMOJI) return
if (!hasAddedEmojiFont) {
hasAddedEmojiFont = true
const style = document.createElement('style')
style.innerHTML = `
@font-face {
unicode-range: U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
font-family: 'TwemojiCountries';
font-display: swap;
src: url('${EMOJI_POLYFILL_FONT}') format('woff2');
}
`
document.head.appendChild(style)
}
})
const emoji = () => {
const upper = props.country.toUpperCase()
if (upper === 'FT') return '🏴‍☠️'
let res = ''
for (let i = 0; i < upper.length; i++) {
res += String.fromCodePoint(0x1F1E6 + upper.charCodeAt(i) - 'A'.charCodeAt(0))
}
return res
}
return (
<span
class={clsx(
'font-[TwemojiCountries]',
props.class,
)}
>
{emoji()}
</span>
)
}

View file

@ -0,0 +1,54 @@
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
import type { ValidComponent } from 'solid-js'
import * as ImagePrimitive from '@kobalte/core/image'
import { splitProps } from 'solid-js'
import { cn } from '../../utils'
type AvatarRootProps<T extends ValidComponent = 'span'> = ImagePrimitive.ImageRootProps<T> & {
class?: string | undefined
}
function Avatar<T extends ValidComponent = 'span'>(props: PolymorphicProps<T, AvatarRootProps<T>>) {
const [local, others] = splitProps(props as AvatarRootProps, ['class'])
return (
<ImagePrimitive.Root
class={cn('relative flex size-10 shrink-0 overflow-hidden rounded-full', local.class)}
{...others}
/>
)
}
type AvatarImageProps<T extends ValidComponent = 'img'> = ImagePrimitive.ImageImgProps<T> & {
class?: string | undefined
}
function AvatarImage<T extends ValidComponent = 'img'>(props: PolymorphicProps<T, AvatarImageProps<T>>) {
const [local, others] = splitProps(props as AvatarImageProps, ['class'])
return <ImagePrimitive.Img class={cn('aspect-square size-full object-cover', local.class)} {...others} />
}
type AvatarFallbackProps<T extends ValidComponent = 'span'> =
ImagePrimitive.ImageFallbackProps<T> & { class?: string | undefined }
function AvatarFallback<T extends ValidComponent = 'span'>(props: PolymorphicProps<T, AvatarFallbackProps<T>>) {
const [local, others] = splitProps(props as AvatarFallbackProps, ['class'])
return (
<ImagePrimitive.Fallback
class={cn('flex size-full items-center justify-center rounded-full bg-muted', local.class)}
{...others}
/>
)
}
export function makeAvatarFallbackText(displayName: string) {
let res = ''
const words = displayName.split(' ')
res += [...words[0]][0]
if (words.length > 1) {
res += ` ${[...words[1]][0]}`
}
return res
}
export { Avatar, AvatarFallback, AvatarImage }

View file

@ -0,0 +1,46 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
import { type ComponentProps, splitProps } from 'solid-js'
import { cn } from '../../utils.ts'
export const badgeVariants = cva(
'inline-flex items-center border transition-shadow focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
},
size: {
default: 'rounded-md px-2.5 py-0.5 text-xs font-semibold',
sm: 'rounded-full px-1.5 text-2xs',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export function Badge(props: ComponentProps<'div'> & VariantProps<typeof badgeVariants>) {
const [local, rest] = splitProps(props, ['class', 'variant', 'size'])
return (
<div
class={cn(
badgeVariants({
variant: local.variant,
size: local.size,
}),
local.class,
)}
{...rest}
/>
)
}

View file

@ -0,0 +1,51 @@
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
import type { VariantProps } from 'class-variance-authority'
import type { JSX, ValidComponent } from 'solid-js'
import * as ButtonPrimitive from '@kobalte/core/button'
import { cva } from 'class-variance-authority'
import { splitProps } from 'solid-js'
import { cn } from '../../utils'
const buttonVariants = cva(
'inline-flex select-none items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input text-muted-foreground hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
xs: 'h-7 rounded-md px-2 text-xs',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
type ButtonProps<T extends ValidComponent = 'button'> = ButtonPrimitive.ButtonRootProps<T> &
VariantProps<typeof buttonVariants> & { class?: string | undefined, children?: JSX.Element }
function Button<T extends ValidComponent = 'button'>(props: PolymorphicProps<T, ButtonProps<T>>) {
const [local, others] = splitProps(props as ButtonProps, ['variant', 'size', 'class'])
return (
<ButtonPrimitive.Root
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
{...others}
/>
)
}
export type { ButtonProps }
export { Button, buttonVariants }

View file

@ -0,0 +1,53 @@
import type { CheckboxControlProps } from '@kobalte/core/checkbox'
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
import type { ValidComponent, VoidProps } from 'solid-js'
import { Checkbox as CheckboxPrimitive } from '@kobalte/core/checkbox'
import { splitProps } from 'solid-js'
import { cn } from '../../utils.ts'
export const CheckboxLabel = CheckboxPrimitive.Label
export const Checkbox = CheckboxPrimitive
export const CheckboxErrorMessage = CheckboxPrimitive.ErrorMessage
export const CheckboxDescription = CheckboxPrimitive.Description
type checkboxControlProps<T extends ValidComponent = 'div'> = VoidProps<
CheckboxControlProps<T> & { class?: string }
>
export function CheckboxControl<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, checkboxControlProps<T>>) {
const [local, rest] = splitProps(props as checkboxControlProps, [
'class',
'children',
])
return (
<>
<CheckboxPrimitive.Input class="[&:focus-visible+div]:outline-none [&:focus-visible+div]:ring-[1.5px] [&:focus-visible+div]:ring-ring [&:focus-visible+div]:ring-offset-2 [&:focus-visible+div]:ring-offset-background" />
<CheckboxPrimitive.Control
class={cn(
'h-4 w-4 shrink-0 rounded-sm border border-primary shadow transition-shadow focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring data-[disabled]:cursor-not-allowed data-[checked]:bg-primary data-[checked]:text-primary-foreground data-[disabled]:opacity-50',
local.class,
)}
{...rest}
>
<CheckboxPrimitive.Indicator class="flex items-center justify-center text-current">
<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="m5 12l5 5L20 7"
/>
<title>Checkbox</title>
</svg>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Control>
</>
)
}

View file

@ -0,0 +1,112 @@
import type {
DialogContentProps,
DialogDescriptionProps,
DialogTitleProps,
} from '@kobalte/core/dialog'
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
import type { ComponentProps, ParentProps, ValidComponent } from 'solid-js'
import { Dialog as DialogPrimitive } from '@kobalte/core/dialog'
import { LucideX } from 'lucide-solid'
import { splitProps } from 'solid-js'
import { cn } from '../../utils.ts'
export const Dialog = DialogPrimitive
export const DialogTrigger = DialogPrimitive.Trigger
export const DialogPortal = DialogPrimitive.Portal
type dialogContentProps<T extends ValidComponent = 'div'> = ParentProps<
DialogContentProps<T> & {
class?: string
withoutCloseButton?: boolean
}
>
export function DialogContent<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dialogContentProps<T>>) {
const [local, rest] = splitProps(props as dialogContentProps, [
'class',
'children',
'withoutCloseButton',
])
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
class={cn(
'fixed inset-0 z-50 bg-background/80 data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0',
)}
{...rest}
/>
<DialogPrimitive.Content
class={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg data-[closed]:duration-200 data-[expanded]:duration-200 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 data-[closed]:slide-out-to-left-1/2 data-[closed]:slide-out-to-top-[48%] data-[expanded]:slide-in-from-left-1/2 data-[expanded]:slide-in-from-top-[48%] sm:rounded-lg md:w-full',
local.class,
)}
{...rest}
>
{local.children}
<DialogPrimitive.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">
<LucideX class="size-4" />
</DialogPrimitive.CloseButton>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)
}
type dialogTitleProps<T extends ValidComponent = 'h2'> = DialogTitleProps<T> & {
class?: string
}
export function DialogTitle<T extends ValidComponent = 'h2'>(props: PolymorphicProps<T, dialogTitleProps<T>>) {
const [local, rest] = splitProps(props as dialogTitleProps, ['class'])
return (
<DialogPrimitive.Title
class={cn('text-lg font-semibold text-foreground', local.class)}
{...rest}
/>
)
}
type dialogDescriptionProps<T extends ValidComponent = 'p'> =
DialogDescriptionProps<T> & {
class?: string
}
export function DialogDescription<T extends ValidComponent = 'p'>(props: PolymorphicProps<T, dialogDescriptionProps<T>>) {
const [local, rest] = splitProps(props as dialogDescriptionProps, ['class'])
return (
<DialogPrimitive.Description
class={cn('text-sm text-muted-foreground', local.class)}
{...rest}
/>
)
}
export function DialogHeader(props: ComponentProps<'div'>) {
const [local, rest] = splitProps(props, ['class'])
return (
<div
class={cn(
'flex flex-col space-y-2 text-center sm:text-left',
local.class,
)}
{...rest}
/>
)
}
export function DialogFooter(props: ComponentProps<'div'>) {
const [local, rest] = splitProps(props, ['class'])
return (
<div
class={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
local.class,
)}
{...rest}
/>
)
}

View file

@ -0,0 +1,280 @@
import type {
DropdownMenuCheckboxItemProps,
DropdownMenuContentProps,
DropdownMenuGroupLabelProps,
DropdownMenuItemLabelProps,
DropdownMenuItemProps,
DropdownMenuRadioItemProps,
DropdownMenuRootProps,
DropdownMenuSeparatorProps,
DropdownMenuSubTriggerProps,
} from '@kobalte/core/dropdown-menu'
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
import type { ComponentProps, ParentProps, ValidComponent } from 'solid-js'
import { DropdownMenu as DropdownMenuPrimitive } from '@kobalte/core/dropdown-menu'
import { mergeProps, splitProps } from 'solid-js'
import { cn } from '../../utils'
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
export const DropdownMenuGroup = DropdownMenuPrimitive.Group
export const DropdownMenuSub = DropdownMenuPrimitive.Sub
export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
export function DropdownMenu(props: DropdownMenuRootProps) {
const merge = mergeProps<DropdownMenuRootProps[]>({ gutter: 4 }, props)
return <DropdownMenuPrimitive {...merge} />
}
type dropdownMenuContentProps<T extends ValidComponent = 'div'> =
DropdownMenuContentProps<T> & {
class?: string
}
export function DropdownMenuContent<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuContentProps<T>>) {
const [local, rest] = splitProps(props as dropdownMenuContentProps, [
'class',
])
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
class={cn(
'min-w-8rem z-50 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md transition-shadow focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring 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}
/>
</DropdownMenuPrimitive.Portal>
)
}
type dropdownMenuItemProps<T extends ValidComponent = 'div'> =
DropdownMenuItemProps<T> & {
class?: string
inset?: boolean
}
export function DropdownMenuItem<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuItemProps<T>>) {
const [local, rest] = splitProps(props as dropdownMenuItemProps, [
'class',
'inset',
])
return (
<DropdownMenuPrimitive.Item
class={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
local.inset && 'pl-8',
local.class,
)}
{...rest}
/>
)
}
type dropdownMenuGroupLabelProps<T extends ValidComponent = 'span'> =
DropdownMenuGroupLabelProps<T> & {
class?: string
}
export function DropdownMenuGroupLabel<T extends ValidComponent = 'span'>(props: PolymorphicProps<T, dropdownMenuGroupLabelProps<T>>) {
const [local, rest] = splitProps(props as dropdownMenuGroupLabelProps, [
'class',
])
return (
<DropdownMenuPrimitive.GroupLabel
as="div"
class={cn('px-2 py-1.5 text-sm font-semibold', local.class)}
{...rest}
/>
)
}
type dropdownMenuItemLabelProps<T extends ValidComponent = 'div'> =
DropdownMenuItemLabelProps<T> & {
class?: string
}
export function DropdownMenuItemLabel<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuItemLabelProps<T>>) {
const [local, rest] = splitProps(props as dropdownMenuItemLabelProps, [
'class',
])
return (
<DropdownMenuPrimitive.ItemLabel
as="div"
class={cn('px-2 py-1.5 text-sm font-semibold', local.class)}
{...rest}
/>
)
}
type dropdownMenuSeparatorProps<T extends ValidComponent = 'hr'> =
DropdownMenuSeparatorProps<T> & {
class?: string
}
export function DropdownMenuSeparator<T extends ValidComponent = 'hr'>(props: PolymorphicProps<T, dropdownMenuSeparatorProps<T>>) {
const [local, rest] = splitProps(props as dropdownMenuSeparatorProps, [
'class',
])
return (
<DropdownMenuPrimitive.Separator
class={cn('-mx-1 my-1 h-px bg-muted', local.class)}
{...rest}
/>
)
}
export function DropdownMenuShortcut(props: ComponentProps<'span'>) {
const [local, rest] = splitProps(props, ['class'])
return (
<span
class={cn('ml-auto text-xs tracking-widest opacity-60', local.class)}
{...rest}
/>
)
}
type dropdownMenuSubTriggerProps<T extends ValidComponent = 'div'> =
ParentProps<
DropdownMenuSubTriggerProps<T> & {
class?: string
}
>
export function DropdownMenuSubTrigger<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuSubTriggerProps<T>>) {
const [local, rest] = splitProps(props as dropdownMenuSubTriggerProps, [
'class',
'children',
])
return (
<DropdownMenuPrimitive.SubTrigger
class={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[expanded]:bg-accent',
local.class,
)}
{...rest}
>
{local.children}
</DropdownMenuPrimitive.SubTrigger>
)
}
type dropdownMenuSubContentProps<T extends ValidComponent = 'div'> =
DropdownMenuSubTriggerProps<T> & {
class?: string
}
export function DropdownMenuSubContent<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuSubContentProps<T>>) {
const [local, rest] = splitProps(props as dropdownMenuSubContentProps, [
'class',
])
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
class={cn(
'min-w-8rem z-50 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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}
/>
</DropdownMenuPrimitive.Portal>
)
}
type dropdownMenuCheckboxItemProps<T extends ValidComponent = 'div'> =
ParentProps<
DropdownMenuCheckboxItemProps<T> & {
class?: string
}
>
export function DropdownMenuCheckboxItem<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuCheckboxItemProps<T>>) {
const [local, rest] = splitProps(props as dropdownMenuCheckboxItemProps, [
'class',
'children',
])
return (
<DropdownMenuPrimitive.CheckboxItem
class={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
local.class,
)}
{...rest}
>
<DropdownMenuPrimitive.ItemIndicator class="absolute left-2 inline-flex size-4 items-center justify-center">
<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="m5 12l5 5L20 7"
/>
<title>Checkbox</title>
</svg>
</DropdownMenuPrimitive.ItemIndicator>
{props.children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
type dropdownMenuRadioItemProps<T extends ValidComponent = 'div'> = ParentProps<
DropdownMenuRadioItemProps<T> & {
class?: string
}
>
export function DropdownMenuRadioItem<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuRadioItemProps<T>>) {
const [local, rest] = splitProps(props as dropdownMenuRadioItemProps, [
'class',
'children',
])
return (
<DropdownMenuPrimitive.RadioItem
class={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
local.class,
)}
{...rest}
>
<DropdownMenuPrimitive.ItemIndicator class="absolute left-2 inline-flex size-4 items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="size-2"
>
<g
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="M0 0h24v24H0z" />
<path
fill="currentColor"
d="M7 3.34a10 10 0 1 1-4.995 8.984L2 12l.005-.324A10 10 0 0 1 7 3.34"
/>
</g>
<title>Radio</title>
</svg>
</DropdownMenuPrimitive.ItemIndicator>
{props.children}
</DropdownMenuPrimitive.RadioItem>
)
}

View file

@ -0,0 +1,19 @@
import type { Component, ComponentProps } from 'solid-js'
import { splitProps } from 'solid-js'
import { cn } from '../../utils'
const Label: Component<ComponentProps<'label'>> = (props) => {
const [local, others] = splitProps(props, ['class'])
return (
<label
class={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
local.class,
)}
{...others}
/>
)
}
export { Label }

View file

@ -0,0 +1,79 @@
import type { DynamicProps, RootProps } from '@corvu/otp-field'
import type { ComponentProps, ValidComponent } from 'solid-js'
import OTPFieldPrimitive from '@corvu/otp-field'
import { Show, splitProps } from 'solid-js'
import { cn } from '../../utils.ts'
export const OTPFieldInput = OTPFieldPrimitive.Input
type OTPFieldProps<T extends ValidComponent = 'div'> = RootProps<T> & {
class?: string
}
export function OTPField<T extends ValidComponent = 'div'>(props: DynamicProps<T, OTPFieldProps<T>>) {
const [local, rest] = splitProps(props, ['class'])
return (
<OTPFieldPrimitive
class={cn(
'flex items-center gap-2 has-[:disabled]:opacity-50',
local.class,
)}
{...rest}
/>
)
}
export function OTPFieldGroup(props: ComponentProps<'div'>) {
const [local, rest] = splitProps(props, ['class'])
return <div class={cn('flex items-center', local.class)} {...rest} />
}
export function OTPFieldSeparator(props: ComponentProps<'div'>) {
return (
// biome-ignore lint/a11y/useAriaPropsForRole: []
<div role="separator" {...props}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="size-4"
viewBox="0 0 15 15"
>
<title>Separator</title>
<path
fill="currentColor"
fill-rule="evenodd"
d="M5 7.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5"
clip-rule="evenodd"
/>
</svg>
</div>
)
}
export function OTPFieldSlot(props: ComponentProps<'div'> & { index: number }) {
const [local, rest] = splitProps(props, ['class', 'index'])
const context = OTPFieldPrimitive.useContext()
const char = () => context.value()[local.index]
const hasFakeCaret = () =>
context.value().length === local.index && context.isInserting()
const isActive = () => context.activeSlots().includes(local.index)
return (
<div
class={cn(
'relative flex size-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-shadow first:rounded-l-md first:border-l last:rounded-r-md',
isActive() && 'z-10 ring-[1.5px] ring-ring',
local.class,
)}
{...rest}
>
{char()}
<Show when={hasFakeCaret()}>
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="h-4 w-px animate-caret-blink bg-foreground" />
</div>
</Show>
</div>
)
}

View file

@ -0,0 +1,62 @@
import type { DynamicProps, HandleProps, RootProps } from '@corvu/resizable'
import type { ValidComponent } from 'solid-js'
import ResizablePrimitive from '@corvu/resizable'
import { Show, splitProps } from 'solid-js'
import { cn } from '../../utils'
type ResizableProps<T extends ValidComponent = 'div'> = RootProps<T> & { class?: string }
function Resizable<T extends ValidComponent = 'div'>(props: DynamicProps<T, ResizableProps<T>>) {
const [, rest] = splitProps(props as ResizableProps, ['class'])
return (
<ResizablePrimitive
class={cn('flex size-full data-[orientation=vertical]:flex-col', props.class)}
{...rest}
/>
)
}
const ResizablePanel = ResizablePrimitive.Panel
type ResizableHandleProps<T extends ValidComponent = 'button'> = HandleProps<T> & {
class?: string
withHandle?: boolean
}
function ResizableHandle<T extends ValidComponent = 'button'>(props: DynamicProps<T, ResizableHandleProps<T>>) {
const [, rest] = splitProps(props as ResizableHandleProps, ['class', 'withHandle'])
return (
<ResizablePrimitive.Handle
class={cn(
'relative flex w-px shrink-0 items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full data-[orientation=vertical]:after:left-0 data-[orientation=vertical]:after:h-1 data-[orientation=vertical]:after:w-full data-[orientation=vertical]:after:-translate-y-1/2 data-[orientation=vertical]:after:translate-x-0 [&[data-orientation=vertical]>div]:rotate-90',
props.class,
)}
{...rest}
>
<Show when={props.withHandle}>
<div class="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-2.5"
>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
</div>
</Show>
</ResizablePrimitive.Handle>
)
}
export { Resizable, ResizableHandle, ResizablePanel }

View file

@ -0,0 +1,121 @@
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
import type {
SelectContentProps,
SelectItemProps,
SelectTriggerProps,
} from '@kobalte/core/select'
import type { ParentProps, ValidComponent } from 'solid-js'
import { Select as SelectPrimitive } from '@kobalte/core/select'
import { splitProps } from 'solid-js'
import { cn } from '../../utils.ts'
export const Select = SelectPrimitive
export const SelectValue = SelectPrimitive.Value
export const SelectDescription = SelectPrimitive.Description
export const SelectErrorMessage = SelectPrimitive.ErrorMessage
export const SelectItemDescription = SelectPrimitive.ItemDescription
export const SelectHiddenSelect = SelectPrimitive.HiddenSelect
export const SelectSection = SelectPrimitive.Section
type selectTriggerProps<T extends ValidComponent = 'button'> = ParentProps<
SelectTriggerProps<T> & { class?: string }
>
export function SelectTrigger<T extends ValidComponent = 'button'>(props: PolymorphicProps<T, selectTriggerProps<T>>) {
const [local, rest] = splitProps(props as selectTriggerProps, [
'class',
'children',
])
return (
<SelectPrimitive.Trigger
class={cn(
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background transition-shadow placeholder:text-muted-foreground focus:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
local.class,
)}
{...rest}
>
{local.children}
<SelectPrimitive.Icon
as="svg"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
class="flex size-4 items-center justify-center opacity-50"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m8 9l4-4l4 4m0 6l-4 4l-4-4"
/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
type selectContentProps<T extends ValidComponent = 'div'> =
SelectContentProps<T> & {
class?: string
}
export function SelectContent<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, selectContentProps<T>>) {
const [local, rest] = splitProps(props as selectContentProps, ['class'])
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
class={cn(
'relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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}
>
<SelectPrimitive.Listbox class="p-1 focus-visible:outline-none" />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
type selectItemProps<T extends ValidComponent = 'li'> = ParentProps<
SelectItemProps<T> & { class?: string }
>
export function SelectItem<T extends ValidComponent = 'li'>(props: PolymorphicProps<T, selectItemProps<T>>) {
const [local, rest] = splitProps(props as selectItemProps, [
'class',
'children',
])
return (
<SelectPrimitive.Item
class={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
local.class,
)}
{...rest}
>
<SelectPrimitive.ItemIndicator class="absolute right-2 flex size-3.5 items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="size-4"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m5 12l5 5L20 7"
/>
<title>Checked</title>
</svg>
</SelectPrimitive.ItemIndicator>
<SelectPrimitive.ItemLabel>{local.children}</SelectPrimitive.ItemLabel>
</SelectPrimitive.Item>
)
}

View file

@ -0,0 +1,75 @@
import { cn } from '../../utils.ts'
export interface SpinnerProps {
class?: string
indeterminate?: boolean
progress?: number // 0-1
bgClass?: string
strokeClass?: string
}
/*
function polarToCartesian(centerX, centerY, radius, angleInDegrees) {
var angleInRadians = (angleInDegrees-90) * Math.PI / 180.0;
return {
x: centerX + (radius * Math.cos(angleInRadians)),
y: centerY + (radius * Math.sin(angleInRadians))
};
} */
const HALF_PI = Math.PI / 2
function describeArc(
centerX: number,
centerY: number,
radius: number,
startAngleRad: number,
endAngleRad: number,
) {
const largeArcFlag = endAngleRad - startAngleRad <= Math.PI ? '0' : '1'
return [
'M',
centerX + radius * Math.cos(startAngleRad - HALF_PI),
centerY + radius * Math.sin(startAngleRad - HALF_PI),
'A',
radius,
radius,
0,
largeArcFlag,
1,
centerX + radius * Math.cos(endAngleRad - HALF_PI),
centerY + radius * Math.sin(endAngleRad - HALF_PI),
].join(' ')
}
export function Spinner(props: SpinnerProps) {
const progress = () => props.indeterminate ? 0.25 : props.progress ?? 0
return (
<svg viewBox="0 0 24 24" class={cn(props.indeterminate && 'animate-spin', props.class)}>
<circle
class={cn('fill-transparent stroke-current opacity-10 stroke-2', props.bgClass)}
cx="12"
cy="12"
r="10"
/>
{progress() >= 1
? (
<circle
class={cn('stroke-current stroke-2', props.bgClass)}
cx="12"
cy="12"
r="10"
fill="none"
/>
)
: (
<path
class={cn('stroke-current stroke-2', props.strokeClass)}
d={describeArc(12, 12, 10, 0, 2 * Math.PI * progress())}
fill="none"
stroke-linecap="round"
/>
)}
</svg>
)
}

View file

@ -0,0 +1,125 @@
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
import type {
TabsContentProps,
TabsIndicatorProps,
TabsListProps,
TabsRootProps,
TabsTriggerProps,
} from '@kobalte/core/tabs'
import type { VariantProps } from 'class-variance-authority'
import type { ValidComponent, VoidProps } from 'solid-js'
import {
Tabs as TabsPrimitive,
} from '@kobalte/core/tabs'
import { cva } from 'class-variance-authority'
import { splitProps } from 'solid-js'
import { cn } from '../../utils.ts'
type tabsProps<T extends ValidComponent = 'div'> = TabsRootProps<T> & {
class?: string
}
export function Tabs<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, tabsProps<T>>) {
const [local, rest] = splitProps(props as tabsProps, ['class'])
return (
<TabsPrimitive
class={cn('w-full data-[orientation=vertical]:flex', local.class)}
{...rest}
/>
)
}
type tabsListProps<T extends ValidComponent = 'div'> = TabsListProps<T> & {
class?: string
}
export function TabsList<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, tabsListProps<T>>) {
const [local, rest] = splitProps(props as tabsListProps, ['class'])
return (
<TabsPrimitive.List
class={cn(
'relative flex w-full rounded-lg bg-muted p-1 text-muted-foreground data-[orientation=vertical]:flex-col data-[orientation=horizontal]:items-center data-[orientation=vertical]:items-stretch',
local.class,
)}
{...rest}
/>
)
}
type tabsContentProps<T extends ValidComponent = 'div'> =
TabsContentProps<T> & {
class?: string
}
export function TabsContent<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, tabsContentProps<T>>) {
const [local, rest] = splitProps(props as tabsContentProps, ['class'])
return (
<TabsPrimitive.Content
class={cn(
'transition-shadow duration-200 focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[orientation=horizontal]:mt-2 data-[orientation=vertical]:ml-2',
local.class,
)}
{...rest}
/>
)
}
type tabsTriggerProps<T extends ValidComponent = 'button'> =
TabsTriggerProps<T> & {
class?: string
}
export function TabsTrigger<T extends ValidComponent = 'button'>(props: PolymorphicProps<T, tabsTriggerProps<T>>) {
const [local, rest] = splitProps(props as tabsTriggerProps, ['class'])
return (
<TabsPrimitive.Trigger
class={cn(
'peer relative z-10 inline-flex h-7 w-full items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium outline-none transition-colors disabled:pointer-events-none disabled:opacity-50 data-[selected]:text-foreground',
local.class,
)}
{...rest}
/>
)
}
const tabsIndicatorVariants = cva(
'absolute outline-none transition-all duration-200',
{
variants: {
variant: {
block:
'rounded-md bg-background shadow peer-focus-visible:outline-none peer-focus-visible:ring-[1.5px] peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background data-[orientation=horizontal]:bottom-1 data-[orientation=horizontal]:left-0 data-[orientation=vertical]:right-1 data-[orientation=vertical]:top-0 data-[orientation=horizontal]:h-[calc(100%-0.5rem)] data-[orientation=vertical]:w-[calc(100%-0.5rem)]',
underline:
'bg-primary data-[orientation=horizontal]:-bottom-px data-[orientation=horizontal]:left-0 data-[orientation=vertical]:-right-px data-[orientation=vertical]:top-0 data-[orientation=horizontal]:h-[2px] data-[orientation=vertical]:w-[2px]',
},
},
defaultVariants: {
variant: 'block',
},
},
)
type tabsIndicatorProps<T extends ValidComponent = 'div'> = VoidProps<
TabsIndicatorProps<T> &
VariantProps<typeof tabsIndicatorVariants> & {
class?: string
}
>
export function TabsIndicator<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, tabsIndicatorProps<T>>) {
const [local, rest] = splitProps(props as tabsIndicatorProps, [
'class',
'variant',
])
return (
<TabsPrimitive.Indicator
class={cn(tabsIndicatorVariants({ variant: local.variant }), local.class)}
{...rest}
/>
)
}

View file

@ -0,0 +1,138 @@
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
import type {
TextFieldDescriptionProps,
TextFieldErrorMessageProps,
TextFieldInputProps,
TextFieldLabelProps,
TextFieldRootProps,
} from '@kobalte/core/text-field'
import type { JSX, ValidComponent, VoidProps } from 'solid-js'
import { useFormControlContext } from '@kobalte/core'
import { TextField as TextFieldPrimitive } from '@kobalte/core/text-field'
import { cva } from 'class-variance-authority'
import { splitProps } from 'solid-js'
import { cn } from '../../utils.ts'
type textFieldProps<T extends ValidComponent = 'div'> =
TextFieldRootProps<T> & {
class?: string
}
export function TextFieldRoot<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, textFieldProps<T>>) {
const [local, rest] = splitProps(props as textFieldProps, ['class'])
return <TextFieldPrimitive class={cn('space-y-1', local.class)} {...rest} />
}
export const textfieldLabel = cva(
'text-sm font-medium data-[disabled]:cursor-not-allowed data-[disabled]:opacity-70',
{
variants: {
label: {
true: 'data-[invalid]:text-destructive',
},
error: {
true: 'text-xs text-destructive',
},
description: {
true: 'font-normal text-muted-foreground',
},
},
defaultVariants: {
label: true,
},
},
)
type textFieldLabelProps<T extends ValidComponent = 'label'> =
TextFieldLabelProps<T> & {
class?: string
}
export function TextFieldLabel<T extends ValidComponent = 'label'>(props: PolymorphicProps<T, textFieldLabelProps<T>>) {
const [local, rest] = splitProps(props as textFieldLabelProps, ['class'])
return (
<TextFieldPrimitive.Label
class={cn(textfieldLabel(), local.class)}
{...rest}
/>
)
}
type textFieldErrorMessageProps<T extends ValidComponent = 'div'> =
TextFieldErrorMessageProps<T> & {
class?: string
}
export function TextFieldErrorMessage<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, textFieldErrorMessageProps<T>>) {
const [local, rest] = splitProps(props as textFieldErrorMessageProps, [
'class',
])
return (
<TextFieldPrimitive.ErrorMessage
class={cn(textfieldLabel({ error: true }), local.class)}
{...rest}
/>
)
}
type textFieldDescriptionProps<T extends ValidComponent = 'div'> =
TextFieldDescriptionProps<T> & {
class?: string
}
export function TextFieldDescription<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, textFieldDescriptionProps<T>>) {
const [local, rest] = splitProps(props as textFieldDescriptionProps, [
'class',
])
return (
<TextFieldPrimitive.Description
class={cn(
textfieldLabel({ description: true, label: false }),
local.class,
)}
{...rest}
/>
)
}
export interface TextFieldFrameOptions {
class?: string
children?: JSX.Element
}
export function TextFieldFrame(props: TextFieldFrameOptions) {
const context = useFormControlContext()
return (
<div
class={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-shadow focus-within:outline-none focus-within:ring-[1.5px] focus-within:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
context.validationState() === 'invalid' && 'border-error-foreground',
props.class,
)}
>
{props.children}
</div>
)
}
type textFieldInputProps<T extends ValidComponent = 'input'> = VoidProps<
TextFieldInputProps<T> & {
class?: string
}
>
export function TextField<T extends ValidComponent = 'input'>(props: PolymorphicProps<T, textFieldInputProps<T>>) {
const [local, rest] = splitProps(props as textFieldInputProps, ['class'])
return (
<TextFieldPrimitive.Input
class={cn('border-none outline-none placeholder:text-muted-foreground bg-transparent', local.class)}
{...rest}
/>
)
}

View file

@ -0,0 +1,43 @@
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
import type {
TooltipContentProps,
TooltipRootProps,
} from '@kobalte/core/tooltip'
import { Tooltip as TooltipPrimitive } from '@kobalte/core/tooltip'
import { mergeProps, splitProps, type ValidComponent } from 'solid-js'
import { cn } from '../../utils.ts'
export const TooltipTrigger = TooltipPrimitive.Trigger
export function Tooltip(props: TooltipRootProps) {
const merge = mergeProps<TooltipRootProps[]>(
{
gutter: 4,
flip: false,
},
props,
)
return <TooltipPrimitive {...merge} />
}
type tooltipContentProps<T extends ValidComponent = 'div'> =
TooltipContentProps<T> & {
class?: string
}
export function TooltipContent<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, tooltipContentProps<T>>) {
const [local, rest] = splitProps(props as tooltipContentProps, ['class'])
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
class={cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground 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}
/>
</TooltipPrimitive.Portal>
)
}

View file

@ -0,0 +1,16 @@
import type { JSX } from 'solid-js'
import { Transition } from 'solid-transition-group'
export function TransitionSlideLtr(props: { mode?: 'outin' | 'inout', children: JSX.Element }) {
return (
<Transition
mode={props.mode}
enterActiveClass="transition-[transform, opacity] duration-150 ease-in-out motion-reduce:transition-none"
exitActiveClass="transition-[transform, opacity] duration-150 ease-in-out motion-reduce:transition-none"
enterClass="translate-x-5 opacity-0"
exitToClass="-translate-x-5 opacity-0"
>
{props.children}
</Transition>
)
}

2
src/lib/env.ts Normal file
View file

@ -0,0 +1,2 @@
export const IS_SAFARI = navigator.userAgent.includes('Safari')
export const IS_CHROMIUM = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor)

9
src/lib/ffetch.ts Normal file
View file

@ -0,0 +1,9 @@
import { ffetchAddons, ffetchBase } from '@fuman/fetch'
import { ffetchValitaAdapter } from '@fuman/fetch/valita'
export const ffetch = ffetchBase.extend({
retry: {},
addons: [
ffetchAddons.parser(ffetchValitaAdapter({ mode: 'strip' })),
],
} as any) // todo

83
src/lib/runtime.ts Normal file
View file

@ -0,0 +1,83 @@
// eslint-disable-next-line antfu/no-import-dist
import chobitsuUrl from '../../vendor/chobitsu/dist/chobitsu.js?url'
import runnerScriptUrl from '../components/runner/iframe.ts?url'
export async function generateImportMap(packageJsons: any[]) {
const importMap: Record<string, string> = {}
for (const pkg of packageJsons) {
const name = pkg.name
const swPrefix = `${location.origin}/sw/runtime/${name}/`
if (pkg.exports) {
if ('import' in pkg.exports || 'require' in pkg.exports) {
pkg.exports = { '.': pkg.exports }
}
for (const key of Object.keys(pkg.exports)) {
let target = pkg.exports[key]
if (typeof target === 'object') {
// { "import": "./index.js", "require": "./index.cjs" }
// or
// { "import": { "types": "./index.d.ts", "default": "./index.js" }, "require": "./index.cjs" }
if (!('import' in target)) {
throw new Error(`Invalid export target (no esm): ${key} in ${name}`)
}
target = target.import
if (typeof target === 'object') {
if (!('default' in target)) {
throw new Error(`Invalid export target (no defalt): ${key} in ${name}`)
}
target = target.default
}
}
if (typeof target !== 'string') {
throw new TypeError(`Invalid export target: ${key} in ${name}`)
}
target = target.replace(/^\.\//, '')
if (target[0] === '.') {
throw new Error(`Invalid export target: ${key} in ${name}`)
}
if (key === '.') {
importMap[name] = `${swPrefix}${target}`
} else if (key[0] === '.' && key[1] === '/') {
importMap[`${name}/${key.slice(2)}`] = `${swPrefix}${target}`
} else {
throw new Error(`Invalid export target: ${key} in ${name}`)
}
}
} else if (pkg.module) {
importMap[name] = `${swPrefix}${pkg.module.replace(/^\.\//, '')}`
} else if (pkg.main) {
let target = pkg.main.replace(/^\.\//, '')
if (!target.endsWith('.js')) {
target += '.js'
}
importMap[name] = `${swPrefix}${target}`
} else {
importMap[name] = `${swPrefix}index.js`
}
}
return importMap
}
export function generateRunnerHtml(importMap: Record<string, string>) {
return `
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<script async src="https://ga.jspm.io/npm:es-module-shims@1.7.0/dist/es-module-shims.js"></script>
<script type="importmap">${JSON.stringify({ imports: importMap })}</script>
<script>
Object.keys(console).forEach(method => Object.defineProperty(console, method, { value: () => {} }));
</script>
<script src="${chobitsuUrl}"></script>
<script type="module" src="${runnerScriptUrl}"></script>
</head>
</html>
`
}

66
src/lib/telegram.ts Normal file
View file

@ -0,0 +1,66 @@
import type { InputStringSessionData } from '@mtcute/web/utils.js'
import type { TelegramAccount } from '../store/accounts.ts'
import { asNonNull } from '@fuman/utils'
import { BaseTelegramClient, IdbStorage, TransportError } from '@mtcute/web'
import { getMe } from '@mtcute/web/methods.js'
import { nanoid } from 'nanoid'
export function createInternalClient(accountId: string, testMode?: boolean) {
return new BaseTelegramClient({
apiId: Number(import.meta.env.VITE_API_ID),
apiHash: import.meta.env.VITE_API_HASH,
storage: new IdbStorage(`mtcute:${accountId}`),
testMode,
logLevel: import.meta.env.DEV ? 5 : 2,
})
}
export async function deleteAccount(accountId: string) {
const req = indexedDB.deleteDatabase(`mtcute:${accountId}`)
return new Promise<void>((resolve, reject) => {
req.onsuccess = () => resolve()
req.onerror = () => reject(req.error)
})
}
export async function importAccount(
session: InputStringSessionData,
abortSignal: AbortSignal,
): Promise<TelegramAccount> {
const accountId = nanoid()
const client = createInternalClient(accountId, session.primaryDcs?.main.testMode)
let is404 = false
try {
await client.importSession(session)
if (abortSignal.aborted) throw abortSignal.reason
// verify auth_key is valid (i.e. we don't get -404)
client.onError.add((err) => {
if (err instanceof TransportError && err.code === 404) {
is404 = true
client.close()
}
})
await client.connect()
const self = await getMe(client)
if (abortSignal.aborted) throw abortSignal.reason
return {
id: accountId,
name: self.displayName,
telegramId: self.id,
bot: self.isBot,
testMode: asNonNull(session.primaryDcs).main.testMode ?? false,
dcId: asNonNull(session.primaryDcs).main.id,
}
} catch (e) {
await deleteAccount(accountId)
if (is404) {
throw new Error('Invalid session (auth key not found)')
}
throw e
}
}

View file

@ -0,0 +1,17 @@
import { createSignal, onMount } from 'solid-js'
export type ColorScheme = 'light' | 'dark'
export function useColorScheme() {
const [scheme, setScheme] = createSignal<ColorScheme>(matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
onMount(() => {
const listener = (e: MediaQueryListEvent) => setScheme(e.matches ? 'dark' : 'light')
const media = matchMedia('(prefers-color-scheme: dark)')
media.addEventListener('change', listener)
return () => media.removeEventListener('change', listener)
})
return scheme
}

23
src/lib/utils.ts Normal file
View file

@ -0,0 +1,23 @@
import type { ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function timeout<T>(promise: Promise<T>, timeout: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error('Timeout'))
}, timeout)
promise.then((res) => {
clearTimeout(timeoutId)
resolve(res)
}).catch((err) => {
clearTimeout(timeoutId)
reject(err)
})
})
}

199
src/lib/vfs/downloader.ts Normal file
View file

@ -0,0 +1,199 @@
import type { VfsFile, VfsStorage } from './storage'
import { read, webReadableToFuman } from '@fuman/io'
import { asyncPool, AsyncQueue, utf8 } from '@fuman/utils'
import { ffetch } from '../ffetch.ts'
import { GunzipStream } from './gzip.ts'
import { extractTar, type TarEntry } from './tar.ts'
const PACKAGES_TO_SKIP = new Set([
'@mtcute/bun',
'@mtcute/node',
'@mtcute/crypto-node',
'@mtcute/deno',
'@mtcute/create-bot',
'@mtcute/test',
])
export async function getLatestVersions() {
const versions = await fetch('https://raw.githubusercontent.com/mtcute/mtcute/refs/heads/master/scripts/latest-versions.json').then(res => res.json())
for (const pkg of Object.keys(versions)) {
if (PACKAGES_TO_SKIP.has(pkg)) {
delete versions[pkg]
}
}
return versions
}
export async function getPackagesToDownload(latestVersions: Record<string, string>, storage: VfsStorage) {
const packages: Record<string, string> = {}
const queue = new AsyncQueue<[string, string]>()
const queued: [string, string][] = []
for (const pkg of Object.keys(latestVersions)) {
if (await storage.getExistingLibVersion(pkg) === latestVersions[pkg]) continue
queue.enqueue([pkg, latestVersions[pkg]])
queued.push([pkg, latestVersions[pkg]])
}
if (queue.queue.length === 0) return packages
let fetched = 0
let total = queue.queue.length
await asyncPool(queue, async ([pkg, version]) => {
let exactVersion: string
if (version.match(/^\d+\.\d+\.\d+$/)) {
// version is already exact
exactVersion = version
} else {
const res = await ffetch(`https://data.jsdelivr.com/v1/packages/npm/${pkg}/resolved`, {
query: { specifier: version },
}).json<{ version: string }>()
exactVersion = res.version
}
packages[pkg] = exactVersion
const packageJson = await ffetch(`https://cdn.jsdelivr.net/npm/${pkg}@${exactVersion}/package.json`).json<{ dependencies?: Record<string, string> }>()
for (const [dep, depVersion_] of Object.entries(packageJson.dependencies ?? {})) {
const depVersion = depVersion_ as string
if (queued.some(([pkg]) => pkg === dep)) {
// already fetched/queued
continue
}
queue.enqueue([dep, depVersion as string])
queued.push([dep, depVersion as string])
total += 1
}
fetched += 1
if (fetched === total) {
// we're done
queue.end()
}
})
return packages
}
function patchMtcuteTl(file: VfsFile) {
if (!file.path.match(/\.(js|json)$/)) return
// @mtcute/tl is currently commonjs-only, so we need to add some shims to make it work with esm
// based on https://github.com/mtcute/mtcute/blob/master/packages/tl/scripts/build-package.ts
let text = utf8.decoder.decode(file.contents)
if (text.includes('export const')) {
// future-proofing for when we eventually switch to esm
return
}
switch (file.path) {
case 'index.js': {
text = [
'const exports = {};',
text,
'export const tl = exports.tl;',
'export const mtp = exports.mtp;',
].join('')
break
}
case 'binary/reader.js': {
text = [
'const exports = {};',
text,
'export const __tlReaderMap = exports.__tlReaderMap;',
].join('')
break
}
case 'binary/writer.js': {
text = [
'const exports = {};',
text,
'export const __tlWriterMap = exports.__tlWriterMap;',
].join('')
break
}
case 'binary/rsa-keys.js': {
text = [
'const exports = {};',
text,
'export const __publicKeyIndex = exports.__publicKeyIndex;',
].join('')
break
}
case 'package.json': {
const json = JSON.parse(text)
json.exports = {
'.': './index.js',
'./binary/reader.js': './binary/reader.js',
'./binary/writer.js': './binary/writer.js',
'./binary/rsa-keys.js': './binary/rsa-keys.js',
}
text = JSON.stringify(json, null, 2)
break
}
}
file.contents = utf8.encoder.encode(text)
}
export async function downloadNpmPackage(params: {
packageName: string
version: string
storage: VfsStorage
progress: (downloaded: number, total: number, file: string) => void
filterFiles?: (file: TarEntry) => boolean
signal: AbortSignal
}) {
const {
packageName,
version,
storage,
progress,
filterFiles,
signal,
} = params
const tgzUrl = `https://registry.npmjs.org/${packageName}/-/${packageName.replace(/^.*?\//, '')}-${version}.tgz`
const response = await fetch(tgzUrl, { signal })
if (!response.ok || !response.body) {
throw new Error(`Failed to download: HTTP ${response.status}`)
}
const stream = webReadableToFuman(response.body)
const gunzipStream = new GunzipStream(stream)
const total = Number(response.headers.get('content-length') ?? 0)
const files: VfsFile[] = []
for await (const file of extractTar(gunzipStream)) {
if (!file.content || file.header.type !== 'file') {
continue
}
progress(gunzipStream.totalRead, total, file.header.name)
if (filterFiles && !filterFiles(file)) {
continue
}
const fileName = file.header.name.replace(/^package\//, '')
const vfsFile: VfsFile = {
path: fileName,
contents: await read.async.untilEnd(file.content),
}
if (packageName === '@mtcute/tl') {
patchMtcuteTl(vfsFile)
}
files.push(vfsFile)
}
await storage.writeLibrary(packageName, version, files)
progress(total, total, 'Done!')
}

74
src/lib/vfs/gzip.ts Normal file
View file

@ -0,0 +1,74 @@
import type { IClosable, IReadable } from '@fuman/io'
import { Bytes, ReaderWithFinal } from '@fuman/io'
import { Deferred, Deque } from '@fuman/utils'
import { AsyncGunzip } from 'fflate/browser'
const INTERNAL_CHUNK_SIZE = 1024 * 1024
export class GunzipStream implements IReadable, IClosable {
#gunzip = new AsyncGunzip(this.#onChunk.bind(this))
#buffer = Bytes.alloc()
#waiters = new Deque<Deferred<void>>()
#error: unknown | null = null
#totalRead = 0
#reader: ReaderWithFinal
#internalBuffer = new Uint8Array(INTERNAL_CHUNK_SIZE)
constructor(stream: IReadable) {
this.#reader = new ReaderWithFinal(stream)
}
get totalRead(): number {
return this.#totalRead
}
#onChunk(err: unknown, data: Uint8Array) {
if (err) {
while (this.#waiters.length > 0) {
this.#waiters.popFront()!.reject(err)
}
this.#error = err
return
}
if (this.#waiters.length > 0) {
this.#waiters.popFront()!.resolve()
}
this.#buffer.writeSync(data.length).set(data)
}
async read(into: Uint8Array): Promise<number> {
if (this.#buffer.available > 0) {
return this.#buffer.read(into)
}
if (this.#error) throw this.#error
const { nread, final } = await this.#reader.readWithFinal(this.#internalBuffer)
if (nread === 0) {
this.#gunzip.terminate()
return 0
}
this.#totalRead += nread
const def = new Deferred<void>()
this.#waiters.pushBack(def)
this.#gunzip.push(this.#internalBuffer.slice(0, nread), final)
await def.promise
if (this.#buffer.available === 0) {
// try reading again
return this.read(into)
}
return this.#buffer.read(into)
}
close(): void {
this.#gunzip.terminate()
}
}

54
src/lib/vfs/storage.ts Normal file
View file

@ -0,0 +1,54 @@
import type { DBSchema, IDBPDatabase } from 'idb'
import { openDB } from 'idb'
export interface VfsFile {
path: string
contents: Uint8Array
}
interface Schema extends DBSchema {
libs: {
key: string
value: {
// we do not support multiple versions of the same lib (for now?)
name: string
version: string
files: VfsFile[]
}
}
}
export class VfsStorage {
constructor(private db: IDBPDatabase<Schema>) {}
static async create() {
const db = await openDB<Schema>('mtcute-repl-vfs', 1, {
upgrade(db) {
db.createObjectStore('libs', { keyPath: 'name' })
},
})
return new VfsStorage(db)
}
async getAvailableLibs() {
return this.db.getAllKeys('libs')
}
async getExistingLibVersion(lib: string) {
const obj = await this.db.get('libs', lib)
return obj?.version
}
async readLibrary(lib: string) {
return this.db.get('libs', lib)
}
async writeLibrary(lib: string, version: string, files: VfsFile[]) {
await this.db.put('libs', { name: lib, version, files })
}
async deleteLib(lib: string) {
await this.db.delete('libs', lib)
}
}

124
src/lib/vfs/system.ts Normal file
View file

@ -0,0 +1,124 @@
// roughly based on https://github.com/microsoft/TypeScript-Website/blob/v2/packages/typescript-vfs/src/index.ts
import type { CompilerHost, CompilerOptions, SourceFile, System } from 'typescript'
import type { VfsStorage } from './storage'
import { iter, utf8 } from '@fuman/utils'
function notImplemented(methodName: string): any {
throw new Error(`Method '${methodName}' is not implemented.`)
}
// "/DOM.d.ts" => "/lib.dom.d.ts"
const libize = (path: string) => path.replace('/', '/lib.').toLowerCase()
export function createSystem(files: Map<string, Uint8Array>): System {
return {
args: [],
createDirectory: () => notImplemented('createDirectory'),
// TODO: could make a real file tree
directoryExists: (directory) => {
return Array.from(files.keys()).some(path => path.startsWith(directory))
},
exit: () => notImplemented('exit'),
fileExists: fileName => files.has(fileName) || files.has(libize(fileName)),
getCurrentDirectory: () => '/',
getDirectories: () => [],
getExecutingFilePath: () => notImplemented('getExecutingFilePath'),
readDirectory: (directory) => {
return (directory === '/' ? Array.from(files.keys()) : [])
},
readFile: (fileName) => {
const bytes = files.get(fileName) ?? files.get(libize(fileName))
if (!bytes) return undefined
return utf8.decoder.decode(bytes)
},
resolvePath: path => path,
newLine: '\n',
useCaseSensitiveFileNames: true,
write: () => notImplemented('write'),
writeFile: (fileName, contents) => {
files.set(fileName, utf8.encoder.encode(contents))
},
deleteFile: (fileName) => {
files.delete(fileName)
},
}
}
export function createVirtualCompilerHost(
sys: System,
compilerOptions: CompilerOptions,
ts: typeof import('typescript'),
) {
const sourceFiles = new Map<string, SourceFile>()
const save = (sourceFile: SourceFile) => {
sourceFiles.set(sourceFile.fileName, sourceFile)
return sourceFile
}
interface Return {
compilerHost: CompilerHost
updateFile: (sourceFile: SourceFile) => boolean
deleteFile: (sourceFile: SourceFile) => boolean
}
const vHost: Return = {
compilerHost: {
...sys,
getCanonicalFileName: fileName => fileName,
getDefaultLibFileName: () => '/lib.d.ts',
// getDefaultLibLocation: () => '/',
getNewLine: () => sys.newLine,
getSourceFile: (fileName, languageVersionOrOptions) => {
const existing = sourceFiles.get(fileName)
if (existing) return existing
const content = sys.readFile(fileName)
if (!content) return undefined
return save(
ts.createSourceFile(
fileName,
content,
languageVersionOrOptions ?? compilerOptions.target ?? ts.ScriptTarget.ESNext,
false,
),
)
},
useCaseSensitiveFileNames: () => sys.useCaseSensitiveFileNames,
},
updateFile: (sourceFile) => {
const alreadyExists = sourceFiles.has(sourceFile.fileName)
sys.writeFile(sourceFile.fileName, sourceFile.text)
sourceFiles.set(sourceFile.fileName, sourceFile)
return alreadyExists
},
deleteFile: (sourceFile) => {
const alreadyExists = sourceFiles.has(sourceFile.fileName)
sourceFiles.delete(sourceFile.fileName)
sys.deleteFile!(sourceFile.fileName)
return alreadyExists
},
}
return vHost
}
export async function createFilesMapFromVfs(vfs: VfsStorage, libs: string[]): Promise<Map<string, Uint8Array>> {
const files = await Promise.all(libs.map(lib => vfs.readLibrary(lib)))
const fileMap = new Map<string, Uint8Array>()
for (const [idx, list] of iter.enumerate(files)) {
const lib = libs[idx].split(':')[1]
const prefix = `/node_modules/${lib}`
for (const file of list) {
const fullPath = prefix + (file.path[0] === '/' ? file.path : `/${file.path}`)
fileMap.set(fullPath, file.contents)
}
}
return fileMap
}

359
src/lib/vfs/tar.ts Normal file
View file

@ -0,0 +1,359 @@
import type { IReadable } from '@fuman/io'
import type { UnsafeMutable, Values } from '@fuman/utils'
import { read } from '@fuman/io'
import { typed, utf8 } from '@fuman/utils'
const BLOCK_SIZE = 512
const USTAR_MAGIC = /* #__PURE__ */ utf8.encoder.encode('ustar\x00')
const GNU_MAGIC = /* #__PURE__ */ utf8.encoder.encode('ustar\x20')
const GNU_VER = /* #__PURE__ */ utf8.encoder.encode('\x20\x00')
const TAR_ENTRY_TYPES = {
0: 'file',
1: 'link',
2: 'symlink',
3: 'characterDevice',
4: 'blockDevice',
5: 'directory',
6: 'fifo',
7: 'contiguousFile',
} as const
type TarEntryType = Values<typeof TAR_ENTRY_TYPES> | 'unknown'
function readString(buf: Uint8Array): string {
const zeroIdx = buf.indexOf(0)
if (zeroIdx !== -1) buf = buf.subarray(0, zeroIdx)
return utf8.decoder.decode(buf)
}
function readOctalInteger(buf: Uint8Array): number {
if (buf[0] & 0x80) {
// If prefixed with 0x80 then parse as a base-256 integer
// currently mostly copy-pasted from node-tar, but we should check if we can simplify this
// first byte MUST be either 80 or FF
// 80 for positive, FF for 2's comp
let positive
if (buf[0] === 0x80) {
positive = true
} else if (buf[0] === 0xFF) {
positive = false
} else {
return 0
}
// build up a base-256 tuple from the least sig to the highest
let zero = false
const tuple: number[] = []
for (let i = buf.length - 1; i > 0; i--) {
const byte = buf[i]
if (positive) {
tuple.push(byte)
} else if (zero && byte === 0) {
tuple.push(0)
} else if (zero) {
zero = false
tuple.push(0x100 - byte)
} else {
tuple.push(0xFF - byte)
}
}
let sum = 0
const l = tuple.length
for (let i = 0; i < l; i++) {
sum += tuple[i] * 256 ** i
}
return positive ? sum : -1 * sum
}
let res = 0
let prefix = true
for (let i = 0; i < buf.length; i++) {
const byte = buf[i]
if (prefix) {
// some tar implementations prefix with spaces.
// zeroes would work fine with the below, but we can also just skip them
if (byte === 0x20 || byte === 0x00) continue
else prefix = false
} else if (byte === 0 || byte === 0x20) {
// some tar implementations also use spaces as terminators
break
}
res = (res << 3) | (buf[i] - 0x30)
}
return res
}
function checksum(block: Uint8Array): number {
let sum = 8 * 32
for (let i = 0; i < 148; i++) sum += block[i]
for (let j = 156; j < 512; j++) sum += block[j]
return sum
}
interface TarHeader {
readonly name: string
readonly type: TarEntryType
readonly typeflag: number
readonly linkName: string | null
readonly size: number
readonly mtime: Date
readonly mode: number
readonly uid: number
readonly gid: number
readonly uname: string
readonly gname: string
readonly devMajor: number
readonly devMinor: number
readonly pax?: Record<string, string>
}
function parseHeader(buffer: Uint8Array): TarHeader | null {
let typeflag = buffer[156] === 0 ? 0 : buffer[156] - 0x30
const name = readString(buffer.subarray(0, 100))
const mode = readOctalInteger(buffer.subarray(100, 108))
const uid = readOctalInteger(buffer.subarray(108, 116))
const gid = readOctalInteger(buffer.subarray(116, 124))
const size = readOctalInteger(buffer.subarray(124, 136))
const mtime = new Date(1000 * readOctalInteger(buffer.subarray(136, 148)))
const linkName = buffer[157] === 0 ? null : readString(buffer.subarray(157, 257))
const uname = readString(buffer.subarray(265, 297))
const gname = readString(buffer.subarray(297, 329))
const devMajor = readOctalInteger(buffer.subarray(329, 337))
const devMinor = readOctalInteger(buffer.subarray(337, 345))
const sum = checksum(buffer)
// checksum is still initial value if header was null.
if (sum === 8 * 32) {
return null
}
if (sum !== readOctalInteger(buffer.subarray(148, 156))) {
throw new Error('Invalid tar header. Maybe the tar is corrupted or it needs to be gunzipped?')
}
const magic = buffer.subarray(257, 263)
if (typed.equal(magic, USTAR_MAGIC)) {
// ustar (posix) format. prepend prefix, if present.
if (buffer[354] !== 0) {
readString(buffer.subarray(345, 354))
}
} else if (typed.equal(magic, GNU_MAGIC) && typed.equal(buffer.subarray(263, 265), GNU_VER)) {
// 'gnu'/'oldgnu' format. Similar to ustar, but has support for incremental and
// multi-volume tarballs.
} else {
throw new Error('Invalid tar header: unknown format')
}
// to support old tar versions that use trailing / to indicate dirs
if (typeflag === 0 && name[name.length - 1] === '/') {
typeflag = 5
}
return {
name,
type: TAR_ENTRY_TYPES[typeflag as keyof typeof TAR_ENTRY_TYPES] ?? 'unknown',
typeflag,
linkName,
size,
mtime,
mode,
uid,
gid,
uname,
gname,
devMajor,
devMinor,
}
}
function parsePaxHeader(buffer: Uint8Array): Record<string, string> | null {
const result: Record<string, string> = {}
while (buffer.length > 0) {
let i = 0
while (i < buffer.length && buffer[i] !== 32) {
i++
}
const len = Number.parseInt(utf8.decoder.decode(buffer.subarray(0, i)), 10)
if (len === 0) {
return result
}
const b = utf8.decoder.decode(buffer.subarray(i + 1, len - 1))
const keyIndex = b.indexOf('=')
if (keyIndex === -1) {
return result
}
result[b.slice(0, keyIndex)] = b.slice(keyIndex + 1)
buffer = buffer.subarray(len)
}
return result
}
export interface TarEntry {
readonly header: TarHeader
readonly content?: IReadable
}
class FileStream implements IReadable {
#inner: IReadable
#pos = 0
constructor(inner: IReadable, readonly size: number) {
this.#inner = inner
}
get remaining(): number {
return this.size - this.#pos
}
async read(into: Uint8Array): Promise<number> {
const remaining = this.size - this.#pos
if (remaining === 0) {
return 0
}
if (into.length > remaining) {
into = into.subarray(0, remaining)
}
const nread = await this.#inner.read(into)
if (nread === 0) return 0
this.#pos += nread
return nread
}
}
async function readNextHeader(readable: IReadable): Promise<TarHeader | null> {
let nextLongPath: string | null = null
let nextLongLinkPath: string | null = null
let paxGlobalHeader: Record<string, string> | null = null
let nextPaxHeader: Record<string, string> | null = null
while (true) {
const block = await read.async.exactly(readable, BLOCK_SIZE, 'truncate')
if (block.length < BLOCK_SIZE) return null // eof
const header = parseHeader(block) as UnsafeMutable<TarHeader>
if (header === null) continue
switch (header.typeflag) {
case 28:
case 30: {
// gnu long path
const nextBlock = await read.async.exactly(readable, BLOCK_SIZE)
nextLongPath = readString(nextBlock.subarray(0, header.size))
continue
}
case 27: {
// gnu long link path
const nextBlock = await read.async.exactly(readable, BLOCK_SIZE)
nextLongLinkPath = readString(nextBlock.subarray(0, header.size))
continue
}
case 72: {
// pax header
const nextBlock = await read.async.exactly(readable, BLOCK_SIZE)
nextPaxHeader = parsePaxHeader(nextBlock.subarray(0, header.size))
if (paxGlobalHeader != null) {
nextPaxHeader = { ...paxGlobalHeader, ...nextPaxHeader }
}
continue
}
case 55: {
// pax global header
const nextBlock = await read.async.exactly(readable, BLOCK_SIZE)
paxGlobalHeader = parsePaxHeader(nextBlock.subarray(0, header.size))
continue
}
}
if (nextLongPath != null) {
header.name = nextLongPath
nextLongPath = null
}
if (nextLongLinkPath != null) {
header.linkName = nextLongLinkPath
nextLongLinkPath = null
}
if (nextPaxHeader != null) {
if (nextPaxHeader.path != null) {
header.name = nextPaxHeader.path
}
if (nextPaxHeader.linkpath != null) {
header.linkName = nextPaxHeader.linkpath
}
if (nextPaxHeader.size != null) {
header.size = Number.parseInt(nextPaxHeader.size, 10)
}
header.pax = nextPaxHeader
nextPaxHeader = null
}
return header
}
}
export function extractTar(readable: IReadable): AsyncIterableIterator<TarEntry> {
let prevContent: FileStream | null = null
const iterator: AsyncIterableIterator<TarEntry> = {
[Symbol.asyncIterator]: () => iterator,
next: async () => {
if (prevContent != null) {
// make sure the previous content is fully read
if (prevContent.remaining > 0) {
await read.async.exactly(readable, prevContent.remaining)
}
// skip padding after the file, if any
const paddedSize = prevContent.size % BLOCK_SIZE
if (paddedSize > 0) {
await read.async.exactly(readable, BLOCK_SIZE - paddedSize)
}
prevContent = null
}
const header = await readNextHeader(readable)
if (header === null) return { done: true, value: undefined }
if (header.size === 0 || header.typeflag === 5) {
// directory or empty file
return { done: false, value: { header } }
}
const content = new FileStream(readable, header.size)
prevContent = content
return {
done: false,
value: { header, content },
}
},
}
return iterator
}

49
src/store/accounts.ts Normal file
View file

@ -0,0 +1,49 @@
import * as v from '@badrap/valita'
import { persistentAtom } from '@nanostores/persistent'
import { computed } from 'nanostores'
export interface TelegramAccount {
id: string
name: string
bot: boolean
testMode: boolean
telegramId: number
dcId: number
}
const AccountSchema = v.object({
_v: v.literal(1),
id: v.string(),
telegramId: v.number(),
bot: v.boolean(),
name: v.string(),
testMode: v.boolean(),
dcId: v.number(),
})
export const $accounts = persistentAtom<TelegramAccount[]>('repl:accounts', [], {
encode: v => JSON.stringify(v.map(a => ({ ...a, _v: 1 }))),
decode: (str) => {
const arr = JSON.parse(str)
const res: TelegramAccount[] = []
for (const account of arr) {
const parsed = AccountSchema.try(account)
if (parsed.ok) {
res.push(parsed.value)
}
}
return res
},
})
export const $activeAccountId = persistentAtom<string>('repl:activeAccountId')
export const $activeAccount = computed([$accounts, $activeAccountId], (accounts, activeAccountId) => {
if (!activeAccountId) return null
const account = accounts.find(account => account.id === activeAccountId)
if (!account) return null
return account
})

17
src/store/tabs.ts Normal file
View file

@ -0,0 +1,17 @@
import { atom } from 'nanostores'
export interface EditorTab {
id: string
fileName: string
main: boolean
}
export const $tabs = atom<EditorTab[]>([
{
id: 'main',
fileName: 'main.ts',
main: true,
},
])
export const $activeTab = atom('main')

31
src/store/use-store.ts Normal file
View file

@ -0,0 +1,31 @@
// based on https://github.com/nanostores/solid/blob/master/src/index.ts, but using signals instead of stores because they make no sense here
import type { Store, StoreValue } from 'nanostores'
import type { Accessor } from 'solid-js'
import { createSignal, onCleanup } from 'solid-js'
/**
* Subscribes to store changes and gets stores value.
*
* @param store Store instance.
* @returns Store value.
*/
export function useStore<SomeStore extends Store, Value extends StoreValue<SomeStore>>(
store: SomeStore,
): Accessor<Value> {
// Activate the store explicitly:
// https://github.com/nanostores/solid/issues/19
const unbindActivation = store.listen(() => {})
const [state, setState] = createSignal(store.get())
const unsubscribe = store.subscribe((newValue) => {
setState(newValue)
})
onCleanup(() => unsubscribe())
// Remove temporary listener now that there is already a proper subscriber.
unbindActivation()
return state
}

46
src/sw/avatar.ts Normal file
View file

@ -0,0 +1,46 @@
import type { BaseTelegramClient } from '@mtcute/web'
import { downloadAsBuffer, getMe } from '@mtcute/web/methods.js'
import { createInternalClient } from '../lib/telegram.ts'
import { timeout } from '../lib/utils.ts'
import { $accounts } from '../store/accounts.ts'
import { getCacheStorage } from './cache.ts'
const clients = new Map<string, BaseTelegramClient>()
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) {
return cachedRes
}
} catch {}
let client = clients.get(accountId)
if (!client) {
client = createInternalClient(accountId)
await client.prepare()
clients.set(accountId, client)
}
const self = await getMe(client)
if (!self.photo) {
return new Response('No photo', { status: 404 })
}
const buf = await downloadAsBuffer(client, self.photo.big)
await client.close()
const res = new Response(buf, {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=86400',
},
})
await cache.put(cacheKey, res.clone())
return res
}

35
src/sw/cache.ts Normal file
View file

@ -0,0 +1,35 @@
import { timeout } from '../lib/utils.ts'
let _cacheStorage: Cache | undefined
const CACHE_STORE_NAME = 'cached'
export async function getCacheStorage() {
if (!_cacheStorage) {
const storage = await caches.open(CACHE_STORE_NAME)
_cacheStorage = storage
}
return _cacheStorage
}
export async function requestCache(event: FetchEvent) {
try {
// const cache = await ctx.caches.open(CACHE_ASSETS_NAME);
const cache = await timeout(getCacheStorage(), 10000)
const cachedRes = await timeout(cache.match(event.request), 10000)
if (cachedRes && cachedRes.ok) {
return cachedRes
}
const headers: HeadersInit = { Vary: '*' }
const response = await fetch(event.request, { headers })
if (response.ok) {
cache.put(event.request, response.clone())
}
return response
} catch {
return fetch(event.request)
}
}

47
src/sw/client.ts Normal file
View file

@ -0,0 +1,47 @@
import type { SwMessage } from './main.ts'
import { asNonNull, Deferred } from '@fuman/utils'
export function getServiceWorker() {
return asNonNull(navigator.serviceWorker.controller)
}
let registered = false
let nextId = 0
const pending = new Map<number, Deferred<any>>()
function swInvokeMethod(request: SwMessage) {
const sw = getServiceWorker()
if (!registered) {
navigator.serviceWorker.addEventListener('message', (e) => {
const { id, result, error } = (e as MessageEvent).data
const def = pending.get(id)
if (!def) return
if (error) {
def.reject(new Error(error))
} else {
def.resolve(result)
}
pending.delete(id)
})
registered = true
}
const def = new Deferred<any>()
const id = nextId++
;(request as any).id = id
pending.set(id, def)
sw.postMessage(request)
return def.promise
}
export function swUploadScript(name: string, files: Record<string, string>) {
return swInvokeMethod({ event: 'UPLOAD_SCRIPT', name, files })
}
export function swForgetScript(name: string) {
return swInvokeMethod({ event: 'FORGET_SCRIPT', name })
}
export function swClearCache() {
return swInvokeMethod({ event: 'CLEAR_CACHE' })
}

85
src/sw/main.ts Normal file
View file

@ -0,0 +1,85 @@
import { unknownToError } from '@fuman/utils'
import { IS_SAFARI } from '../lib/env.ts'
import { handleAvatarRequest } from './avatar.ts'
import { requestCache } from './cache.ts'
import { clearCache, forgetScript, handleRuntimeRequest, uploadScript } from './runtime.ts'
declare const self: ServiceWorkerGlobalScope
async function handleSwRequest(req: Request, url: URL): Promise<Response> {
if (url.pathname.startsWith('/sw/avatar/')) {
const accountId = url.pathname.split('/')[3]
return handleAvatarRequest(accountId)
}
if (url.pathname.startsWith('/sw/runtime/')) {
return handleRuntimeRequest(url)
}
return new Response('Not Found', { status: 404 })
}
function onFetch(event: FetchEvent) {
const req = event.request
const url = new URL(req.url)
if (
import.meta.env.PROD
&& !IS_SAFARI
&& event.request.url.indexOf(`${location.origin}/`) === 0
&& event.request.url.match(/\.(js|css|jpe?g|json|wasm|png|mp3|svg|tgs|ico|woff2?|ttf|webmanifest?)(?:\?.*)?$/)
) {
return event.respondWith(requestCache(event))
}
if (url.pathname.startsWith('/sw/')) {
event.respondWith(
handleSwRequest(req, url)
.catch((err) => {
console.error(err)
return new Response(err.message || err.toString(), { status: 500 })
}),
)
}
}
function register() {
self.onfetch = onFetch
}
register()
self.onoffline = self.ononline = () => {
register()
}
export type SwMessage =
| { event: 'UPLOAD_SCRIPT', name: string, files: Record<string, string> }
| { event: 'FORGET_SCRIPT', name: string }
| { event: 'CLEAR_CACHE' }
function handleMessage(msg: SwMessage) {
switch (msg.event) {
case 'UPLOAD_SCRIPT': {
uploadScript(msg.name, msg.files)
break
}
case 'FORGET_SCRIPT': {
forgetScript(msg.name)
break
}
case 'CLEAR_CACHE': {
clearCache()
break
}
}
}
self.onmessage = async (event) => {
const msg = event.data as SwMessage & { id: number }
try {
const result = await handleMessage(msg)
event.source!.postMessage({ id: msg.id, result })
} catch (e) {
event.source!.postMessage({ id: msg.id, error: unknownToError(e).message })
}
}

39
src/sw/register.ts Normal file
View file

@ -0,0 +1,39 @@
import workerUrl from '../../sw.ts?worker&url'
export function registerServiceWorker() {
if (!('serviceWorker' in navigator)) {
document.body.innerHTML = `
<div style="display: flex; justify-content: center; align-items: center; height: 100vh; font-size: 2rem; color: #fff; background-color: #000">
<div>Service worker support is required to use this app.</div>
<div>Please enable it in your browser settings (or update your browser).</div>
</div>
`
throw new Error('Service worker not supported')
}
navigator.serviceWorker.register(
workerUrl,
{ type: 'module', scope: './' },
).then((reg) => {
const url = new URL(window.location.href)
const FIX_KEY = 'swfix'
const swfix = Number(url.searchParams.get(FIX_KEY) ?? 0)
if (reg.active && !navigator.serviceWorker.controller) {
if (swfix >= 3) {
throw new Error('no controller')
}
// sometimes this happens on hard refresh
return reg.unregister().then(() => {
url.searchParams.set(FIX_KEY, `${swfix + 1}`)
location.replace(url)
})
}
if (swfix) {
url.searchParams.delete(FIX_KEY)
history.pushState(undefined, '', url)
}
})
}

136
src/sw/runtime.ts Normal file
View file

@ -0,0 +1,136 @@
import { utf8 } from '@fuman/utils'
import { generateImportMap, generateRunnerHtml } from '../lib/runtime.ts'
import { VfsStorage } from '../lib/vfs/storage.ts'
const libraryCache = new Map<string, Map<string, Uint8Array>>()
let importMapCache: Record<string, string> | undefined
let vfs: VfsStorage | undefined
const scriptsStorage = new Map<string, string>()
async function getVfs() {
if (!vfs) {
vfs = await VfsStorage.create()
}
return vfs
}
async function loadLibrary(name: string) {
const vfs = await getVfs()
if (libraryCache.has(name)) {
return libraryCache.get(name)!
}
const lib = await vfs.readLibrary(name)
if (!lib) return null
const map = new Map<string, Uint8Array>()
libraryCache.set(name, map)
for (const file of lib.files) {
map.set(file.path, file.contents)
}
return map
}
export function uploadScript(name: string, files: Record<string, string>) {
for (const [fileName, contents] of Object.entries(files)) {
scriptsStorage.set(`${name}/${fileName}`, contents)
}
}
export function forgetScript(name: string) {
const folder = `${name}/`
for (const path of scriptsStorage.keys()) {
if (path.startsWith(folder)) {
scriptsStorage.delete(path)
}
}
}
export function clearCache() {
libraryCache.clear()
scriptsStorage.clear()
importMapCache = undefined
vfs = undefined
}
// /sw/runtime/[library-name]/[...file...]
export async function handleRuntimeRequest(url: URL) {
const path = url.pathname.slice('/sw/runtime/'.length)
if (path === '_iframe.html') {
// primarily a workaround for chrome bug: https://crbug.com/880768
// (tldr: iframes created with blob: do not inherit service worker from the parent page)
// but also a nice way to warm up the cache
if (!importMapCache) {
const vfs = await getVfs()
const libNames = (await vfs.getAvailableLibs()).filter(lib => !lib.startsWith('@types/'))
const allLibs = await Promise.all(libNames.map(loadLibrary))
const packageJsons: any[] = []
for (const lib of allLibs) {
if (!lib) continue
const pkgJson = lib.get('package.json')
if (!pkgJson) continue
packageJsons.push(JSON.parse(utf8.decoder.decode(pkgJson)))
}
importMapCache = await generateImportMap(packageJsons)
}
const html = generateRunnerHtml(importMapCache)
return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html' } })
}
if (path.startsWith('script/')) {
const scriptId = path.slice('script/'.length)
if (!scriptsStorage.has(scriptId)) {
return new Response('Not found', { status: 404 })
}
return new Response(scriptsStorage.get(scriptId)!, {
headers: {
'Content-Type': 'application/javascript',
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
})
}
let slashIdx = path.indexOf('/')
if (slashIdx === -1) {
return new Response('Not found', { status: 404 })
}
if (path[0] === '@') {
// scoped package
slashIdx = path.indexOf('/', slashIdx + 1)
if (slashIdx === -1) {
return new Response('Not found', { status: 404 })
}
}
const packageName = path.slice(0, slashIdx)
const filePath = path.slice(slashIdx + 1)
const files = await loadLibrary(packageName)
if (!files) {
return new Response('Library not found', { status: 404 })
}
const file = files.get(filePath)
if (!file) return new Response('Not found', { status: 404 })
const ext = filePath.split('.').pop()!
const mime = {
js: 'application/javascript',
mjs: 'application/javascript',
wasm: 'application/wasm',
json: 'application/json',
}[ext] ?? 'application/octet-stream'
return new Response(file, {
headers: {
'Content-Type': mime,
},
})
}

5
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="vite/client" />
declare module '@mtcute/web?external' {
export * from '@mtcute/web'
}

3
sw.ts Normal file
View file

@ -0,0 +1,3 @@
// we have to do it this way because otherwise vite will bundle it to
// /src/sw/main.js and we wont be able to scope it to /
import './src/sw/main.ts'

106
tailwind.config.js Normal file
View file

@ -0,0 +1,106 @@
/** @type {import("tailwindcss").Config} */
module.exports = {
darkMode: ['class', '[data-kb-theme="dark"]'],
content: ['./src/**/*.{ts,tsx}'],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
info: {
DEFAULT: 'hsl(var(--info))',
foreground: 'hsl(var(--info-foreground))',
},
success: {
DEFAULT: 'hsl(var(--success))',
foreground: 'hsl(var(--success-foreground))',
},
warning: {
DEFAULT: 'hsl(var(--warning))',
foreground: 'hsl(var(--warning-foreground))',
},
error: {
DEFAULT: 'hsl(var(--error))',
foreground: 'hsl(var(--error-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
xl: 'calc(var(--radius) + 4px)',
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: 0 },
to: { height: 'var(--kb-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--kb-accordion-content-height)' },
to: { height: 0 },
},
'content-show': {
from: { opacity: 0, transform: 'scale(0.96)' },
to: { opacity: 1, transform: 'scale(1)' },
},
'content-hide': {
from: { opacity: 1, transform: 'scale(1)' },
to: { opacity: 0, transform: 'scale(0.96)' },
},
'caret-blink': {
'0%, 70%, 100%': { opacity: 1 },
'20%, 50%': { opacity: 0 },
},
},
fontSize: {
'2xs': ['0.625rem', { lineHeight: '1rem' }],
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'content-show': 'content-show 0.2s ease-out',
'content-hide': 'content-hide 0.2s ease-out',
'caret-blink': 'caret-blink 1.2s ease-out infinite',
},
},
},
plugins: [require('tailwindcss-animate')],
}

31
tsconfig.app.json Normal file
View file

@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"jsx": "preserve",
"jsxImportSource": "solid-js",
"lib": [
"ES2020",
"DOM",
"DOM.Iterable",
"WebWorker"
],
"moduleDetection": "force",
"useDefineForClassFields": true,
"baseUrl": ".",
"module": "ESNext",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
/* Linting */
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"files": []
}

22
tsconfig.node.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"moduleDetection": "force",
"module": "ESNext",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
/* Linting */
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["vite.config.ts"]
}

8
ui.config.json Normal file
View file

@ -0,0 +1,8 @@
{
"tsx": true,
"componentDir": "./src/lib/components/ui",
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app.css"
}
}

1
vendor/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
chobitsu

13
vendor/build-patched-chobitsu.sh vendored Normal file
View file

@ -0,0 +1,13 @@
#!/usr/bin/env bash
# https://github.com/liriliri/chobitsu/issues/18
set -euo pipefail
git clone https://github.com/liriliri/chobitsu
cd chobitsu
git checkout v1.8.4
git apply ../chobitsu.patch
npm install
npm run build

74
vendor/chobitsu.patch vendored Normal file
View file

@ -0,0 +1,74 @@
diff --git a/src/domains/Runtime.ts b/src/domains/Runtime.ts
index b980929..67388f9 100644
--- a/src/domains/Runtime.ts
+++ b/src/domains/Runtime.ts
@@ -64,9 +64,9 @@ export function getProperties(
return objManager.getProperties(params)
}
-export function evaluate(
+export async function evaluate(
params: Runtime.EvaluateRequest
-): Runtime.EvaluateResponse {
+): Promise<Runtime.EvaluateResponse> {
const ret: any = {}
let result: any
@@ -74,7 +74,7 @@ export function evaluate(
if (params.throwOnSideEffect && hasSideEffect(params.expression)) {
throw EvalError('Possible side-effect in debug-evaluate')
}
- result = evaluateJs(params.expression)
+ result = await evaluateJs(params.expression)
setGlobal('$_', result)
ret.result = objManager.wrap(result, {
generatePreview: true,
diff --git a/src/index.ts b/src/index.ts
index 1dd6e97..0ea8300 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -38,7 +38,6 @@ chobitsu.register('Runtime', {
discardConsoleEntries: noop,
getHeapUsage: noop,
getIsolateId: noop,
- releaseObject: noop,
releaseObjectGroup: noop,
runIfWaitingForDebugger: noop,
})
diff --git a/src/lib/evaluate.ts b/src/lib/evaluate.ts
index 0732a13..f0abcce 100644
--- a/src/lib/evaluate.ts
+++ b/src/lib/evaluate.ts
@@ -44,14 +44,14 @@ export function setGlobal(name: string, val: any) {
global[name] = val
}
-export default function evaluate(expression: string) {
+export default async function evaluate(expression: string) {
let ret
injectGlobal()
try {
- ret = eval.call(window, `(${expression})`)
+ ret = await eval.call(window, `(async() => (${expression}))()`)
} catch (e) {
- ret = eval.call(window, expression)
+ ret = await eval.call(window, `(async () => {${expression}})()`)
}
clearGlobal()
diff --git a/src/lib/objManager.ts b/src/lib/objManager.ts
index ff6a9e3..d79cdd6 100644
--- a/src/lib/objManager.ts
+++ b/src/lib/objManager.ts
@@ -46,6 +46,10 @@ export function wrap(
value: any,
{ generatePreview = false, self = value } = {}
): any {
+ if (typeof value === 'object' && value !== null && typeof value.toJSON === 'function') {
+ value = value.toJSON()
+ }
+
const ret = basic(value)
const { type, subtype } = ret

15
vite.config.ts Normal file
View file

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import solid from 'vite-plugin-solid'
import externalizeDeps from './scripts/vite-plugin-externalize-dependencies.ts'
export default defineConfig({
optimizeDeps: {
exclude: ['@mtcute/wasm'],
},
plugins: [
solid(),
externalizeDeps({
externals: [],
}),
],
})