Compare commits

...

7 commits

10 changed files with 183 additions and 44 deletions

View file

@ -1,5 +1,6 @@
import { ColorModeProvider, ColorModeScript } from '@kobalte/core' import type { RunnerController } from './components/runner/Runner.tsx'
import { ColorModeProvider, ColorModeScript } from '@kobalte/core'
import { workerInit } from 'mtcute-repl-worker/client' import { workerInit } from 'mtcute-repl-worker/client'
import { createSignal, lazy, onCleanup, onMount, Show } from 'solid-js' import { createSignal, lazy, onCleanup, onMount, Show } from 'solid-js'
import { EditorTabs } from './components/editor/EditorTabs.tsx' import { EditorTabs } from './components/editor/EditorTabs.tsx'
@ -16,6 +17,7 @@ export function App() {
const [updating, setUpdating] = createSignal(true) const [updating, setUpdating] = createSignal(true)
const [showSettings, setShowSettings] = createSignal(false) const [showSettings, setShowSettings] = createSignal(false)
const [settingsTab, setSettingsTab] = createSignal<SettingsTab>('accounts') const [settingsTab, setSettingsTab] = createSignal<SettingsTab>('accounts')
const [runnerController, setRunnerController] = createSignal<RunnerController>()
const [isResizing, setIsResizing] = createSignal(false) const [isResizing, setIsResizing] = createSignal(false)
const [sizes, setSizes] = createSignal([0.5, 0.5]) const [sizes, setSizes] = createSignal([0.5, 0.5])
@ -76,7 +78,10 @@ export function App() {
<Resizable sizes={sizes()} onSizesChange={e => setSizes(e)} orientation="horizontal" class="size-full max-h-[calc(100vh-57px)]"> <Resizable sizes={sizes()} onSizesChange={e => setSizes(e)} orientation="horizontal" class="size-full max-h-[calc(100vh-57px)]">
<ResizablePanel class="h-full overflow-x-auto overflow-y-hidden" minSize={0.2}> <ResizablePanel class="h-full overflow-x-auto overflow-y-hidden" minSize={0.2}>
<EditorTabs /> <EditorTabs />
<Editor class="size-full" /> <Editor
class="size-full"
onRun={() => runnerController()?.run()}
/>
</ResizablePanel> </ResizablePanel>
<ResizableHandle <ResizableHandle
withHandle withHandle
@ -90,7 +95,10 @@ export function App() {
class="flex max-h-full flex-col overflow-hidden" class="flex max-h-full flex-col overflow-hidden"
minSize={0.2} minSize={0.2}
> >
<Runner isResizing={isResizing()} /> <Runner
isResizing={isResizing()}
controllerRef={setRunnerController}
/>
</ResizablePanel> </ResizablePanel>
</Resizable> </Resizable>
</Show> </Show>

View file

@ -1,5 +1,5 @@
import { useColorModeValue } from '@kobalte/core' import { useColorModeValue } from '@kobalte/core'
import { editor as mEditor, Uri } from 'monaco-editor' import { KeyCode, KeyMod, editor as mEditor, Uri } from 'monaco-editor'
import { createEffect, on, onMount } from 'solid-js' import { createEffect, on, onMount } from 'solid-js'
import { $activeTab, $tabs, type EditorTab } from '../../store/tabs.ts' import { $activeTab, $tabs, type EditorTab } from '../../store/tabs.ts'
@ -9,6 +9,7 @@ import './Editor.css'
export interface EditorProps { export interface EditorProps {
class?: string class?: string
onRun: () => void
} }
const DEFAULT_CODE = ` const DEFAULT_CODE = `
@ -22,6 +23,8 @@ export const self = await tg.getMe()
console.log(self) console.log(self)
`.trimStart() `.trimStart()
const LOCAL_STORAGE_PREFIX = 'repl:tab-content:'
function findChangedTab(a: EditorTab[], b: EditorTab[]) { function findChangedTab(a: EditorTab[], b: EditorTab[]) {
const set = new Set(a.map(tab => tab.id)) const set = new Set(a.map(tab => tab.id))
for (const tab of b) { for (const tab of b) {
@ -39,7 +42,6 @@ export default function Editor(props: EditorProps) {
let editor: mEditor.IStandaloneCodeEditor | undefined let editor: mEditor.IStandaloneCodeEditor | undefined
const monacoTheme = useColorModeValue('latte', 'mocha') const monacoTheme = useColorModeValue('latte', 'mocha')
// const monacoTheme = () => scheme() === 'dark' ? 'ayu-dark' : 'ayu-light'
const modelsByTab = new Map<string, mEditor.ITextModel>() const modelsByTab = new Map<string, mEditor.ITextModel>()
onMount(async () => { onMount(async () => {
@ -75,15 +77,24 @@ export default function Editor(props: EditorProps) {
await setupMonaco() await setupMonaco()
for (const tab of tabs()) { for (const tab of tabs()) {
const model = mEditor.createModel(tab.main ? DEFAULT_CODE : '', 'typescript', Uri.parse(`file:///${tab.id}.ts`)) const storedCode = localStorage.getItem(LOCAL_STORAGE_PREFIX + tab.id)
const model = mEditor.createModel(storedCode ?? (tab.main ? DEFAULT_CODE : ''), 'typescript', Uri.parse(`file:///${tab.id}.ts`))
modelsByTab.set(tab.id, model) modelsByTab.set(tab.id, model)
} }
editor.setModel(modelsByTab.get(activeTab())!) editor.setModel(modelsByTab.get(activeTab())!)
// editor.onDidChangeModelContent(() => { editor.addCommand(KeyMod.CtrlCmd | KeyCode.Enter, () => {
// props.onCodeChange(editor?.getValue() ?? '') props.onRun()
// }) })
editor.onDidChangeModelContent(() => {
const currentTab = tabs().find(tab => tab.id === activeTab())!
const content = editor?.getModel()?.getValue()
if (!currentTab || !content) return
localStorage.setItem(LOCAL_STORAGE_PREFIX + currentTab.id, content)
})
return () => editor?.dispose() return () => editor?.dispose()
}) })
@ -136,6 +147,7 @@ export default function Editor(props: EditorProps) {
if (!changed) return if (!changed) return
modelsByTab.get(changed.id)?.dispose() modelsByTab.get(changed.id)?.dispose()
modelsByTab.delete(changed.id) modelsByTab.delete(changed.id)
localStorage.removeItem(LOCAL_STORAGE_PREFIX + changed.id)
} }
})) }))

View file

@ -1,12 +1,14 @@
import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu' import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu'
import type { mtcute } from 'mtcute-repl-worker/client' import type { mtcute } from 'mtcute-repl-worker/client'
import type { Setter } from 'solid-js'
import type { CustomTypeScriptWorker } from '../editor/utils/custom-worker.ts' import type { CustomTypeScriptWorker } from '../editor/utils/custom-worker.ts'
import { timers } from '@fuman/utils' import { timers } from '@fuman/utils'
import { persistentAtom } from '@nanostores/persistent' import { persistentAtom } from '@nanostores/persistent'
import { LucideCheck, LucidePlay, LucidePlug, LucideRefreshCw, LucideSkull, LucideUnplug } from 'lucide-solid' import { LucideCheck, LucidePlay, LucidePlug, LucideRefreshCw, LucideSettings2, LucideSkull, LucideUnplug } from 'lucide-solid'
import { languages, Uri } from 'monaco-editor/esm/vs/editor/editor.api.js' import { languages, Uri } from 'monaco-editor/esm/vs/editor/editor.api.js'
import { createEffect, createSignal, on, onCleanup, onMount } from 'solid-js' import { createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
import { Dynamic } from 'solid-js/web' import { Dynamic } from 'solid-js/web'
import { toast } from 'solid-sonner'
import { Button } from '../../lib/components/ui/button.tsx' import { Button } from '../../lib/components/ui/button.tsx'
import { import {
DropdownMenu, DropdownMenu,
@ -37,7 +39,14 @@ const $enableVerbose = persistentAtom('repl:verboseLogs', false, {
decode: value => value === 'true', decode: value => value === 'true',
}) })
export function Runner(props: { isResizing: boolean }) { export interface RunnerController {
run: () => void
}
export function Runner(props: {
isResizing: boolean
controllerRef: Setter<RunnerController | undefined>
}) {
const [devtoolsIframe, setDevtoolsIframe] = createSignal<HTMLIFrameElement | undefined>() const [devtoolsIframe, setDevtoolsIframe] = createSignal<HTMLIFrameElement | undefined>()
const [runnerIframe, setRunnerIframe] = createSignal<HTMLIFrameElement>() const [runnerIframe, setRunnerIframe] = createSignal<HTMLIFrameElement>()
const [runnerLoaded, setRunnerLoaded] = createSignal(false) const [runnerLoaded, setRunnerLoaded] = createSignal(false)
@ -154,6 +163,10 @@ export function Runner(props: { isResizing: boolean }) {
onMount(async () => { onMount(async () => {
window.addEventListener('message', handleMessage) window.addEventListener('message', handleMessage)
props.controllerRef({
run: () => handleRun(),
})
}) })
onCleanup(() => { onCleanup(() => {
window.removeEventListener('message', handleMessage) window.removeEventListener('message', handleMessage)
@ -168,6 +181,11 @@ export function Runner(props: { isResizing: boolean }) {
}, { defer: true })) }, { defer: true }))
async function handleRun() { async function handleRun() {
if ($activeAccountId.get() === undefined) {
toast('You need to log in to run a script')
return
}
const getWorker = await languages.typescript.getTypeScriptWorker() const getWorker = await languages.typescript.getTypeScriptWorker()
const worker = await getWorker(Uri.parse('file:///main.ts')) as unknown as CustomTypeScriptWorker const worker = await getWorker(Uri.parse('file:///main.ts')) as unknown as CustomTypeScriptWorker
@ -250,14 +268,7 @@ export function Runner(props: { isResizing: boolean }) {
</Button> </Button>
)} )}
<div class="flex-1" /> <div class="flex-1" />
<DropdownMenu> <div class="mr-2 flex items-center text-xs font-medium">
<DropdownMenuTrigger
as={(props: DropdownMenuTriggerProps) => (
<Button
variant="ghost"
size="xs"
{...props}
>
{{ {{
offline: 'Disconnected', offline: 'Disconnected',
connecting: 'Connecting...', connecting: 'Connecting...',
@ -274,6 +285,17 @@ export function Runner(props: { isResizing: boolean }) {
}[connectionState()], }[connectionState()],
)} )}
/> />
</div>
<DropdownMenu>
<DropdownMenuTrigger
as={(props: DropdownMenuTriggerProps) => (
<Button
variant="ghost"
size="icon"
class="size-7"
{...props}
>
<LucideSettings2 class="size-4" />
</Button> </Button>
)} )}
/> />

View file

@ -13,6 +13,7 @@ import {
LucideRefreshCw, LucideRefreshCw,
LucideSearch, LucideSearch,
LucideTrash, LucideTrash,
LucideTriangleAlert,
LucideUser, LucideUser,
LucideX, LucideX,
} from 'lucide-solid' } from 'lucide-solid'
@ -22,6 +23,7 @@ import { nanoid } from 'nanoid'
import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from 'solid-js' import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from 'solid-js'
import { toast } from 'solid-sonner' import { toast } from 'solid-sonner'
import { copyToClipboard } from '../../lib/clipboard.tsx' import { copyToClipboard } from '../../lib/clipboard.tsx'
import { Alert, AlertDescription, AlertTitle } from '../../lib/components/ui/alert.tsx'
import { Badge } from '../../lib/components/ui/badge.tsx' import { Badge } from '../../lib/components/ui/badge.tsx'
import { Button } from '../../lib/components/ui/button.tsx' import { Button } from '../../lib/components/ui/button.tsx'
import { Dialog, DialogContent } from '../../lib/components/ui/dialog.tsx' import { Dialog, DialogContent } from '../../lib/components/ui/dialog.tsx'
@ -298,7 +300,7 @@ export function AccountsTab() {
return accounts() return accounts()
} }
return accounts().filter((account) => { return accounts()?.filter((account) => {
return account.name.toLowerCase().includes(query) || account.telegramId.toString().includes(query) return account.name.toLowerCase().includes(query) || account.telegramId.toString().includes(query)
}) })
}) })
@ -306,9 +308,25 @@ export function AccountsTab() {
return ( return (
<> <>
<Show <Show
when={accounts().length !== 0} when={accounts()?.length !== 0}
fallback={( fallback={(
<div class="flex h-full flex-col items-center justify-center gap-4 text-muted-foreground"> <div class="flex h-full flex-col items-center justify-center gap-4 px-2 text-muted-foreground">
<Alert variant="destructive" class="max-w-md">
<LucideTriangleAlert class="size-4" />
<AlertTitle class="font-bold">Warning</AlertTitle>
<AlertDescription>
This is an
{' '}
<b>unofficial</b>
{' '}
Telegram application.
<br />
You might trigger anti-spam measures and get banned.
<br />
Proceed at your own risk.
</AlertDescription>
</Alert>
No accounts yet No accounts yet
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<Button <Button

View file

@ -25,7 +25,7 @@ export function TdataImportDialog(props: {
const [error, setError] = createSignal<string | undefined>('') const [error, setError] = createSignal<string | undefined>('')
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const accountExists = (id: number) => $accounts.get().some(it => it.telegramId === id) const accountExists = (id: number) => $accounts.get()?.some(it => it.telegramId === id)
let abortController: AbortController | undefined let abortController: AbortController | undefined
const handleSubmit = async () => { const handleSubmit = async () => {

View file

@ -81,7 +81,7 @@ function QrLoginStep(props: StepProps<'qr'>) {
/> />
) : <Spinner indeterminate class="size-10" />} ) : <Spinner indeterminate class="size-10" />}
</div> </div>
<ol class="text-muted-foreground mt-4 list-inside list-decimal text-sm"> <ol class="mt-4 list-inside list-decimal text-sm text-muted-foreground">
<li>Open Telegram on your phone</li> <li>Open Telegram on your phone</li>
<li> <li>
Go to Go to
@ -147,7 +147,7 @@ function PhoneNumberStep(props: StepProps<'phone'>) {
<h2 class="mt-4 text-xl font-bold"> <h2 class="mt-4 text-xl font-bold">
Log in with phone number Log in with phone number
</h2> </h2>
<div class="text-muted-foreground mt-2 text-center text-sm"> <div class="mt-2 text-center text-sm text-muted-foreground">
Please confirm your country code Please confirm your country code
<br /> <br />
and enter your phone number and enter your phone number
@ -177,7 +177,7 @@ function PhoneNumberStep(props: StepProps<'phone'>) {
<TextFieldErrorMessage>{error()}</TextFieldErrorMessage> <TextFieldErrorMessage>{error()}</TextFieldErrorMessage>
</TextFieldRoot> </TextFieldRoot>
<div class="flex-1" /> <div class="flex-1" />
<div class="text-muted-foreground text-center text-sm"> <div class="text-center text-sm text-muted-foreground">
or, or,
{' '} {' '}
<a <a
@ -309,7 +309,7 @@ function OtpStep(props: StepProps<'otp'>) {
Wrong number? Wrong number?
</div> </div>
<div class="text-muted-foreground mt-4 text-center text-sm"> <div class="mt-4 text-center text-sm text-muted-foreground">
{description()} {description()}
</div> </div>
<div class="mt-4 flex flex-col items-center text-center"> <div class="mt-4 flex flex-col items-center text-center">
@ -363,7 +363,7 @@ function OtpStep(props: StepProps<'otp'>) {
</OTPFieldGroup> </OTPFieldGroup>
</OTPField> </OTPField>
{error() && ( {error() && (
<div class="text-error-foreground mt-1 text-sm">{error()}</div> <div class="mt-1 text-sm text-error-foreground">{error()}</div>
)} )}
</Show> </Show>
@ -438,7 +438,7 @@ function PasswordStep(props: StepProps<'password'>) {
<h2 class="text-xl font-bold"> <h2 class="text-xl font-bold">
2FA password 2FA password
</h2> </h2>
<div class="text-muted-foreground mt-4 text-center text-sm"> <div class="mt-4 text-center text-sm text-muted-foreground">
Your account is protected with an additional password. Your account is protected with an additional password.
</div> </div>
<div class="mt-4"> <div class="mt-4">
@ -483,9 +483,9 @@ function DoneStep(props: StepProps<'done'>) {
<div class="flex flex-col items-center justify-center"> <div class="flex flex-col items-center justify-center">
<AccountAvatar <AccountAvatar
account={props.ctx.account} account={props.ctx.account}
class="animate-scale-up fill-mode-forwards mb-4 size-24 shadow-sm" class="mb-4 size-24 animate-scale-up shadow-sm fill-mode-forwards"
/> />
<div class="animate-fade-out-down fill-mode-forwards text-center font-medium"> <div class="animate-fade-out-down text-center font-medium fill-mode-forwards">
Welcome, Welcome,
{' '} {' '}
{props.ctx.account.name} {props.ctx.account.name}

View file

@ -0,0 +1,64 @@
import type { AlertRootProps } from '@kobalte/core/alert'
import type { PolymorphicProps } from '@kobalte/core/polymorphic'
import type { VariantProps } from 'class-variance-authority'
import type { ComponentProps, ValidComponent } from 'solid-js'
import { Alert as AlertPrimitive } from '@kobalte/core/alert'
import { cva } from 'class-variance-authority'
import { splitProps } from 'solid-js'
import { cn } from '../../utils.ts'
export const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm [&:has(svg)]:pl-11 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: 'default',
},
},
)
type alertProps<T extends ValidComponent = 'div'> = AlertRootProps<T> &
VariantProps<typeof alertVariants> & {
class?: string
}
export function Alert<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, alertProps<T>>) {
const [local, rest] = splitProps(props as alertProps, ['class', 'variant'])
return (
<AlertPrimitive
class={cn(
alertVariants({
variant: props.variant,
}),
local.class,
)}
{...rest}
/>
)
}
export function AlertTitle(props: ComponentProps<'div'>) {
const [local, rest] = splitProps(props, ['class'])
return (
<div
class={cn('font-medium leading-5 tracking-tight', local.class)}
{...rest}
/>
)
}
export function AlertDescription(props: ComponentProps<'div'>) {
const [local, rest] = splitProps(props, ['class'])
return (
<div class={cn('text-sm [&_p]:leading-relaxed', local.class)} {...rest} />
)
}

View file

@ -2,13 +2,13 @@ import type { TelegramAccount } from 'mtcute-repl-worker/client'
import { computed } from 'nanostores' import { computed } from 'nanostores'
import { linkedAtom } from './link.ts' import { linkedAtom } from './link.ts'
export const $accounts = linkedAtom<TelegramAccount[]>('accounts') export const $accounts = linkedAtom<TelegramAccount[] | undefined>('accounts')
export const $activeAccountId = linkedAtom<string | undefined>('activeAccountId') export const $activeAccountId = linkedAtom<string | undefined>('activeAccountId')
export const $activeAccount = computed([$accounts, $activeAccountId], (accounts, activeAccountId) => { export const $activeAccount = computed([$accounts, $activeAccountId], (accounts, activeAccountId) => {
if (!activeAccountId) return null if (!activeAccountId) return null
const account = accounts.find(account => account.id === activeAccountId) const account = accounts?.find(account => account.id === activeAccountId)
if (!account) return null if (!account) return null
return account return account

View file

@ -1,3 +1,4 @@
import { persistentAtom } from '@nanostores/persistent'
import { atom } from 'nanostores' import { atom } from 'nanostores'
export interface EditorTab { export interface EditorTab {
@ -6,12 +7,15 @@ export interface EditorTab {
main: boolean main: boolean
} }
export const $tabs = atom<EditorTab[]>([ export const $tabs = persistentAtom<EditorTab[]>('repl:tabs', [
{ {
id: 'main', id: 'main',
fileName: 'main.ts', fileName: 'main.ts',
main: true, main: true,
}, },
]) ], {
encode: JSON.stringify,
decode: JSON.parse,
})
export const $activeTab = atom('main') export const $activeTab = atom('main')

View file

@ -141,6 +141,16 @@ window.addEventListener('message', async ({ data }) => {
currentScriptId = nanoid() currentScriptId = nanoid()
await swInvokeMethodInner({ event: 'UPLOAD_SCRIPT', name: currentScriptId, files: data.files }, asNonNull(navigator.serviceWorker.controller)) await swInvokeMethodInner({ event: 'UPLOAD_SCRIPT', name: currentScriptId, files: data.files }, asNonNull(navigator.serviceWorker.controller))
if (!window.tg) {
// shouldnt happen but just in case
console.warn('[mtcute-repl] Telegram client not initialized yet')
return
}
if (lastConnectionState === 'offline') {
await window.tg.connect()
}
const el = document.createElement('script') const el = document.createElement('script')
el.type = 'module' el.type = 'module'
let script = `import * as result from "/sw/runtime/script/${currentScriptId}/main.js";` let script = `import * as result from "/sw/runtime/script/${currentScriptId}/main.js";`
@ -177,6 +187,7 @@ window.addEventListener('message', async ({ data }) => {
initClient(lastAccountId, data.verboseLogs) initClient(lastAccountId, data.verboseLogs)
} }
window.parent.postMessage({ event: 'CONNECTION_STATE', value: 'offline' }, HOST_ORIGIN) window.parent.postMessage({ event: 'CONNECTION_STATE', value: 'offline' }, HOST_ORIGIN)
lastConnectionState = 'offline'
} else if (data.event === 'RECONNECT') { } else if (data.event === 'RECONNECT') {
if (window.tg !== undefined) { if (window.tg !== undefined) {
window.tg.connect() window.tg.connect()