+
+
+
+
+ setDetailOpen((v) => !v) }} />
+
+
+
+
+ {detailOpen ? (
+
+
setDetailOpen(false)} />
+
+ setDetailOpen(false)} />
+
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/index.css b/web/src/index.css
new file mode 100644
index 00000000..23adf1f6
--- /dev/null
+++ b/web/src/index.css
@@ -0,0 +1,118 @@
+@import "tailwindcss";
+
+/*
+ * Design tokens ported 1:1 from docs/design_handoff_meetingim_web/README.md's "Design Tokens"
+ * section. Values are exact hex/px from the design handoff, not semantic guesses -- keep them in
+ * sync with that doc, not with whatever the previous im_webapp's css/base.css happened to use.
+ *
+ * Breakpoints are intentionally remapped onto Tailwind's own sm/md/lg so every component can just
+ * use the standard `sm:`/`md:`/`lg:` prefixes (min-width, mobile-first) instead of inventing custom
+ * variants: <640 base styles = single column mobile, sm: (>=640) = two column, md: (>=960) = three
+ * column, lg: (>=1280) = four column docked detail panel.
+ */
+@theme {
+ --breakpoint-sm: 640px;
+ --breakpoint-md: 960px;
+ --breakpoint-lg: 1280px;
+
+ --font-sans: Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
+
+ /* Primary / accent */
+ --color-accent: #1677ff;
+ --color-accent-soft-bg: #e3eefe;
+ --color-accent-selected-bg: #e8f1fe;
+ --color-focus-border: #4091ff;
+
+ --color-btn: #368bfa;
+ --color-btn-hover: #1677e8;
+ --color-btn-disabled: #b8d5fa;
+
+ /* Sent bubble */
+ --color-bubble-mine: #b9eb8f;
+
+ /* Surfaces */
+ --color-surface-chat: #eef1f4;
+ --color-surface-content: #f6f7f9;
+ --color-surface-page: #f7f9fc;
+ --color-surface-rail: #f2f4f7;
+ --color-surface-input: #f2f4f7;
+ --color-surface-search: #f1f2f4;
+
+ /* Borders */
+ --color-border-panel: #eceef1;
+ --color-border-row: #f1f2f4;
+ --color-border-list-item: #f4f5f7;
+
+ /* Text */
+ --color-text-title: #14171c;
+ --color-text-heading: #22262d;
+ --color-text-body: #252931;
+ --color-text-secondary: #656b74;
+ --color-text-tertiary-1: #4a4f57;
+ --color-text-tertiary-2: #969ca5;
+ --color-text-tertiary-3: #9ca2aa;
+ --color-text-tertiary-4: #8d949d;
+ --color-text-placeholder-1: #b2b7bf;
+ --color-text-placeholder-2: #a5abb4;
+
+ --color-icon-default: #555c65;
+ --color-icon-arrow: #c2c7ce;
+
+ /* Status */
+ --color-unread: #f34b46;
+ --color-danger-text: #d94b4b;
+ --color-danger-border: #cf3f3f;
+ --color-danger-border-soft: #f3c4c4;
+ --color-online: #1fb36a;
+ --color-offline: #aeb4bd;
+
+ /* Orange (system messages / group owner / warning) */
+ --color-warn: #ff951c;
+ --color-warn-bg: #fff5e5;
+ --color-announce: #f5a623;
+ --color-announce-bg: #fff8e6;
+ --color-announce-text: #6d4c41;
+
+ /* Green (file assistant / success) */
+ --color-success: #16ae54;
+ --color-success-bg: #eefaf3;
+
+ /* Alert strips */
+ --color-error-strip-bg: #ffeef0;
+ --color-error-strip-text: #b51c1c;
+ --color-info-strip-bg: #e8f1fe;
+ --color-info-strip-text: #1a4f9c;
+
+ /* Call surfaces (dark) */
+ --color-call-bg-1: #1b2231;
+ --color-call-bg-2: #1f2a3d;
+ --color-call-bg-3: #3b4a63;
+
+ /* Avatar palette (deterministic per-contact hashing picks from this list) */
+ --color-avatar-1: #3c8df6;
+ --color-avatar-2: #7c5cf6;
+ --color-avatar-3: #2bb673;
+ --color-avatar-4: #f28c3b;
+ --color-avatar-5: #e2537a;
+ --color-avatar-6: #0097a8;
+ --color-avatar-7: #8d6e63;
+ --color-avatar-8: #5c6bc0;
+ --color-avatar-9: #26a69a;
+ --color-avatar-10: #ef6c00;
+ --color-avatar-11: #9ca2aa;
+
+ /* Shadows */
+ --shadow-card: 0 8px 32px rgba(35, 74, 125, 0.10);
+ --shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.18);
+ --shadow-menu: 0 12px 36px rgba(0, 0, 0, 0.16);
+ --shadow-bubble: 0 1px 2px rgba(0, 0, 0, 0.06);
+ --shadow-btn: 0 4px 12px rgba(54, 139, 250, 0.2);
+ --shadow-focus-ring: 0 0 0 3px rgba(22, 119, 255, 0.1);
+}
+
+body {
+ margin: 0;
+ background: var(--color-surface-page);
+ color: var(--color-text-body);
+ font-family: var(--font-sans);
+}
diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts
new file mode 100644
index 00000000..03ae1056
--- /dev/null
+++ b/web/src/lib/config.ts
@@ -0,0 +1,78 @@
+// Ported from the previous im_webapp's src/config.js. These values encode real constraints that
+// must match the backend (server/*.go) -- they are not stylistic choices, so the numbers/strings
+// themselves carry over unchanged even though this is a from-scratch rewrite.
+
+/** Version-independent product name (Firebase app name, youth-mode copy, etc.). */
+export const BASE_APP_NAME = 'MeetingIM';
+
+/** Name used in the Tinode client's User-Agent string. */
+export const APP_NAME = `${BASE_APP_NAME}/${__APP_VERSION__}`;
+
+/**
+ * Tinode API key for the WS handshake ({hi} packet) and every REST call's X-MeetingIM-APIKey
+ * header. Generated with https://github.com/tinode/chat/tree/master/keygen against this
+ * deployment's own API_KEY_SALT -- the fallback below only works against a backend still using
+ * the upstream sample salt, same caveat as the old app.
+ */
+export const API_KEY = import.meta.env.VITE_TINODE_API_KEY || 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';
+
+export const KNOWN_HOSTS = { hosted: 'web.118im.com', local: 'localhost:6060' } as const;
+export const DEFAULT_HOST = KNOWN_HOSTS.hosted;
+
+export const LOGGING_ENABLED = true;
+
+/** Minimum time between two keypress notifications, ms. */
+export const KEYPRESS_DELAY = 3_000;
+/** Delay before sending a read notification, ms. */
+export const READ_DELAY = 1_500;
+
+export const MIN_TAG_LENGTH = 2;
+export const MAX_TAG_LENGTH = 96;
+export const MAX_TAG_COUNT = 16;
+
+export const DEFAULT_P2P_ACCESS_MODE = 'JRWPS';
+export const NEW_GRP_ACCESS_MODE = 'JRWPSAO';
+export const CHANNEL_ACCESS_MODE = 'JR';
+export const NO_ACCESS_MODE = 'N';
+
+/** Design breakpoints, px. Four tiers per the new design handoff (was a single 640 cut before). */
+export const BREAKPOINTS = { sm: 640, md: 960, lg: 1280 } as const;
+
+export const AVATAR_SIZE = 384;
+export const MAX_AVATAR_BYTES = 4096;
+export const BROKEN_IMAGE_SIZE = 32;
+export const NO_DIMENSIONS_VIDEO = 128;
+
+export const MESSAGES_PAGE = 24;
+
+export const MAX_INBAND_ATTACHMENT_SIZE = 262_144;
+export const MAX_EXTERN_ATTACHMENT_SIZE = 1 << 23;
+export const MAX_IMAGE_DIM = 1024;
+export const IMAGE_PREVIEW_DIM = 64;
+export const VIDEO_PREVIEW_DIM = 96;
+export const IMAGE_THUMBNAIL_DIM = 36;
+export const VIDEO_THUMBNAIL_WIDTH = 48;
+
+export const MAX_ONLINE_IN_TOPIC = 4;
+export const MAX_TITLE_LENGTH = 60;
+export const MAX_TOPIC_DESCRIPTION_LENGTH = 360;
+export const MAX_PEER_TITLE_LENGTH = 24;
+
+export const MESSAGE_PREVIEW_LENGTH = 80;
+export const QUOTED_REPLY_LENGTH = 30;
+export const FORWARDED_PREVIEW_LENGTH = 84;
+export const EDIT_PREVIEW_LENGTH = 48;
+
+export const MIN_DURATION = 2_000;
+export const MAX_DURATION = 600_000;
+
+export const IMMEDIATE_P2P_SUBSCRIPTION = false;
+
+export const CLICKABLE_URL_SCHEMES = ['http', 'https', 'ftp', 'ftps'];
+
+export const QRCODE_SIZE = 128;
+
+export const WAKE_UP_TIMEOUT = 80_000;
+export const WAKE_UP_TICK = 1_000;
+
+export const TOAST_DURATION = 3_000;
diff --git a/web/src/lib/tinode/tinode-sdk.d.ts b/web/src/lib/tinode/tinode-sdk.d.ts
new file mode 100644
index 00000000..17dc23f6
--- /dev/null
+++ b/web/src/lib/tinode/tinode-sdk.d.ts
@@ -0,0 +1,133 @@
+/**
+ * `tinode-sdk` ships as a plain UMD JS bundle with no bundled TypeScript types. This is a
+ * deliberately partial ambient declaration covering only the surface this app actually calls --
+ * grown incrementally as later phases touch more of the SDK, not an attempt at a full port of the
+ * upstream JS. Method/property names and shapes below are verified against the installed
+ * `node_modules/tinode-sdk/umd/tinode.dev.js` (v0.25.3), not guessed from the upstream docs, since
+ * this project's own history has repeatedly found the two disagree (e.g. this deployment's REST
+ * layer uses `X-MeetingIM-APIKey`, not the upstream `X-Tinode-APIKey`).
+ */
+declare module 'tinode-sdk' {
+ export interface TinodeOptions {
+ appName: string;
+ host: string;
+ apiKey: string;
+ transport?: 'ws' | 'lp' | null;
+ secure?: boolean;
+ persist?: boolean;
+ }
+
+ export type AuthLevel = 'anon' | 'auth' | 'root';
+
+ export class AccessMode {
+ mode: string;
+ given: string;
+ want: string;
+ isOwner(): boolean;
+ isAdmin(): boolean;
+ isSharer(): boolean;
+ isWriter(): boolean;
+ isReader(): boolean;
+ isMuted(): boolean;
+ isDeleter(): boolean;
+ isJoiner(): boolean;
+ isPresencer(): boolean;
+ }
+
+ export interface TopicDescPublic {
+ fn?: string;
+ name?: string;
+ photo?: { data?: string; type?: string; ref?: string };
+ [key: string]: unknown;
+ }
+
+ export interface TopicDescPrivate {
+ comment?: string;
+ pinned?: boolean;
+ [key: string]: unknown;
+ }
+
+ /** A row from `MeTopic.contacts()` -- a live, mutable contact/subscription snapshot. */
+ export interface Contact {
+ topic: string;
+ user?: string;
+ updated?: Date;
+ touched?: Date;
+ online?: boolean;
+ acs?: AccessMode;
+ seq?: number;
+ read?: number;
+ recv?: number;
+ unread?: number;
+ public?: TopicDescPublic | null;
+ private?: TopicDescPrivate | null;
+ latestMessage?: () => StoredMessage | undefined;
+ }
+
+ export interface StoredMessage {
+ seq: number;
+ from?: string;
+ ts?: Date;
+ content?: unknown;
+ head?: Record
;
+ }
+
+ export class Topic {
+ name: string;
+ subscribe(): Promise;
+ leave(unsub?: boolean): Promise;
+ getMeta(query: unknown): Promise;
+ publish(data: unknown, head?: Record): Promise;
+ isSubscribed(): boolean;
+ }
+
+ export class MeTopic extends Topic {
+ contacts(callback: (contact: Contact) => void): void;
+ pinnedTopicRank(topic: string): number;
+ getMeta(query: unknown): Promise;
+ }
+
+ export class Tinode {
+ constructor(options: TinodeOptions, onCompletion?: () => void);
+
+ _connection?: { sendText: (msg: string) => unknown; __tenantPatched?: boolean };
+
+ setHumanLanguage(locale: string): void;
+ enableLogging(enabled: boolean, verbose?: boolean): void;
+
+ connect(host?: string): Promise;
+ disconnect(): void;
+ isConnected(): boolean;
+ isAuthenticated(): boolean;
+ getAuthenticatedUserID(): string | undefined;
+
+ login(scheme: string, secret: string, cred?: unknown): Promise<{ ctrl: { code: number; params?: Record } }>;
+ loginToken(token: string, cred?: unknown): Promise<{ ctrl: { code: number } }>;
+ getAuthToken(): { token: string; expires: Date } | null;
+
+ getMeTopic(): MeTopic;
+ getTopic(topicName: string): Topic;
+
+ account(userId: string, scheme: string, secret: string, login: boolean, desc?: unknown): Promise;
+
+ onConnect?: () => void;
+ onDisconnect?: (err?: Error) => void;
+ onLogin?: (code: number, text: string) => void;
+ onNetworkProbe?: () => void;
+ onAutoreconnectIteration?: (seconds: number, promise?: Promise) => void;
+ }
+
+ export const Drafty: {
+ init(text: string): unknown;
+ parse(text: string): unknown;
+ toPlainText(content: unknown): string;
+ preview(content: unknown, length: number, cutFirst?: boolean): unknown;
+ videoCall(audioOnly: boolean): unknown;
+ isValid(content: unknown): boolean;
+ [key: string]: unknown;
+ };
+
+ export const TheCard: {
+ [key: string]: unknown;
+ };
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 00000000..bef5202a
--- /dev/null
+++ b/web/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.tsx'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts
new file mode 100644
index 00000000..ece79680
--- /dev/null
+++ b/web/src/vite-env.d.ts
@@ -0,0 +1,12 @@
+///
+
+/** Injected by vite.config.ts's `define`, from package.json's version field. */
+declare const __APP_VERSION__: string;
+
+interface ImportMetaEnv {
+ readonly VITE_TINODE_API_KEY?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json
new file mode 100644
index 00000000..658d9c82
--- /dev/null
+++ b/web/tsconfig.app.json
@@ -0,0 +1,32 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023", "DOM"],
+ "module": "esnext",
+ "types": ["vite/client"],
+ "allowArbitraryExtensions": true,
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Path aliases */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ },
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 00000000..1ffef600
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json
new file mode 100644
index 00000000..8455dcbc
--- /dev/null
+++ b/web/tsconfig.node.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023"],
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "module": "nodenext",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 00000000..2612d6ee
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,23 @@
+import { fileURLToPath, URL } from 'node:url'
+
+import tailwindcss from '@tailwindcss/vite'
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+
+import pkg from './package.json' with { type: 'json' }
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ },
+ define: {
+ __APP_VERSION__: JSON.stringify(pkg.version),
+ },
+ server: {
+ port: 8094,
+ },
+})