공개: KoTalk 최신 기준선
This commit is contained in:
commit
debf62f76e
572 changed files with 41689 additions and 0 deletions
25
src/PhysOn.Web/.gitignore
vendored
Normal file
25
src/PhysOn.Web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.tsbuildinfo
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
27
src/PhysOn.Web/README.md
Normal file
27
src/PhysOn.Web/README.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# vstalk Web
|
||||
|
||||
`src/PhysOn.Web`는 `vstalk.phy.kr`에 배포할 모바일 퍼스트 웹앱 채널입니다.
|
||||
|
||||
현재 범위:
|
||||
- 이름 + 초대코드 기반 초간단 가입
|
||||
- 최근 대화 목록
|
||||
- 대화 진입과 메시지 읽기
|
||||
- 텍스트 메시지 전송
|
||||
- 모바일 브라우저용 PWA 메타데이터
|
||||
|
||||
개발 명령:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
기본 개발 프록시:
|
||||
- 웹앱: `http://127.0.0.1:4173`
|
||||
- API 프록시 대상: `http://127.0.0.1:5082`
|
||||
|
||||
환경 변수:
|
||||
- `VITE_API_BASE_URL`
|
||||
- `VITE_DEV_PROXY_TARGET`
|
||||
|
||||
프로덕션 배포는 루트 문서의 [deploy/README.md](../../deploy/README.md)와 [문서/17-vstalk-webapp-mvp-and-rollout-plan.md](../../문서/17-vstalk-webapp-mvp-and-rollout-plan.md)를 따른다.
|
||||
28
src/PhysOn.Web/eslint.config.js
Normal file
28
src/PhysOn.Web/eslint.config.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
23
src/PhysOn.Web/index.html
Normal file
23
src/PhysOn.Web/index.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#101826" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="KoTalk" />
|
||||
<meta
|
||||
name="description"
|
||||
content="업무 대화와 일상 대화를 같은 흐름 안에서 가볍게 이어 주는 KoTalk 웹앱입니다."
|
||||
/>
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.svg" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>KoTalk</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3811
src/PhysOn.Web/package-lock.json
generated
Normal file
3811
src/PhysOn.Web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
src/PhysOn.Web/package.json
Normal file
30
src/PhysOn.Web/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "physon-web",
|
||||
"private": true,
|
||||
"version": "0.1.0-alpha.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^9.9.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"globals": "^15.9.0",
|
||||
"puppeteer-core": "^24.41.0",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.0.1",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
}
|
||||
5
src/PhysOn.Web/public/apple-touch-icon.svg
Normal file
5
src/PhysOn.Web/public/apple-touch-icon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="180" height="180" rx="50" fill="#101826"/>
|
||||
<path d="M42 55C42 47.268 48.268 41 56 41H124C131.732 41 138 47.268 138 55V104C138 111.732 131.732 118 124 118H89.285L65.86 136.409C61.252 140.031 54.55 136.752 54.55 130.896V118H56C48.268 118 42 111.732 42 104V55Z" fill="#F6F2EA"/>
|
||||
<path d="M63 77.5L78.363 100.438L92.545 77.727L107.223 100.438L122.5 77.5" stroke="#101826" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 562 B |
5
src/PhysOn.Web/public/icon.svg
Normal file
5
src/PhysOn.Web/public/icon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" rx="144" fill="#101826"/>
|
||||
<path d="M120 150C120 127.909 137.909 110 160 110H352C374.091 110 392 127.909 392 150V304C392 326.091 374.091 344 352 344H251.232L184.314 396.674C171.148 407.041 152 397.656 152 380.899V344H160C137.909 344 120 326.091 120 304V150Z" fill="#F6F2EA"/>
|
||||
<path d="M171 201.5L214.894 267L255.4 202.15L297.35 267L341 201.5" stroke="#101826" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 570 B |
24
src/PhysOn.Web/public/manifest.webmanifest
Normal file
24
src/PhysOn.Web/public/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "PhysOn",
|
||||
"short_name": "PhysOn",
|
||||
"description": "업무 소통과 친근한 대화를 한 손 흐름으로 이어 주는 모바일 중심 메신저 웹앱",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#101826",
|
||||
"theme_color": "#101826",
|
||||
"lang": "ko-KR",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/mask-icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
4
src/PhysOn.Web/public/mask-icon.svg
Normal file
4
src/PhysOn.Web/public/mask-icon.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" rx="144" fill="#101826"/>
|
||||
<path d="M120 150C120 127.909 137.909 110 160 110H352C374.091 110 392 127.909 392 150V304C392 326.091 374.091 344 352 344H251.232L184.314 396.674C171.148 407.041 152 397.656 152 380.899V344H160C137.909 344 120 326.091 120 304V150Z" fill="#F6F2EA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 417 B |
57
src/PhysOn.Web/public/sw.js
Normal file
57
src/PhysOn.Web/public/sw.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
const CACHE_NAME = 'vs-talk-shell-v2';
|
||||
const SHELL = ['/manifest.webmanifest', '/icon.svg', '/apple-touch-icon.svg', '/mask-icon.svg'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
|
||||
),
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
if (url.origin !== self.location.origin || url.pathname.startsWith('/v1/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then(async (response) => {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put('/index.html', response.clone());
|
||||
return response;
|
||||
})
|
||||
.catch(async () => {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
return (await cache.match('/index.html')) ?? Response.error();
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return fetch(event.request).then((response) => {
|
||||
const cloned = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, cloned));
|
||||
return response;
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
5
src/PhysOn.Web/public/vs-mark.svg
Normal file
5
src/PhysOn.Web/public/vs-mark.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
|
||||
<rect width="128" height="128" rx="34" fill="#17372F"/>
|
||||
<path d="M27 33h23l14 25 14-25h23L75 95H53L27 33Z" fill="#F6E8D5"/>
|
||||
<path d="M60 39h8v50h-8z" fill="#DDB07B"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 255 B |
1093
src/PhysOn.Web/src/App.css
Normal file
1093
src/PhysOn.Web/src/App.css
Normal file
File diff suppressed because it is too large
Load diff
1573
src/PhysOn.Web/src/App.tsx
Normal file
1573
src/PhysOn.Web/src/App.tsx
Normal file
File diff suppressed because it is too large
Load diff
71
src/PhysOn.Web/src/index.css
Normal file
71
src/PhysOn.Web/src/index.css
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
:root {
|
||||
font-family:
|
||||
'Pretendard Variable',
|
||||
'SUIT Variable',
|
||||
'Noto Sans KR',
|
||||
'Apple SD Gothic Neo',
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
color: #111111;
|
||||
background: #f5f5f6;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--surface-page: #f5f5f6;
|
||||
--surface-base: #ffffff;
|
||||
--surface-raised: #fcfcfc;
|
||||
--surface-muted: #f6f6f7;
|
||||
--surface-selected: #eef2f6;
|
||||
--surface-chat-mine: #f1f1f2;
|
||||
--border-subtle: #ececef;
|
||||
--border-strong: #d8d8dd;
|
||||
--border-contrast: #18181b;
|
||||
--text-strong: #141416;
|
||||
--text-soft: #333338;
|
||||
--text-muted: #7b7b84;
|
||||
--focus-ring: #1a73e8;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
background: var(--surface-page);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100svh;
|
||||
background: var(--surface-page);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100svh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
202
src/PhysOn.Web/src/lib/api.ts
Normal file
202
src/PhysOn.Web/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import type {
|
||||
ApiEnvelope,
|
||||
ApiErrorEnvelope,
|
||||
BootstrapResponse,
|
||||
ListEnvelope,
|
||||
MessageItemDto,
|
||||
ReadCursorUpdatedDto,
|
||||
RefreshTokenRequest,
|
||||
RefreshTokenResponse,
|
||||
RegisterAlphaQuickRequest,
|
||||
RegisterAlphaQuickResponse,
|
||||
UpdateReadCursorRequest,
|
||||
} from '../types'
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
readonly code?: string
|
||||
readonly status?: number
|
||||
|
||||
constructor(message: string, code?: string, status?: number) {
|
||||
super(message)
|
||||
this.name = 'ApiRequestError'
|
||||
this.code = code
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
function resolveErrorMessage(status: number, code?: string, fallback?: string): string {
|
||||
if (status === 401) {
|
||||
return '연결이 잠시 만료되었습니다. 다시 이어서 들어와 주세요.'
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
return '이 화면은 아직 사용할 수 없습니다. 초대 상태를 확인해 주세요.'
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
return '대화 정보를 다시 불러오는 중입니다. 잠시 후 다시 시도해 주세요.'
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
return '요청이 많습니다. 잠시 후 다시 시도해 주세요.'
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
return '지금은 연결이 고르지 않습니다. 잠시 후 다시 시도해 주세요.'
|
||||
}
|
||||
|
||||
if (code === 'invite_code_invalid') {
|
||||
return '초대코드를 다시 확인해 주세요.'
|
||||
}
|
||||
|
||||
return fallback ?? '요청을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.'
|
||||
}
|
||||
|
||||
function ensureTrailingSlash(value: string): string {
|
||||
if (!value) {
|
||||
return `${window.location.origin}/`
|
||||
}
|
||||
|
||||
return value.endsWith('/') ? value : `${value}/`
|
||||
}
|
||||
|
||||
function buildUrl(apiBaseUrl: string, path: string): string {
|
||||
return new URL(path.replace(/^\//, ''), ensureTrailingSlash(apiBaseUrl)).toString()
|
||||
}
|
||||
|
||||
function parsePayload<T>(text: string): ApiEnvelope<T> | ApiErrorEnvelope | null {
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as ApiEnvelope<T> | ApiErrorEnvelope
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
apiBaseUrl: string,
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
accessToken?: string,
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('Accept', 'application/json')
|
||||
|
||||
if (init.body) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
if (accessToken) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`)
|
||||
}
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(buildUrl(apiBaseUrl, path), {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
} catch {
|
||||
throw new ApiRequestError('네트워크 연결을 확인한 뒤 다시 시도해 주세요.')
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
const payload = parsePayload<T>(text)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = (payload as ApiErrorEnvelope | null)?.error
|
||||
throw new ApiRequestError(
|
||||
resolveErrorMessage(response.status, error?.code, error?.message ?? undefined),
|
||||
error?.code,
|
||||
response.status,
|
||||
)
|
||||
}
|
||||
|
||||
if (!payload || !('data' in payload)) {
|
||||
throw new ApiRequestError('응답을 다시 확인하는 중입니다. 잠시 후 다시 시도해 주세요.')
|
||||
}
|
||||
|
||||
return payload.data
|
||||
}
|
||||
|
||||
export function registerAlphaQuick(
|
||||
apiBaseUrl: string,
|
||||
body: RegisterAlphaQuickRequest,
|
||||
): Promise<RegisterAlphaQuickResponse> {
|
||||
return request<RegisterAlphaQuickResponse>(apiBaseUrl, '/v1/auth/register/alpha-quick', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function refreshToken(
|
||||
apiBaseUrl: string,
|
||||
body: RefreshTokenRequest,
|
||||
): Promise<RefreshTokenResponse> {
|
||||
return request<RefreshTokenResponse>(apiBaseUrl, '/v1/auth/token/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function getBootstrap(apiBaseUrl: string, accessToken: string): Promise<BootstrapResponse> {
|
||||
return request<BootstrapResponse>(apiBaseUrl, '/v1/bootstrap', { method: 'GET' }, accessToken)
|
||||
}
|
||||
|
||||
export function getMessages(
|
||||
apiBaseUrl: string,
|
||||
accessToken: string,
|
||||
conversationId: string,
|
||||
beforeSequence?: number,
|
||||
): Promise<ListEnvelope<MessageItemDto>> {
|
||||
const query = new URLSearchParams()
|
||||
if (beforeSequence) {
|
||||
query.set('beforeSequence', String(beforeSequence))
|
||||
}
|
||||
query.set('limit', '60')
|
||||
|
||||
const suffix = query.toString()
|
||||
return request<ListEnvelope<MessageItemDto>>(
|
||||
apiBaseUrl,
|
||||
`/v1/conversations/${conversationId}/messages${suffix ? `?${suffix}` : ''}`,
|
||||
{ method: 'GET' },
|
||||
accessToken,
|
||||
)
|
||||
}
|
||||
|
||||
export function sendTextMessage(
|
||||
apiBaseUrl: string,
|
||||
accessToken: string,
|
||||
conversationId: string,
|
||||
body: { clientRequestId: string; body: string },
|
||||
): Promise<MessageItemDto> {
|
||||
return request<MessageItemDto>(
|
||||
apiBaseUrl,
|
||||
`/v1/conversations/${conversationId}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
accessToken,
|
||||
)
|
||||
}
|
||||
|
||||
export function updateReadCursor(
|
||||
apiBaseUrl: string,
|
||||
accessToken: string,
|
||||
conversationId: string,
|
||||
body: UpdateReadCursorRequest,
|
||||
): Promise<ReadCursorUpdatedDto> {
|
||||
return request<ReadCursorUpdatedDto>(
|
||||
apiBaseUrl,
|
||||
`/v1/conversations/${conversationId}/read-cursor`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
accessToken,
|
||||
)
|
||||
}
|
||||
48
src/PhysOn.Web/src/lib/realtime.ts
Normal file
48
src/PhysOn.Web/src/lib/realtime.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type {
|
||||
MessageItemDto,
|
||||
ReadCursorUpdatedDto,
|
||||
RealtimeEventEnvelope,
|
||||
SessionConnectedDto,
|
||||
} from '../types'
|
||||
|
||||
export type RealtimeEvent =
|
||||
| { kind: 'session.connected'; payload: SessionConnectedDto }
|
||||
| { kind: 'message.created'; payload: MessageItemDto }
|
||||
| { kind: 'read_cursor.updated'; payload: ReadCursorUpdatedDto }
|
||||
| { kind: 'unknown'; payload: unknown }
|
||||
|
||||
export function buildBrowserWsUrl(rawUrl: string, ticket: string): string {
|
||||
const url = new URL(rawUrl)
|
||||
if (window.location.protocol === 'https:' && url.protocol === 'ws:') {
|
||||
url.protocol = 'wss:'
|
||||
}
|
||||
if (window.location.protocol === 'http:' && url.protocol === 'wss:') {
|
||||
url.protocol = 'ws:'
|
||||
}
|
||||
url.searchParams.set('access_token', ticket)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function parseRealtimeEvent(message: string): RealtimeEvent | null {
|
||||
let envelope: RealtimeEventEnvelope
|
||||
try {
|
||||
envelope = JSON.parse(message) as RealtimeEventEnvelope
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!envelope.event) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (envelope.event) {
|
||||
case 'session.connected':
|
||||
return { kind: envelope.event, payload: envelope.data as SessionConnectedDto }
|
||||
case 'message.created':
|
||||
return { kind: envelope.event, payload: envelope.data as MessageItemDto }
|
||||
case 'read_cursor.updated':
|
||||
return { kind: envelope.event, payload: envelope.data as ReadCursorUpdatedDto }
|
||||
default:
|
||||
return { kind: 'unknown', payload: envelope.data }
|
||||
}
|
||||
}
|
||||
98
src/PhysOn.Web/src/lib/storage.ts
Normal file
98
src/PhysOn.Web/src/lib/storage.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import type { StoredSession } from '../types'
|
||||
|
||||
const SESSION_KEY = 'vs-talk.session'
|
||||
const INSTALL_ID_KEY = 'vs-talk.install-id'
|
||||
const INVITE_CODE_KEY = 'vs-talk.invite-code'
|
||||
const DRAFTS_KEY = 'vs-talk.drafts'
|
||||
|
||||
function fallbackRandomId(): string {
|
||||
return `web-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
|
||||
}
|
||||
|
||||
export function getInstallId(): string {
|
||||
const existing = window.localStorage.getItem(INSTALL_ID_KEY)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const next = typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : fallbackRandomId()
|
||||
window.localStorage.setItem(INSTALL_ID_KEY, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export function readStoredSession(): StoredSession | null {
|
||||
const raw = window.sessionStorage.getItem(SESSION_KEY) ?? window.localStorage.getItem(SESSION_KEY)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as StoredSession
|
||||
if (window.localStorage.getItem(SESSION_KEY)) {
|
||||
window.localStorage.removeItem(SESSION_KEY)
|
||||
window.sessionStorage.setItem(SESSION_KEY, raw)
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
window.sessionStorage.removeItem(SESSION_KEY)
|
||||
window.localStorage.removeItem(SESSION_KEY)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredSession(session: StoredSession): void {
|
||||
window.sessionStorage.setItem(SESSION_KEY, JSON.stringify(session))
|
||||
window.localStorage.removeItem(SESSION_KEY)
|
||||
}
|
||||
|
||||
export function clearStoredSession(): void {
|
||||
window.sessionStorage.removeItem(SESSION_KEY)
|
||||
window.localStorage.removeItem(SESSION_KEY)
|
||||
}
|
||||
|
||||
export function readSavedInviteCode(): string {
|
||||
return window.localStorage.getItem(INVITE_CODE_KEY) ?? ''
|
||||
}
|
||||
|
||||
export function writeSavedInviteCode(value: string): void {
|
||||
window.localStorage.setItem(INVITE_CODE_KEY, value)
|
||||
}
|
||||
|
||||
function readDraftMap(): Record<string, string> {
|
||||
const raw = window.localStorage.getItem(DRAFTS_KEY)
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as Record<string, string>
|
||||
} catch {
|
||||
window.localStorage.removeItem(DRAFTS_KEY)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function readConversationDraft(conversationId: string): string {
|
||||
return readDraftMap()[conversationId] ?? ''
|
||||
}
|
||||
|
||||
export function writeConversationDraft(conversationId: string, value: string): void {
|
||||
const next = readDraftMap()
|
||||
if (value.trim()) {
|
||||
next[conversationId] = value
|
||||
} else {
|
||||
delete next[conversationId]
|
||||
}
|
||||
|
||||
window.localStorage.setItem(DRAFTS_KEY, JSON.stringify(next))
|
||||
}
|
||||
|
||||
export function clearConversationDraft(conversationId: string): void {
|
||||
const next = readDraftMap()
|
||||
delete next[conversationId]
|
||||
window.localStorage.setItem(DRAFTS_KEY, JSON.stringify(next))
|
||||
}
|
||||
|
||||
export function clearConversationDrafts(): void {
|
||||
window.localStorage.removeItem(DRAFTS_KEY)
|
||||
}
|
||||
26
src/PhysOn.Web/src/main.tsx
Normal file
26
src/PhysOn.Web/src/main.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const isLocalPreview = ['127.0.0.1', 'localhost'].includes(window.location.hostname)
|
||||
|
||||
if ('serviceWorker' in navigator && isLocalPreview) {
|
||||
navigator.serviceWorker.getRegistrations().then((registrations) => {
|
||||
registrations.forEach((registration) => {
|
||||
registration.unregister().catch(() => undefined)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if ('serviceWorker' in navigator && !isLocalPreview) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => undefined)
|
||||
})
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
156
src/PhysOn.Web/src/types.ts
Normal file
156
src/PhysOn.Web/src/types.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
export interface ApiEnvelope<T> {
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
code: string
|
||||
message: string
|
||||
retryable?: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ApiErrorEnvelope {
|
||||
error: ApiError
|
||||
}
|
||||
|
||||
export interface ListEnvelope<T> {
|
||||
items: T[]
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
export interface MeDto {
|
||||
userId: string
|
||||
displayName: string
|
||||
profileImageUrl: string | null
|
||||
statusMessage: string | null
|
||||
}
|
||||
|
||||
export interface SessionDto {
|
||||
sessionId: string
|
||||
deviceId: string
|
||||
deviceName: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface TokenPairDto {
|
||||
accessToken: string
|
||||
accessTokenExpiresAt: string
|
||||
refreshToken: string
|
||||
refreshTokenExpiresAt: string
|
||||
}
|
||||
|
||||
export interface BootstrapWsDto {
|
||||
url: string
|
||||
ticket: string
|
||||
ticketExpiresAt: string
|
||||
}
|
||||
|
||||
export interface MessagePreviewDto {
|
||||
messageId: string
|
||||
text: string
|
||||
createdAt: string
|
||||
senderUserId: string
|
||||
}
|
||||
|
||||
export interface ConversationSummaryDto {
|
||||
conversationId: string
|
||||
type: string
|
||||
title: string
|
||||
avatarUrl: string | null
|
||||
subtitle: string
|
||||
memberCount: number
|
||||
isMuted: boolean
|
||||
isPinned: boolean
|
||||
sortKey: string
|
||||
unreadCount: number
|
||||
lastReadSequence: number
|
||||
lastMessage: MessagePreviewDto | null
|
||||
}
|
||||
|
||||
export interface MessageSenderDto {
|
||||
userId: string
|
||||
displayName: string
|
||||
profileImageUrl: string | null
|
||||
}
|
||||
|
||||
export interface MessageItemDto {
|
||||
messageId: string
|
||||
conversationId: string
|
||||
clientMessageId: string
|
||||
kind: string
|
||||
text: string
|
||||
createdAt: string
|
||||
editedAt: string | null
|
||||
sender: MessageSenderDto
|
||||
isMine: boolean
|
||||
serverSequence: number
|
||||
}
|
||||
|
||||
export interface BootstrapResponse {
|
||||
me: MeDto
|
||||
session: SessionDto
|
||||
ws: BootstrapWsDto
|
||||
conversations: ListEnvelope<ConversationSummaryDto>
|
||||
}
|
||||
|
||||
export interface RegisterAlphaQuickResponse {
|
||||
account: MeDto
|
||||
session: SessionDto
|
||||
tokens: TokenPairDto
|
||||
bootstrap: BootstrapResponse
|
||||
}
|
||||
|
||||
export interface RefreshTokenRequest {
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
tokens: TokenPairDto
|
||||
}
|
||||
|
||||
export interface DeviceRegistrationDto {
|
||||
installId: string
|
||||
platform: string
|
||||
deviceName: string
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
export interface RegisterAlphaQuickRequest {
|
||||
displayName: string
|
||||
inviteCode: string
|
||||
device: DeviceRegistrationDto
|
||||
}
|
||||
|
||||
export interface PostTextMessageRequest {
|
||||
clientRequestId: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface UpdateReadCursorRequest {
|
||||
lastReadSequence: number
|
||||
}
|
||||
|
||||
export interface ReadCursorUpdatedDto {
|
||||
conversationId: string
|
||||
accountId: string
|
||||
lastReadSequence: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface StoredSession {
|
||||
apiBaseUrl: string
|
||||
tokens: TokenPairDto
|
||||
bootstrap: BootstrapResponse
|
||||
savedAt: string
|
||||
}
|
||||
|
||||
export interface RealtimeEventEnvelope<T = unknown> {
|
||||
event: string
|
||||
eventId: string
|
||||
occurredAt: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface SessionConnectedDto {
|
||||
sessionId: string
|
||||
}
|
||||
1
src/PhysOn.Web/src/vite-env.d.ts
vendored
Normal file
1
src/PhysOn.Web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
24
src/PhysOn.Web/tsconfig.app.json
Normal file
24
src/PhysOn.Web/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
src/PhysOn.Web/tsconfig.json
Normal file
7
src/PhysOn.Web/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
22
src/PhysOn.Web/tsconfig.node.json
Normal file
22
src/PhysOn.Web/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
30
src/PhysOn.Web/vite.config.ts
Normal file
30
src/PhysOn.Web/vite.config.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '')
|
||||
const proxyTarget = env.VITE_DEV_PROXY_TARGET || 'http://127.0.0.1:5082'
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 4173,
|
||||
proxy: {
|
||||
'/v1': {
|
||||
target: proxyTarget,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
'/health': {
|
||||
target: proxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
host: '0.0.0.0',
|
||||
port: 4173,
|
||||
},
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue