diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 82c541e..3e938f4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -33,6 +33,32 @@ jobs:
- name: Run API integration tests
run: dotnet test tests/PhysOn.Api.IntegrationTests/PhysOn.Api.IntegrationTests.csproj -c Release --no-restore
+ web:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: src/PhysOn.Web/package-lock.json
+
+ - name: Install web dependencies
+ run: npm ci
+ working-directory: src/PhysOn.Web
+
+ - name: Lint web
+ run: npm run lint
+ working-directory: src/PhysOn.Web
+
+ - name: Build web
+ run: npm run build
+ working-directory: src/PhysOn.Web
+
desktop-windows:
runs-on: windows-latest
diff --git a/.github/workflows/release-portable.yml b/.github/workflows/release-portable.yml
index a4c4bc6..d12184b 100644
--- a/.github/workflows/release-portable.yml
+++ b/.github/workflows/release-portable.yml
@@ -43,23 +43,23 @@ jobs:
global-json-file: global.json
- name: Restore desktop project
- run: dotnet restore src/VsMessenger.Desktop/VsMessenger.Desktop.csproj
+ run: dotnet restore src/PhysOn.Desktop/PhysOn.Desktop.csproj
- name: Publish portable desktop build
- run: dotnet publish src/VsMessenger.Desktop/VsMessenger.Desktop.csproj -c Release -r win-x64 --self-contained true -o out/win-x64
+ run: dotnet publish src/PhysOn.Desktop/PhysOn.Desktop.csproj -c Release -r win-x64 --self-contained true -o out/win-x64
- name: Create portable ZIP
shell: pwsh
- run: Compress-Archive -Path out/win-x64/* -DestinationPath out/VsMessenger-win-x64.zip
+ run: Compress-Archive -Path out/win-x64/* -DestinationPath out/KoTalk-windows-x64.zip
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: windows-portable
- path: out/VsMessenger-win-x64.zip
+ path: out/KoTalk-windows-x64.zip
build-android:
- if: ${{ hashFiles('src/VsMessenger.Mobile.Android/*.csproj') != '' }}
+ if: ${{ hashFiles('src/PhysOn.Mobile.Android/*.csproj') != '' }}
runs-on: ubuntu-latest
steps:
@@ -81,11 +81,11 @@ jobs:
run: dotnet workload install android
- name: Restore Android project
- run: dotnet restore src/VsMessenger.Mobile.Android/VsMessenger.Mobile.Android.csproj
+ run: dotnet restore src/PhysOn.Mobile.Android/PhysOn.Mobile.Android.csproj
- name: Publish Android APK
run: |
- dotnet publish src/VsMessenger.Mobile.Android/VsMessenger.Mobile.Android.csproj \
+ dotnet publish src/PhysOn.Mobile.Android/PhysOn.Mobile.Android.csproj \
-c Release \
-f net8.0-android \
-p:AndroidPackageFormat=apk \
@@ -96,13 +96,13 @@ jobs:
run: |
apk_path="$(find out/android -type f -name '*.apk' | head -n 1)"
test -n "$apk_path"
- cp "$apk_path" out/VsMessenger-android-universal.apk
+ cp "$apk_path" out/KoTalk-android-universal.apk
- name: Upload Android artifact
uses: actions/upload-artifact@v4
with:
name: android-apk
- path: out/VsMessenger-android-universal.apk
+ path: out/KoTalk-android-universal.apk
assemble-release:
if: ${{ always() && needs.build-windows.result == 'success' && (needs.build-android.result == 'success' || needs.build-android.result == 'skipped') }}
@@ -148,12 +148,12 @@ jobs:
prepare_args=(
--version "$VERSION_INPUT"
--channel "$channel"
- --windows-zip incoming/windows/VsMessenger-win-x64.zip
+ --windows-zip incoming/windows/KoTalk-windows-x64.zip
--force
)
- if [[ -f incoming/android/VsMessenger-android-universal.apk ]]; then
- prepare_args+=(--android-apk incoming/android/VsMessenger-android-universal.apk)
+ if [[ -f incoming/android/KoTalk-android-universal.apk ]]; then
+ prepare_args+=(--android-apk incoming/android/KoTalk-android-universal.apk)
fi
./scripts/release/release-prepare-assets.sh \
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3d20205..951711f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,13 @@
### Added
+- 공개 원격용 버전 태그 생성 스크립트 `scripts/release/release-create-tag.sh`
+- GitHub 릴리즈 게시 스크립트 `scripts/release/release-publish-github.sh`
+- 제2·제3 공개 원격 순차 게시 스크립트 `scripts/release/release-publish-public.sh`
+
+- `artifacts/builds/2026.04.16-alpha.4/` Windows 포터블 산출물과 체크섬 추가
+- `download-vstalk.phy.kr` HTTPS 미러를 실제 latest ZIP/manifest 경로로 활성화
+
- `KoTalk` 공개 브랜드 기준과 다운로드/릴리즈 표면 정리
- 공개 가입 전략을 `1회성 인증 중심`으로 재정의한 기획 문서 보강
- Apache-2.0 기준의 라이선스/상표/기여 정책 정리
@@ -46,6 +53,15 @@
### Changed
+- 공개 원격 배포 정책을 `public/* 브랜치 + 버전 태그 + 릴리즈 페이지 + 자산` 기준으로 고정
+- Gitea/GitHub 릴리즈 게시 스크립트가 지정 원격 기준으로 동작하고 최신 스크린샷 자산도 함께 첨부하도록 확장
+- 비-`origin` 원격에 대한 로컬 pre-push 가드가 `public/*` 브랜치와 `refs/tags/*`를 함께 허용하도록 조정
+
+- 데스크톱·웹 UI의 설명형 카피를 더 줄이고 말풍선/칩/버튼 라운드를 2px 기준으로 축소
+- 웹 앱 버전을 `0.1.0-alpha.4`로 올리고 최신 캡처 자산을 재생성
+- 다운로드 스크립트, 릴리즈 워크플로, Caddy 예시 설정을 `download-vstalk.phy.kr`와 `KoTalk-*` 자산명 기준으로 정렬
+- 다운로드 미러 상태를 DNS/HTTPS 정상 동작 기준으로 문서에 반영
+
- 공개 브랜드를 `KoTalk`로 정리하고 공개 문서의 직접적·내부지향 표현을 제거
- README를 대중용 첫인상 기준으로 다시 구성하고 다운로드는 공식 미러와 저장소 릴리즈를 함께 표기
- 보안/신뢰 문서에서 운영 힌트와 공유 접근값 노출을 줄이고 공개 범위를 재정의
diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md
index 4894517..320cb42 100644
--- a/PROJECT_STATUS.md
+++ b/PROJECT_STATUS.md
@@ -9,7 +9,7 @@
| Public brand | `KoTalk` |
| Stage | `Alpha` |
| Most usable surface | Mobile web live + Windows build |
-| Biggest current gap | Android 실빌드와 다운로드 미러 정합성 |
+| Biggest current gap | Android 실빌드와 데스크톱 멀티윈도우 완성도 |
| Signup direction | 공개형 1회성 인증 중심으로 재설계 중 |
| Tone of this repo | 현재 동작 범위와 남은 갭을 함께 적는 제품형 저장소 |
@@ -30,7 +30,7 @@ KoTalk는 아직 모든 플랫폼이 완성된 상태는 아니지만, “문서
| Windows desktop | 저장소 빌드 / 릴리즈 산출물 | Buildable | 핵심 메시징 루프 검증 가능 |
| Mobile web | [vstalk.phy.kr](https://vstalk.phy.kr) | Live | 가입, 대화, 검색, 보관 1차 흐름 제공 |
| Android | 저장소 릴리즈 예정 | In progress | 문서와 배포 구조 우선 정리 중 |
-| Official mirror | [download-vstalk.phy.kr](https://download-vstalk.phy.kr) | Normalizing | 2026-04-16 기준 DNS/HTTPS 정합성 확인 필요 |
+| Official mirror | [download-vstalk.phy.kr](https://download-vstalk.phy.kr) | Live | Windows latest와 version manifest를 HTTPS로 제공 |
## Verified Now
@@ -67,7 +67,6 @@ KoTalk는 아직 모든 플랫폼이 완성된 상태는 아니지만, “문서
## In Progress
- Android 첫 실사용 빌드
-- 공개 다운로드 미러 정합성
- 릴리즈 페이지와 미러 간 latest 라우트 통합
- 검색 범위 확장
- 파일 전송
@@ -80,7 +79,7 @@ KoTalk는 아직 모든 플랫폼이 완성된 상태는 아니지만, “문서
- Android 실사용 빌드는 아직 제공되지 않습니다.
- 파일 전송은 미구현입니다.
- 검색은 전역 파일/링크/사람 범위까지 확장되지 않았습니다.
-- 공식 다운로드 미러는 DNS/HTTPS 정상화가 끝나야 안정 채널로 표기할 수 있습니다.
+- 공식 다운로드 미러는 현재 Windows latest와 version manifest 기준으로 동작합니다.
- 데스크톱 멀티 윈도우는 방향은 잡혀 있지만, 실제 생산성 흐름은 더 다듬어야 합니다.
## Download And Release Paths
diff --git a/README.md b/README.md
index 962abde..78cad12 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
-
+
@@ -108,7 +108,7 @@ KoTalk는 이 배경을 리스크 문구로 숨기지 않고, 왜 이 프로젝
| Windows desktop | 저장소 빌드와 버전별 산출물 | Buildable | 핵심 메시징 루프와 데스크톱 레이아웃 실험 진행 중 |
| Mobile web | [vstalk.phy.kr](https://vstalk.phy.kr) | Live | 가입, 대화, 검색, 보관 1차 흐름 검증 |
| Android | 저장소 릴리즈 예정 | In progress | 문서와 배포 구조 우선 정리 중 |
-| Official download mirror | [download-vstalk.phy.kr](https://download-vstalk.phy.kr) | Normalizing | 2026-04-16 기준 DNS/HTTPS 정합성 점검 진행 중 |
+| Official download mirror | [download-vstalk.phy.kr](https://download-vstalk.phy.kr) | Live | Windows latest와 version manifest를 HTTPS로 제공 |
## Architecture Snapshot
diff --git a/RELEASING.md b/RELEASING.md
index 99e457e..b7a84ce 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -14,8 +14,8 @@ KoTalk의 릴리즈는 단순한 파일 업로드가 아니라, 산출물과 공
## Current Note
-2026-04-16 기준 [download-vstalk.phy.kr](https://download-vstalk.phy.kr)의 DNS/HTTPS 정합성은 재점검이 필요합니다.
-그래서 현재는 저장소 릴리즈 경로를 함께 유지하는 것을 원칙으로 둡니다.
+2026-04-16 기준 [download-vstalk.phy.kr](https://download-vstalk.phy.kr)는 DNS와 HTTPS가 정상입니다.
+현재는 Windows latest와 version manifest를 제공하고, 저장소 릴리즈 경로를 함께 유지합니다.
## Minimum Release Contract
@@ -24,6 +24,8 @@ KoTalk의 릴리즈는 단순한 파일 업로드가 아니라, 산출물과 공
3. [CHANGELOG.md](CHANGELOG.md)에 의미 있는 변경이 기록돼야 합니다.
4. 최신 스크린샷이 현재 UI를 대표해야 합니다.
5. 다운로드 경로와 릴리즈 링크가 함께 갱신돼야 합니다.
+6. 공개 원격은 `브랜치 + 태그 + 릴리즈 페이지 + 자산`을 한 세트로 맞춥니다.
+7. 공개 릴리즈 페이지에는 산출물과 최신 스크린샷을 함께 게시합니다.
## Platform Policy
@@ -31,6 +33,23 @@ KoTalk의 릴리즈는 단순한 파일 업로드가 아니라, 산출물과 공
- Mobile web: 라이브 반영이 있으면 스크린샷과 상태 문서를 함께 갱신합니다.
- Android: APK 공개 시 공식 미러와 저장소 릴리즈를 함께 맞춥니다.
+## Public Release Sequence
+
+1. 내부 기준선에서 산출물과 스크린샷을 먼저 고정합니다.
+2. `public/*` 브랜치에 공개 가능한 이력을 정리합니다.
+3. 같은 기준선에 버전 태그를 생성합니다.
+4. 제2 공개 레포에 브랜치와 태그를 푸시합니다.
+5. 제2 공개 레포 릴리즈 페이지에 자산과 노트를 게시합니다.
+6. 명시적 요청이 있을 때만 같은 태그와 자산을 제3 공개 레포에 게시합니다.
+7. `download-vstalk.phy.kr`는 최신 포인터만 유지합니다.
+
+## Release Scripts
+
+- 공개 기준 태그 생성: [`scripts/create-release-tag.sh`](scripts/create-release-tag.sh)
+- 제2 공개 레포 릴리즈 게시: [`scripts/publish-gitea-release.sh`](scripts/publish-gitea-release.sh)
+- 제3 GitHub 릴리즈 게시: [`scripts/publish-github-release.sh`](scripts/publish-github-release.sh)
+- 공개 브랜치/태그/릴리즈 순차 게시: [`scripts/publish-public-release.sh`](scripts/publish-public-release.sh)
+
## Related Docs
- 배포 골격: [deploy/README.md](deploy/README.md)
diff --git a/deploy/Caddyfile b/deploy/Caddyfile
index 55d4b92..5009816 100644
--- a/deploy/Caddyfile
+++ b/deploy/Caddyfile
@@ -7,14 +7,18 @@
root * /srv/download
redir /windows /windows/latest 302
redir /android /android/latest 302
- redir /windows/latest /windows/latest/VsMessenger-win-x64.zip 302
- redir /android/latest /android/latest/VsMessenger-android-universal.apk 302
+ redir /windows/latest /latest/KoTalk-windows-x64.zip 302
+ redir /android/latest /latest/KoTalk-android-universal.apk 302
redir /latest /latest/version.json 302
header {
+ Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"
+ Cross-Origin-Resource-Policy "same-origin"
+ Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
+ X-Robots-Tag "noindex, nofollow"
}
file_server
}
@@ -22,6 +26,10 @@
{$WEBAPP_HOST:vstalk.phy.kr} {
encode zstd gzip
header {
+ Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' https: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
+ Cross-Origin-Opener-Policy "same-origin"
+ Cross-Origin-Resource-Policy "same-origin"
+ Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
@@ -41,6 +49,9 @@
{$API_HOST} {
encode zstd gzip
header {
+ Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"
+ Cross-Origin-Resource-Policy "same-origin"
+ Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
diff --git a/docs/assets/latest/conversation.png b/docs/assets/latest/conversation.png
index d163f5e..8cf9d3c 100644
Binary files a/docs/assets/latest/conversation.png and b/docs/assets/latest/conversation.png differ
diff --git a/docs/assets/latest/hero-shell.png b/docs/assets/latest/hero-shell.png
index b77cdea..f0fccd9 100644
Binary files a/docs/assets/latest/hero-shell.png and b/docs/assets/latest/hero-shell.png differ
diff --git a/docs/assets/latest/onboarding.png b/docs/assets/latest/onboarding.png
index 8d8323a..d033cd1 100644
Binary files a/docs/assets/latest/onboarding.png and b/docs/assets/latest/onboarding.png differ
diff --git a/docs/assets/latest/vstalk-web-chat.png b/docs/assets/latest/vstalk-web-chat.png
index 22da3cd..eb05981 100644
Binary files a/docs/assets/latest/vstalk-web-chat.png and b/docs/assets/latest/vstalk-web-chat.png differ
diff --git a/docs/assets/latest/vstalk-web-list.png b/docs/assets/latest/vstalk-web-list.png
index c7e4d37..ed91abc 100644
Binary files a/docs/assets/latest/vstalk-web-list.png and b/docs/assets/latest/vstalk-web-list.png differ
diff --git a/docs/assets/latest/vstalk-web-onboarding.png b/docs/assets/latest/vstalk-web-onboarding.png
index 09f306c..a6c59f3 100644
Binary files a/docs/assets/latest/vstalk-web-onboarding.png and b/docs/assets/latest/vstalk-web-onboarding.png differ
diff --git a/docs/assets/latest/vstalk-web-saved.png b/docs/assets/latest/vstalk-web-saved.png
index 1ddba0a..2bbb236 100644
Binary files a/docs/assets/latest/vstalk-web-saved.png and b/docs/assets/latest/vstalk-web-saved.png differ
diff --git a/docs/assets/latest/vstalk-web-search.png b/docs/assets/latest/vstalk-web-search.png
index 2569e92..11fb563 100644
Binary files a/docs/assets/latest/vstalk-web-search.png and b/docs/assets/latest/vstalk-web-search.png differ
diff --git a/release-assets/README.md b/release-assets/README.md
index 230aad3..4e17265 100644
--- a/release-assets/README.md
+++ b/release-assets/README.md
@@ -6,7 +6,7 @@
- 같은 버전 번호 아래에 Windows와 Android 산출물을 병렬로 보관합니다.
- `latest/`는 최신 포인터, `releases//`는 불변 이력으로 구분합니다.
-- 원격 Forge Releases는 버전별 원본 저장소, `download-vs-messanger.phy.kr`는 최종 사용자용 다운로드 미러로 사용합니다.
+- 원격 Forge Releases는 버전별 원본 저장소, `download-vstalk.phy.kr`는 최종 사용자용 다운로드 미러로 사용합니다.
## 목표 구조
@@ -19,11 +19,11 @@ release-assets/
SHA256SUMS.txt
screenshots/
windows/
- VsMessenger-win-x64.zip
+ KoTalk-windows-x64.zip
SHA256SUMS.txt
version.json
android/
- VsMessenger-android-universal.apk
+ KoTalk-android-universal.apk
SHA256SUMS.txt
version.json
releases/
@@ -34,11 +34,11 @@ release-assets/
screenshots/
windows/
x64/
- VsMessenger-win-x64-v0.2.0-alpha.1.zip
+ KoTalk-windows-x64-v0.2.0-alpha.1.zip
SHA256SUMS.txt
android/
universal/
- VsMessenger-android-universal-v0.2.0-alpha.1.apk
+ KoTalk-android-universal-v0.2.0-alpha.1.apk
SHA256SUMS.txt
```
@@ -52,11 +52,11 @@ release-assets/
## 다운로드 경로 규칙
-- 최신 Windows: `https://download-vs-messanger.phy.kr/windows/latest`
-- 최신 Android: `https://download-vs-messanger.phy.kr/android/latest`
-- 전체 최신 메타데이터: `https://download-vs-messanger.phy.kr/latest/version.json`
-- 버전별 Windows: `https://download-vs-messanger.phy.kr/releases//windows/x64/...`
-- 버전별 Android: `https://download-vs-messanger.phy.kr/releases//android/universal/...`
+- 최신 Windows: `https://download-vstalk.phy.kr/windows/latest`
+- 최신 Android: `https://download-vstalk.phy.kr/android/latest`
+- 전체 최신 메타데이터: `https://download-vstalk.phy.kr/latest/version.json`
+- 버전별 Windows: `https://download-vstalk.phy.kr/releases//windows/x64/...`
+- 버전별 Android: `https://download-vstalk.phy.kr/releases//android/universal/...`
## 생성 스크립트
@@ -68,8 +68,8 @@ release-assets/
./scripts/release/release-prepare-assets.sh \
--version v0.2.0-alpha.1 \
--channel alpha \
- --windows-zip artifacts/release/VsMessenger-win-x64-v0.2.0-alpha.1.zip \
- --android-apk artifacts/release/VsMessenger-android-universal-v0.2.0-alpha.1.apk \
+ --windows-zip artifacts/release/KoTalk-windows-x64-v0.2.0-alpha.1.zip \
+ --android-apk artifacts/release/KoTalk-android-universal-v0.2.0-alpha.1.apk \
--screenshots artifacts/screenshots \
--force
```
@@ -78,16 +78,20 @@ release-assets/
- VPS 다운로드 미러 업로드: `scripts/release/release-upload-assets.sh`
- Forge Releases 게시: `scripts/release/release-publish-forge.sh`
+- GitHub Releases 게시: `scripts/release/release-publish-github.sh`
+- 공개 원격 전체 게시: `scripts/release/release-publish-public.sh`
+- 공개 태그 생성: `scripts/release/release-create-tag.sh`
두 채널은 목적이 다릅니다.
- Forge Releases: 버전별 원본 보관
- 다운로드 미러: 최신 포인터와 빠른 정적 다운로드
- 모바일 웹앱: `release-assets/`가 아니라 `vstalk.phy.kr` 배포 트랙에서 별도 운영
+- 공개 원격 릴리즈 페이지에는 ZIP/APK뿐 아니라 `screenshots/` 아래 최신 캡처도 함께 게시합니다.
## 운영 메모
-- 생성된 버전별 산출물은 기본적으로 Git 추적 대상이 아닙니다.
+- 생성된 버전별 산출물은 워크트리에 유지하며, 최신 로컬 검수와 서버 업로드 기준으로 사용합니다.
- 공개 릴리즈마다 `RELEASE_NOTES.ko.md`, `SHA256SUMS.txt`, `version.json`을 함께 갱신합니다.
- 같은 버전에서 Windows만 있고 Android가 아직 없을 수는 있지만, 장기 원칙은 `같은 버전 아래 두 플랫폼 병렬 게시`입니다.
- 모바일 웹앱 정적 산출물은 `release-assets/`가 아니라 `/srv/vs-messanger/webapp/releases/`에 배포합니다.
diff --git a/scripts/capture_kotalk_desktop_screenshots.sh b/scripts/capture_kotalk_desktop_screenshots.sh
new file mode 100755
index 0000000..929bb5f
--- /dev/null
+++ b/scripts/capture_kotalk_desktop_screenshots.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+"$SCRIPT_DIR/ci/capture-kotalk-desktop-screenshots.sh" "$@"
diff --git a/scripts/ci/capture-kotalk-desktop-screenshots.sh b/scripts/ci/capture-kotalk-desktop-screenshots.sh
new file mode 100755
index 0000000..6adb920
--- /dev/null
+++ b/scripts/ci/capture-kotalk-desktop-screenshots.sh
@@ -0,0 +1,206 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+OUTPUT_DIR="${1:-$ROOT_DIR/docs/assets/latest}"
+CAPTURE_MODE="${2:-all}"
+PROJECT_PATH="$ROOT_DIR/src/PhysOn.Desktop/PhysOn.Desktop.csproj"
+DOTNET_BIN="${DOTNET_BIN:-$HOME/.dotnet/dotnet}"
+
+if [[ ! -x "$DOTNET_BIN" ]]; then
+ echo "dotnet not found at $DOTNET_BIN" >&2
+ exit 1
+fi
+
+mkdir -p "$OUTPUT_DIR"
+TMP_DIR="$(mktemp -d)"
+trap 'rm -rf "$TMP_DIR"' EXIT
+
+capture_mode() {
+ local mode="$1"
+ local output_path="$2"
+ local main_title="$3"
+ local detached_title="${4:-}"
+ local data_home="$TMP_DIR/$mode-data"
+ local config_home="$TMP_DIR/$mode-config"
+ local cache_home="$TMP_DIR/$mode-cache"
+ local runtime_home="$TMP_DIR/$mode-runtime"
+ local tree_path="$TMP_DIR/$mode-tree.txt"
+ local log_path="$TMP_DIR/$mode.log"
+ mkdir -p "$data_home" "$config_home" "$cache_home" "$runtime_home"
+
+ env \
+ XDG_DATA_HOME="$data_home" \
+ XDG_CONFIG_HOME="$config_home" \
+ XDG_CACHE_HOME="$cache_home" \
+ XDG_RUNTIME_DIR="$runtime_home" \
+ DOTNET_BIN="$DOTNET_BIN" \
+ PROJECT_PATH="$PROJECT_PATH" \
+ TREE_PATH="$tree_path" \
+ LOG_PATH="$log_path" \
+ OUTPUT_PATH="$output_path" \
+ MAIN_TITLE="$main_title" \
+ DETACHED_TITLE="$detached_title" \
+ MODE="$mode" \
+ xvfb-run -a bash -lc '
+ set -euo pipefail
+
+ refresh_tree() {
+ xwininfo -root -tree >"$TREE_PATH" 2>/dev/null || true
+ }
+
+ find_window_id() {
+ local title="$1"
+ python3 - "$TREE_PATH" "$title" <<'"'"'PY'"'"'
+import re
+import sys
+
+tree_path, title = sys.argv[1], sys.argv[2]
+pattern = re.compile(r"^\s*(0x[0-9a-fA-F]+)\s+\"([^\"]+)\"")
+
+with open(tree_path, "r", encoding="utf-8", errors="ignore") as handle:
+ for line in handle:
+ match = pattern.match(line)
+ if not match:
+ continue
+ window_id, window_title = match.groups()
+ if window_title == title:
+ print(window_id)
+ raise SystemExit(0)
+
+raise SystemExit(1)
+PY
+ }
+
+ wait_for_window() {
+ local title="$1"
+ local attempts="${2:-40}"
+ local pause="${3:-0.5}"
+ local window_id=""
+
+ for _ in $(seq 1 "$attempts"); do
+ refresh_tree
+ if window_id="$(find_window_id "$title" 2>/dev/null)"; then
+ echo "$window_id"
+ return 0
+ fi
+ sleep "$pause"
+ done
+
+ return 1
+ }
+
+ window_geometry() {
+ local window_id="$1"
+ python3 - "$window_id" <<'"'"'PY'"'"'
+import re
+import subprocess
+import sys
+
+window_id = sys.argv[1]
+output = subprocess.check_output(["xwininfo", "-id", window_id], text=True, errors="ignore")
+patterns = {
+ "x": r"Absolute upper-left X:\s+(-?\d+)",
+ "y": r"Absolute upper-left Y:\s+(-?\d+)",
+ "w": r"Width:\s+(\d+)",
+ "h": r"Height:\s+(\d+)",
+}
+
+values = {}
+for key, pattern in patterns.items():
+ match = re.search(pattern, output)
+ if not match:
+ raise SystemExit(1)
+ values[key] = int(match.group(1))
+
+if values["w"] < 240 or values["h"] < 240:
+ raise SystemExit(2)
+
+print(values["x"], values["y"], values["w"], values["h"])
+PY
+ }
+
+ wait_for_geometry() {
+ local window_id="$1"
+ local attempts="${2:-30}"
+ local pause="${3:-0.4}"
+ local geometry=""
+ local previous=""
+
+ for _ in $(seq 1 "$attempts"); do
+ if geometry="$(window_geometry "$window_id" 2>/dev/null)"; then
+ if [[ "$geometry" == "$previous" ]]; then
+ echo "$geometry"
+ return 0
+ fi
+ previous="$geometry"
+ fi
+ sleep "$pause"
+ done
+
+ if [[ -n "$previous" ]]; then
+ echo "$previous"
+ return 0
+ fi
+
+ return 1
+ }
+
+ capture_window() {
+ local window_id="$1"
+ local target_path="$2"
+ local root_capture="${target_path%.*}-root.${target_path##*.}"
+ local geometry
+ read -r crop_x crop_y crop_w crop_h < <(wait_for_geometry "$window_id")
+ import -window root "$root_capture"
+ convert "$root_capture" -crop "${crop_w}x${crop_h}+${crop_x}+${crop_y}" +repage "$target_path"
+ rm -f "$root_capture"
+ }
+
+ create_conversation_fallback() {
+ local source_path="$1"
+ local target_path="$2"
+ convert "$source_path" -gravity east -crop 58%x84%+0+0 +repage "$target_path"
+ }
+
+ if [[ "$MODE" == "sample" ]]; then
+ export KOTALK_DESKTOP_SAMPLE_MODE=1
+ export KOTALK_DESKTOP_OPEN_SAMPLE_WINDOW=1
+ fi
+ export XDG_DATA_HOME="$XDG_DATA_HOME"
+ export XDG_CONFIG_HOME="$XDG_CONFIG_HOME"
+ export XDG_CACHE_HOME="$XDG_CACHE_HOME"
+ export XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR"
+
+ "$DOTNET_BIN" run --project "$PROJECT_PATH" -c Debug >"$LOG_PATH" 2>&1 &
+ app_pid=$!
+
+ cleanup() {
+ kill "$app_pid" >/dev/null 2>&1 || true
+ wait "$app_pid" >/dev/null 2>&1 || true
+ }
+ trap cleanup EXIT
+
+ main_id="$(wait_for_window "$MAIN_TITLE" 60 0.5)"
+ capture_window "$main_id" "$OUTPUT_PATH"
+
+ if [[ -n "$DETACHED_TITLE" ]]; then
+ conversation_output="${OUTPUT_PATH%/*}/conversation.${OUTPUT_PATH##*.}"
+ if detached_id="$(wait_for_window "$DETACHED_TITLE" 12 0.5 2>/dev/null)"; then
+ capture_window "$detached_id" "$conversation_output"
+ else
+ create_conversation_fallback "$OUTPUT_PATH" "$conversation_output"
+ fi
+ fi
+ '
+}
+
+if [[ "$CAPTURE_MODE" == "all" || "$CAPTURE_MODE" == "onboarding" ]]; then
+ capture_mode "onboarding" "$OUTPUT_DIR/onboarding.png" "KoTalk"
+fi
+
+if [[ "$CAPTURE_MODE" == "all" || "$CAPTURE_MODE" == "sample" ]]; then
+ capture_mode "sample" "$OUTPUT_DIR/hero-shell.png" "KoTalk" "제품 운영"
+fi
+
+echo "Desktop screenshots written to $OUTPUT_DIR"
diff --git a/scripts/create-release-tag.sh b/scripts/create-release-tag.sh
new file mode 100755
index 0000000..b473848
--- /dev/null
+++ b/scripts/create-release-tag.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+exec "$script_dir/release/release-create-tag.sh" "$@"
diff --git a/scripts/publish-github-release.sh b/scripts/publish-github-release.sh
new file mode 100755
index 0000000..5c42cf2
--- /dev/null
+++ b/scripts/publish-github-release.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+exec "$script_dir/release/release-publish-github.sh" "$@"
diff --git a/scripts/publish-public-release.sh b/scripts/publish-public-release.sh
new file mode 100755
index 0000000..e946a56
--- /dev/null
+++ b/scripts/publish-public-release.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+exec "$script_dir/release/release-publish-public.sh" "$@"
diff --git a/scripts/release/release-create-tag.sh b/scripts/release/release-create-tag.sh
new file mode 100755
index 0000000..3115f9b
--- /dev/null
+++ b/scripts/release/release-create-tag.sh
@@ -0,0 +1,102 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage:
+ ./scripts/release/release-create-tag.sh --version 2026.04.16-alpha.4 [options]
+
+Options:
+ --ref Target ref or commit. Default: public/main
+ --message Annotated tag message
+ --remote Push tag to a remote after creation
+ --force Replace an existing local tag
+ --dry-run Print what would happen
+EOF
+}
+
+version=""
+target_ref="public/main"
+message=""
+remote_name=""
+force="false"
+dry_run="false"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --version)
+ version="${2:-}"
+ shift 2
+ ;;
+ --ref)
+ target_ref="${2:-}"
+ shift 2
+ ;;
+ --message)
+ message="${2:-}"
+ shift 2
+ ;;
+ --remote)
+ remote_name="${2:-}"
+ shift 2
+ ;;
+ --force)
+ force="true"
+ shift
+ ;;
+ --dry-run)
+ dry_run="true"
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+if [[ -z "$version" ]]; then
+ usage >&2
+ exit 1
+fi
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$repo_root"
+
+target_commit="$(git rev-parse "$target_ref")"
+tag_message="${message:-Release $version}"
+
+echo "Tag: $version"
+echo "Target ref: $target_ref"
+echo "Target commit: $target_commit"
+[[ -n "$remote_name" ]] && echo "Remote: $remote_name"
+
+if [[ "$dry_run" == "true" ]]; then
+ exit 0
+fi
+
+if git rev-parse -q --verify "refs/tags/$version" >/dev/null; then
+ if [[ "$force" != "true" ]]; then
+ echo "Tag already exists: $version" >&2
+ echo "Use --force to replace it." >&2
+ exit 1
+ fi
+ git tag -d "$version" >/dev/null
+fi
+
+git tag -a "$version" "$target_commit" -m "$tag_message"
+
+if [[ -n "$remote_name" ]]; then
+ push_args=()
+ if [[ "$force" == "true" ]]; then
+ push_args+=(--force)
+ fi
+ git push "${push_args[@]}" "$remote_name" "refs/tags/$version:refs/tags/$version"
+fi
+
+echo "Created annotated tag $version at $target_commit"
diff --git a/scripts/release/release-prepare-assets.sh b/scripts/release/release-prepare-assets.sh
index 4a96b92..90efd56 100755
--- a/scripts/release/release-prepare-assets.sh
+++ b/scripts/release/release-prepare-assets.sh
@@ -16,7 +16,7 @@ Options:
--force Overwrite an existing release folder
Environment:
- DOWNLOAD_BASE_URL Defaults to https://download-vs-messanger.phy.kr
+ DOWNLOAD_BASE_URL Defaults to https://download-vstalk.phy.kr
EOF
}
@@ -95,7 +95,7 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
release_root="$repo_root/release-assets/releases/$version"
latest_root="$repo_root/release-assets/latest"
template_path="$repo_root/release-assets/templates/RELEASE_NOTES.ko.md"
-download_base_url="${DOWNLOAD_BASE_URL:-https://download-vs-messanger.phy.kr}"
+download_base_url="${DOWNLOAD_BASE_URL:-https://download-vstalk.phy.kr}"
published_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
derive_release_url() {
@@ -179,8 +179,8 @@ EOF
}
if [[ -n "$windows_zip" ]]; then
- windows_release_name="VsMessenger-win-x64-$version.zip"
- windows_latest_name="VsMessenger-win-x64.zip"
+ windows_release_name="KoTalk-windows-x64-$version.zip"
+ windows_latest_name="KoTalk-windows-x64.zip"
windows_release_dir="$release_root/windows/x64"
windows_latest_dir="$latest_root/windows"
mkdir -p "$windows_release_dir" "$windows_latest_dir"
@@ -229,8 +229,8 @@ EOF
fi
if [[ -n "$android_apk" ]]; then
- android_release_name="VsMessenger-android-universal-$version.apk"
- android_latest_name="VsMessenger-android-universal.apk"
+ android_release_name="KoTalk-android-universal-$version.apk"
+ android_latest_name="KoTalk-android-universal.apk"
android_release_dir="$release_root/android/universal"
android_latest_dir="$latest_root/android"
mkdir -p "$android_release_dir" "$android_latest_dir"
diff --git a/scripts/release/release-publish-forge.sh b/scripts/release/release-publish-forge.sh
index 147f230..4dc928d 100755
--- a/scripts/release/release-publish-forge.sh
+++ b/scripts/release/release-publish-forge.sh
@@ -7,17 +7,23 @@ Usage:
./scripts/release/release-publish-forge.sh --version v0.2.0-alpha.1 [options]
Options:
+ --remote Git remote name. Default: public-stage
--base-url Forge base URL. Example: https://forge.example.com
--repo Repository in owner/name form
--token Gitea API token
+ --target-commitish [ Branch or commit to associate when creating the tag
+ --notes ] Release notes markdown file
--dry-run Print planned uploads without calling the API
EOF
}
version=""
+remote_name="public-stage"
base_url=""
repo_full_name=""
token="${FORGE_RELEASE_TOKEN:-${GITEA_RELEASE_TOKEN:-}}"
+target_commitish=""
+notes_path=""
dry_run="false"
while [[ $# -gt 0 ]]; do
@@ -30,6 +36,10 @@ while [[ $# -gt 0 ]]; do
base_url="${2:-}"
shift 2
;;
+ --remote)
+ remote_name="${2:-}"
+ shift 2
+ ;;
--repo)
repo_full_name="${2:-}"
shift 2
@@ -38,6 +48,14 @@ while [[ $# -gt 0 ]]; do
token="${2:-}"
shift 2
;;
+ --target-commitish)
+ target_commitish="${2:-}"
+ shift 2
+ ;;
+ --notes)
+ notes_path="${2:-}"
+ shift 2
+ ;;
--dry-run)
dry_run="true"
shift
@@ -67,20 +85,24 @@ if [[ ! -d "$release_root" ]]; then
exit 1
fi
-origin_url="$(git -C "$repo_root" remote get-url origin 2>/dev/null || true)"
+remote_url="$(git -C "$repo_root" remote get-url "$remote_name" 2>/dev/null || true)"
if [[ -z "$base_url" ]]; then
- if [[ "$origin_url" =~ ^(https?://[^/]+)/(.+)\.git$ ]]; then
+ if [[ "$remote_url" =~ ^(https?://[^/]+)(/open-source/projects)/([^/]+)/([^/]+?)(\.git)?$ ]]; then
+ base_url="${BASH_REMATCH[1]}${BASH_REMATCH[2]}"
+ elif [[ "$remote_url" =~ ^(https?://[^/]+)/(.+?)(\.git)?$ ]]; then
base_url="${BASH_REMATCH[1]}"
- elif [[ "$origin_url" =~ ^git@([^:]+):(.+)\.git$ ]]; then
+ elif [[ "$remote_url" =~ ^git@([^:]+):(.+)\.git$ ]]; then
base_url="https://${BASH_REMATCH[1]}"
fi
fi
if [[ -z "$repo_full_name" ]]; then
- if [[ "$origin_url" =~ ^https?://[^/]+/(.+)\.git$ ]]; then
+ if [[ "$remote_url" =~ ^https?://[^/]+/open-source/projects/([^/]+)/([^/]+?)(\.git)?$ ]]; then
+ repo_full_name="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
+ elif [[ "$remote_url" =~ ^https?://[^/]+/(.+?)(\.git)?$ ]]; then
repo_full_name="${BASH_REMATCH[1]}"
- elif [[ "$origin_url" =~ ^git@[^:]+:(.+)\.git$ ]]; then
+ elif [[ "$remote_url" =~ ^git@[^:]+:(.+)\.git$ ]]; then
repo_full_name="${BASH_REMATCH[1]}"
fi
fi
@@ -90,8 +112,47 @@ if [[ -z "$base_url" || -z "$repo_full_name" ]]; then
exit 1
fi
-if [[ "$dry_run" != "true" && -z "$token" ]]; then
- echo "A Gitea API token is required. Use --token or FORGE_RELEASE_TOKEN." >&2
+if [[ -z "$notes_path" ]]; then
+ notes_path="$release_root/RELEASE_NOTES.ko.md"
+fi
+
+if [[ -n "$notes_path" && ! -f "$notes_path" ]]; then
+ echo "Release notes file not found: $notes_path" >&2
+ exit 1
+fi
+
+if [[ -z "$target_commitish" ]]; then
+ target_commitish="main"
+fi
+
+basic_auth_user=""
+basic_auth_password=""
+
+if [[ -z "$token" ]]; then
+ for fallback_path in \
+ "$repo_root/.workspace-secrets/${remote_name}.token" \
+ "$repo_root/.workspace-secrets/forge-release.token"; do
+ if [[ -f "$fallback_path" ]]; then
+ token="$(tr -d '\r\n' < "$fallback_path")"
+ break
+ fi
+ done
+fi
+
+if [[ -z "$token" && -n "$remote_url" ]]; then
+ credential_output="$(printf 'url=%s\n\n' "$remote_url" | git credential fill 2>/dev/null || true)"
+ if [[ -n "$credential_output" ]]; then
+ while IFS='=' read -r key value; do
+ case "$key" in
+ username) basic_auth_user="$value" ;;
+ password) basic_auth_password="$value" ;;
+ esac
+ done <<< "$credential_output"
+ fi
+fi
+
+if [[ "$dry_run" != "true" && -z "$token" && ( -z "$basic_auth_user" || -z "$basic_auth_password" ) ]]; then
+ echo "A Gitea API token or basic credential is required. Use --token, FORGE_RELEASE_TOKEN, a local secret file, or configured git credentials." >&2
exit 1
fi
@@ -108,7 +169,7 @@ esac
mapfile -t asset_files < <(
find "$release_root" -type f \
- \( -name '*.zip' -o -name '*.apk' -o -name 'RELEASE_NOTES.ko.md' -o -name 'SHA256SUMS.txt' -o -name 'version.json' \) \
+ \( -name '*.zip' -o -name '*.apk' -o -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' -o -name 'RELEASE_NOTES.ko.md' -o -name 'SHA256SUMS.txt' -o -name 'version.json' \) \
| sort
)
@@ -118,7 +179,9 @@ if [[ ${#asset_files[@]} -eq 0 ]]; then
fi
echo "Forge release target: $base_url/$repo_full_name"
+echo "Remote: $remote_name"
echo "Version: $version"
+echo "Target commitish: $target_commitish"
printf 'Assets:\n'
printf ' - %s\n' "${asset_files[@]#$release_root/}"
@@ -126,11 +189,16 @@ if [[ "$dry_run" == "true" ]]; then
exit 0
fi
-auth_header="Authorization: token $token"
+auth_args=()
+if [[ -n "$token" ]]; then
+ auth_args=(-H "Authorization: token $token")
+else
+ auth_args=(-u "$basic_auth_user:$basic_auth_password")
+fi
tmp_response="$(mktemp)"
trap 'rm -f "$tmp_response"' EXIT
-existing_status="$(curl -sS -o "$tmp_response" -w '%{http_code}' -H "$auth_header" "$tag_api")"
+existing_status="$(curl -sS -o "$tmp_response" -w '%{http_code}' "${auth_args[@]}" "$tag_api")"
if [[ "$existing_status" == "200" ]]; then
existing_release_id="$(python3 - <<'PY' "$tmp_response"
import json, sys
@@ -138,17 +206,17 @@ with open(sys.argv[1], "r", encoding="utf-8") as fh:
print(json.load(fh)["id"])
PY
)"
- curl -sS -X DELETE -H "$auth_header" "${release_api}/${existing_release_id}" >/dev/null
+ curl -sS -X DELETE "${auth_args[@]}" "${release_api}/${existing_release_id}" >/dev/null
fi
-release_body=$'Windows와 Android 클라이언트 산출물을 병렬로 정리한 릴리즈입니다.\n\n'
-release_body+=$'동일 버전 번호 아래 OS별 자산을 함께 게시하며, 최신 다운로드 채널은 download-vs-messanger.phy.kr에서 운영합니다.'
+release_body="$(cat "$notes_path")"
-create_payload="$(python3 - <<'PY' "$version" "$release_body" "$pre_release"
+create_payload="$(python3 - <<'PY' "$version" "$release_body" "$pre_release" "$target_commitish"
import json, sys
-version, body, prerelease = sys.argv[1], sys.argv[2], sys.argv[3] == "true"
+version, body, prerelease, target_commitish = sys.argv[1], sys.argv[2], sys.argv[3] == "true", sys.argv[4]
print(json.dumps({
"tag_name": version,
+ "target_commitish": target_commitish,
"name": version,
"body": body,
"draft": False,
@@ -159,7 +227,7 @@ PY
create_status="$(curl -sS -o "$tmp_response" -w '%{http_code}' \
-X POST \
- -H "$auth_header" \
+ "${auth_args[@]}" \
-H 'Content-Type: application/json' \
-d "$create_payload" \
"$release_api")"
@@ -181,7 +249,7 @@ for asset in "${asset_files[@]}"; do
name="$(basename "$asset")"
curl -sS \
-X POST \
- -H "$auth_header" \
+ "${auth_args[@]}" \
-H 'Content-Type: application/octet-stream' \
--data-binary @"$asset" \
"${release_api}/${release_id}/assets?name=${name}" >/dev/null
diff --git a/scripts/release/release-publish-github.sh b/scripts/release/release-publish-github.sh
new file mode 100755
index 0000000..f8660ed
--- /dev/null
+++ b/scripts/release/release-publish-github.sh
@@ -0,0 +1,226 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage:
+ ./scripts/release/release-publish-github.sh --version 2026.04.16-alpha.4 [options]
+
+Options:
+ --remote Git remote name. Default: github-public
+ --repo GitHub repository in owner/name form
+ --token GitHub token
+ --target-commitish [ Branch or commit to associate when creating the tag
+ --notes ] Release notes markdown file
+ --dry-run Print planned uploads without calling the API
+EOF
+}
+
+version=""
+remote_name="github-public"
+repo_full_name=""
+token="${GITHUB_RELEASE_TOKEN:-${GITHUB_TOKEN:-}}"
+target_commitish=""
+notes_path=""
+dry_run="false"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --version)
+ version="${2:-}"
+ shift 2
+ ;;
+ --remote)
+ remote_name="${2:-}"
+ shift 2
+ ;;
+ --repo)
+ repo_full_name="${2:-}"
+ shift 2
+ ;;
+ --token)
+ token="${2:-}"
+ shift 2
+ ;;
+ --target-commitish)
+ target_commitish="${2:-}"
+ shift 2
+ ;;
+ --notes)
+ notes_path="${2:-}"
+ shift 2
+ ;;
+ --dry-run)
+ dry_run="true"
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+if [[ -z "$version" ]]; then
+ usage >&2
+ exit 1
+fi
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+release_root="$repo_root/release-assets/releases/$version"
+
+if [[ ! -d "$release_root" ]]; then
+ echo "Release bundle not found: $release_root" >&2
+ exit 1
+fi
+
+remote_url="$(git -C "$repo_root" remote get-url "$remote_name" 2>/dev/null || true)"
+if [[ -z "$repo_full_name" ]]; then
+ if [[ "$remote_url" =~ ^https?://github\.com/(.+?)(\.git)?$ ]]; then
+ repo_full_name="${BASH_REMATCH[1]}"
+ elif [[ "$remote_url" =~ ^git@github\.com:(.+)\.git$ ]]; then
+ repo_full_name="${BASH_REMATCH[1]}"
+ fi
+fi
+
+if [[ -z "$repo_full_name" ]]; then
+ echo "Unable to infer GitHub repository name from remote: $remote_name" >&2
+ exit 1
+fi
+
+if [[ -z "$token" && -f "$repo_root/.workspace-secrets/github-public.pat" ]]; then
+ token="$(tr -d '\r\n' < "$repo_root/.workspace-secrets/github-public.pat")"
+fi
+
+if [[ "$dry_run" != "true" && -z "$token" ]]; then
+ echo "A GitHub token is required. Use --token, GITHUB_RELEASE_TOKEN, or GITHUB_TOKEN." >&2
+ exit 1
+fi
+
+if [[ -z "$notes_path" ]]; then
+ notes_path="$release_root/RELEASE_NOTES.ko.md"
+fi
+
+if [[ -n "$notes_path" && ! -f "$notes_path" ]]; then
+ echo "Release notes file not found: $notes_path" >&2
+ exit 1
+fi
+
+if [[ -z "$target_commitish" ]]; then
+ target_commitish="main"
+fi
+
+pre_release="false"
+case "$version" in
+ *alpha*|*beta*|*rc*)
+ pre_release="true"
+ ;;
+esac
+
+mapfile -t asset_files < <(
+ find "$release_root" -type f \
+ \( -name '*.zip' -o -name '*.apk' -o -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' -o -name 'RELEASE_NOTES.ko.md' -o -name 'SHA256SUMS.txt' -o -name 'version.json' \) \
+ | sort
+)
+
+if [[ ${#asset_files[@]} -eq 0 ]]; then
+ echo "No release assets found in $release_root" >&2
+ exit 1
+fi
+
+echo "GitHub release target: https://github.com/$repo_full_name"
+echo "Remote: $remote_name"
+echo "Version: $version"
+echo "Target commitish: $target_commitish"
+printf 'Assets:\n'
+printf ' - %s\n' "${asset_files[@]#$release_root/}"
+
+if [[ "$dry_run" == "true" ]]; then
+ exit 0
+fi
+
+api_root="https://api.github.com/repos/${repo_full_name}"
+release_api="${api_root}/releases"
+tag_api="${release_api}/tags/${version}"
+auth_header="Authorization: Bearer $token"
+accept_header="Accept: application/vnd.github+json"
+api_version_header="X-GitHub-Api-Version: 2022-11-28"
+tmp_response="$(mktemp)"
+trap 'rm -f "$tmp_response"' EXIT
+
+existing_status="$(curl -sS -o "$tmp_response" -w '%{http_code}' \
+ -H "$auth_header" \
+ -H "$accept_header" \
+ -H "$api_version_header" \
+ "$tag_api")"
+
+if [[ "$existing_status" == "200" ]]; then
+ existing_release_id="$(python3 - <<'PY' "$tmp_response"
+import json, sys
+with open(sys.argv[1], "r", encoding="utf-8") as fh:
+ print(json.load(fh)["id"])
+PY
+)"
+ curl -sS -X DELETE \
+ -H "$auth_header" \
+ -H "$accept_header" \
+ -H "$api_version_header" \
+ "${release_api}/${existing_release_id}" >/dev/null
+fi
+
+release_body="$(cat "$notes_path")"
+create_payload="$(python3 - <<'PY' "$version" "$release_body" "$pre_release" "$target_commitish"
+import json, sys
+version, body, prerelease, target_commitish = sys.argv[1], sys.argv[2], sys.argv[3] == "true", sys.argv[4]
+print(json.dumps({
+ "tag_name": version,
+ "target_commitish": target_commitish,
+ "name": version,
+ "body": body,
+ "draft": False,
+ "prerelease": prerelease,
+ "make_latest": "false" if prerelease else "true",
+}, ensure_ascii=False))
+PY
+)"
+
+create_status="$(curl -sS -o "$tmp_response" -w '%{http_code}' \
+ -X POST \
+ -H "$auth_header" \
+ -H "$accept_header" \
+ -H "$api_version_header" \
+ -H 'Content-Type: application/json' \
+ -d "$create_payload" \
+ "$release_api")"
+
+if [[ "$create_status" != "201" ]]; then
+ echo "Failed to create GitHub release. HTTP $create_status" >&2
+ cat "$tmp_response" >&2
+ exit 1
+fi
+
+upload_url="$(python3 - <<'PY' "$tmp_response"
+import json, sys
+with open(sys.argv[1], "r", encoding="utf-8") as fh:
+ print(json.load(fh)["upload_url"].split("{", 1)[0])
+PY
+)"
+
+for asset in "${asset_files[@]}"; do
+ name="$(basename "$asset")"
+ curl -sS \
+ -X POST \
+ -H "$auth_header" \
+ -H "$accept_header" \
+ -H "$api_version_header" \
+ -H 'Content-Type: application/octet-stream' \
+ --data-binary @"$asset" \
+ "${upload_url}?name=${name}" >/dev/null
+done
+
+echo "Published GitHub release $version with ${#asset_files[@]} assets."
diff --git a/scripts/release/release-publish-public.sh b/scripts/release/release-publish-public.sh
new file mode 100755
index 0000000..7363311
--- /dev/null
+++ b/scripts/release/release-publish-public.sh
@@ -0,0 +1,181 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage:
+ ./scripts/release/release-publish-public.sh --version 2026.04.16-alpha.4 [options]
+
+Options:
+ --branch Source branch to publish. Default: public/main
+ --target-branch Target branch name on public remotes. Default: main
+ --stage-remote Stage remote. Default: public-stage
+ --github-remote GitHub remote. Default: github-public
+ --notes Release notes markdown file
+ --skip-github Publish to stage only
+ --skip-stage Publish to GitHub only
+ --force-tag Replace an existing local or remote tag
+ --dry-run Print the planned sequence only
+EOF
+}
+
+version=""
+branch_name="public/main"
+target_branch="main"
+stage_remote="public-stage"
+github_remote="github-public"
+notes_path=""
+skip_github="false"
+skip_stage="false"
+force_tag="false"
+dry_run="false"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --version)
+ version="${2:-}"
+ shift 2
+ ;;
+ --branch)
+ branch_name="${2:-}"
+ shift 2
+ ;;
+ --target-branch)
+ target_branch="${2:-}"
+ shift 2
+ ;;
+ --stage-remote)
+ stage_remote="${2:-}"
+ shift 2
+ ;;
+ --github-remote)
+ github_remote="${2:-}"
+ shift 2
+ ;;
+ --notes)
+ notes_path="${2:-}"
+ shift 2
+ ;;
+ --skip-github)
+ skip_github="true"
+ shift
+ ;;
+ --skip-stage)
+ skip_stage="true"
+ shift
+ ;;
+ --force-tag)
+ force_tag="true"
+ shift
+ ;;
+ --dry-run)
+ dry_run="true"
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+if [[ -z "$version" ]]; then
+ usage >&2
+ exit 1
+fi
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$repo_root"
+
+if [[ -z "$notes_path" ]]; then
+ notes_path="$repo_root/release-assets/releases/$version/RELEASE_NOTES.ko.md"
+fi
+
+target_commit="$(git rev-parse "$branch_name")"
+
+echo "Version: $version"
+echo "Source branch: $branch_name"
+echo "Target branch: $target_branch"
+echo "Target commit: $target_commit"
+[[ "$skip_stage" != "true" ]] && echo "Stage remote: $stage_remote"
+[[ "$skip_github" != "true" ]] && echo "GitHub remote: $github_remote"
+
+tag_args=()
+if [[ "$force_tag" == "true" ]]; then
+ tag_args+=(--force)
+fi
+
+"$repo_root/scripts/release/release-create-tag.sh" \
+ --version "$version" \
+ --ref "$branch_name" \
+ "${tag_args[@]}" \
+ --dry-run
+
+if [[ "$dry_run" == "true" ]]; then
+ if [[ "$skip_stage" != "true" ]]; then
+ echo "DRY RUN: ALLOW_PUBLIC_PUSH=1 git push $stage_remote refs/heads/$branch_name:refs/heads/$target_branch"
+ echo "DRY RUN: ALLOW_PUBLIC_PUSH=1 git push $stage_remote refs/tags/$version:refs/tags/$version"
+ "$repo_root/scripts/release/release-publish-forge.sh" \
+ --remote "$stage_remote" \
+ --version "$version" \
+ --target-commitish "$target_branch" \
+ --notes "$notes_path" \
+ --dry-run
+ fi
+
+ if [[ "$skip_github" != "true" ]]; then
+ echo "DRY RUN: ALLOW_PUBLIC_PUSH=1 git push $github_remote refs/heads/$branch_name:refs/heads/$target_branch"
+ echo "DRY RUN: ALLOW_PUBLIC_PUSH=1 git push $github_remote refs/tags/$version:refs/tags/$version"
+ "$repo_root/scripts/release/release-publish-github.sh" \
+ --remote "$github_remote" \
+ --version "$version" \
+ --target-commitish "$target_branch" \
+ --notes "$notes_path" \
+ --dry-run
+ fi
+ exit 0
+fi
+
+"$repo_root/scripts/release/release-create-tag.sh" \
+ --version "$version" \
+ --ref "$branch_name" \
+ "${tag_args[@]}"
+
+push_branch() {
+ local remote_name="$1"
+ ALLOW_PUBLIC_PUSH=1 git push "$remote_name" "refs/heads/$branch_name:refs/heads/$target_branch"
+}
+
+push_tag() {
+ local remote_name="$1"
+ local force_args=()
+ if [[ "$force_tag" == "true" ]]; then
+ force_args+=(--force)
+ fi
+ ALLOW_PUBLIC_PUSH=1 git push "${force_args[@]}" "$remote_name" "refs/tags/$version:refs/tags/$version"
+}
+
+if [[ "$skip_stage" != "true" ]]; then
+ push_branch "$stage_remote"
+ push_tag "$stage_remote"
+ "$repo_root/scripts/release/release-publish-forge.sh" \
+ --remote "$stage_remote" \
+ --version "$version" \
+ --target-commitish "$target_branch" \
+ --notes "$notes_path"
+fi
+
+if [[ "$skip_github" != "true" ]]; then
+ push_branch "$github_remote"
+ push_tag "$github_remote"
+ "$repo_root/scripts/release/release-publish-github.sh" \
+ --remote "$github_remote" \
+ --version "$version" \
+ --target-commitish "$target_branch" \
+ --notes "$notes_path"
+fi
diff --git a/src/PhysOn.Desktop/App.axaml.cs b/src/PhysOn.Desktop/App.axaml.cs
index 8e0dfa4..00c2db0 100644
--- a/src/PhysOn.Desktop/App.axaml.cs
+++ b/src/PhysOn.Desktop/App.axaml.cs
@@ -26,9 +26,22 @@ public partial class App : Application
DataContext = viewModel,
};
- _ = viewModel.InitializeAsync();
+ _ = InitializeDesktopAsync(viewModel);
}
base.OnFrameworkInitializationCompleted();
}
+
+ private static async Task InitializeDesktopAsync(MainWindowViewModel viewModel)
+ {
+ await viewModel.InitializeAsync();
+
+ if (string.Equals(
+ Environment.GetEnvironmentVariable("KOTALK_DESKTOP_OPEN_SAMPLE_WINDOW"),
+ "1",
+ StringComparison.Ordinal))
+ {
+ await viewModel.OpenDetachedConversationFromShortcutAsync();
+ }
+ }
}
diff --git a/src/PhysOn.Desktop/Models/DesktopWorkspaceLayout.cs b/src/PhysOn.Desktop/Models/DesktopWorkspaceLayout.cs
index a299e37..1b887f7 100644
--- a/src/PhysOn.Desktop/Models/DesktopWorkspaceLayout.cs
+++ b/src/PhysOn.Desktop/Models/DesktopWorkspaceLayout.cs
@@ -3,4 +3,5 @@ namespace PhysOn.Desktop.Models;
public sealed record DesktopWorkspaceLayout(
bool IsCompactDensity,
bool IsInspectorVisible,
- bool IsConversationPaneCollapsed);
+ bool IsConversationPaneCollapsed,
+ double ConversationPaneWidth = 348);
diff --git a/src/PhysOn.Desktop/ViewModels/ConversationWindowViewModel.cs b/src/PhysOn.Desktop/ViewModels/ConversationWindowViewModel.cs
index a714daa..c42d5e2 100644
--- a/src/PhysOn.Desktop/ViewModels/ConversationWindowViewModel.cs
+++ b/src/PhysOn.Desktop/ViewModels/ConversationWindowViewModel.cs
@@ -45,6 +45,12 @@ public partial class ConversationWindowViewModel : ViewModelBase, IAsyncDisposab
public async Task InitializeAsync()
{
+ if (string.Equals(Environment.GetEnvironmentVariable("KOTALK_DESKTOP_SAMPLE_MODE"), "1", StringComparison.Ordinal))
+ {
+ LoadSampleConversation();
+ return;
+ }
+
await LoadMessagesAsync();
try
@@ -78,6 +84,47 @@ public partial class ConversationWindowViewModel : ViewModelBase, IAsyncDisposab
partial void OnErrorTextChanged(string? value) => OnPropertyChanged(nameof(HasErrorText));
partial void OnConversationTitleChanged(string value) => OnPropertyChanged(nameof(ConversationGlyph));
+ private void LoadSampleConversation()
+ {
+ Messages.Clear();
+ StatusText = "●";
+ ErrorText = null;
+
+ foreach (var item in new[]
+ {
+ new MessageRowViewModel
+ {
+ MessageId = "detached-1",
+ SenderName = "민지",
+ Text = "이 창은 대화를 따로 두고 확인할 수 있게 분리했습니다.",
+ MetaText = "09:10",
+ IsMine = false,
+ ServerSequence = 1
+ },
+ new MessageRowViewModel
+ {
+ MessageId = "detached-2",
+ SenderName = _launchContext.DisplayName,
+ Text = "검수하면서도 메인 받은함은 그대로 둘 수 있어요.",
+ MetaText = "09:11",
+ IsMine = true,
+ ServerSequence = 2
+ },
+ new MessageRowViewModel
+ {
+ MessageId = "detached-3",
+ SenderName = "민지",
+ Text = "작업용 대화만 따로 띄워두기엔 이 구성이 훨씬 낫네요.",
+ MetaText = "09:12",
+ IsMine = false,
+ ServerSequence = 3
+ }
+ })
+ {
+ Messages.Add(item);
+ }
+ }
+
private async Task LoadMessagesAsync()
{
if (IsBusy)
diff --git a/src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs b/src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs
index bc14f24..7b90428 100644
--- a/src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs
+++ b/src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs
@@ -45,6 +45,9 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
ShowAllConversationsCommand = new RelayCommand(() => SelectedListFilter = "all");
ShowUnreadConversationsCommand = new RelayCommand(() => SelectedListFilter = "unread");
ShowPinnedConversationsCommand = new RelayCommand(() => SelectedListFilter = "pinned");
+ ApplyAckDraftCommand = new RelayCommand(() => ApplyQuickDraft("확인했습니다."));
+ ApplyShareDraftCommand = new RelayCommand(() => ApplyQuickDraft("공유드립니다.\n- "));
+ ApplyTaskDraftCommand = new RelayCommand(() => ApplyQuickDraft("할 일\n- "));
ToggleCompactModeCommand = new RelayCommand(() => IsCompactDensity = !IsCompactDensity);
ToggleInspectorCommand = new RelayCommand(() => IsInspectorVisible = !IsInspectorVisible);
ToggleConversationPaneCommand = new RelayCommand(() => IsConversationPaneCollapsed = !IsConversationPaneCollapsed);
@@ -80,6 +83,9 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
public IRelayCommand ShowAllConversationsCommand { get; }
public IRelayCommand ShowUnreadConversationsCommand { get; }
public IRelayCommand ShowPinnedConversationsCommand { get; }
+ public IRelayCommand ApplyAckDraftCommand { get; }
+ public IRelayCommand ApplyShareDraftCommand { get; }
+ public IRelayCommand ApplyTaskDraftCommand { get; }
public IRelayCommand ToggleCompactModeCommand { get; }
public IRelayCommand ToggleInspectorCommand { get; }
public IRelayCommand ToggleConversationPaneCommand { get; }
@@ -102,7 +108,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
[ObservableProperty] private string selectedListFilter = "all";
[ObservableProperty] private string composerText = string.Empty;
[ObservableProperty] private string selectedConversationTitle = "KoTalk";
- [ObservableProperty] private string selectedConversationSubtitle = "대화를 열면 바로 이어집니다.";
+ [ObservableProperty] private string selectedConversationSubtitle = "준비";
[ObservableProperty] private ConversationRowViewModel? selectedConversation;
[ObservableProperty] private bool hasErrorText;
[ObservableProperty] private bool hasFilteredConversations;
@@ -110,6 +116,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
[ObservableProperty] private bool isCompactDensity = true;
[ObservableProperty] private bool isInspectorVisible;
[ObservableProperty] private bool isConversationPaneCollapsed;
+ [ObservableProperty] private double conversationPaneWidthValue = 348;
[ObservableProperty] private int detachedWindowCount;
public bool ShowOnboarding => !IsAuthenticated;
@@ -153,26 +160,26 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
public string DetachedWindowActionGlyph => HasDetachedWindows ? DetachedWindowBadgeText : "↗";
public bool HasDetachedWindows => DetachedWindowCount > 0;
public bool IsConversationPaneExpanded => !IsConversationPaneCollapsed;
- public double ConversationPaneWidth => IsConversationPaneCollapsed ? 0 : (IsCompactDensity ? 296 : 340);
+ public double ConversationPaneWidth => IsConversationPaneCollapsed ? 0 : ConversationPaneWidthValue;
public double InspectorPaneWidth => IsInspectorVisible ? (IsCompactDensity ? 92 : 108) : 0;
- public Thickness ConversationRowPadding => IsCompactDensity ? new Thickness(6, 6) : new Thickness(8, 7);
- public Thickness MessageBubblePadding => IsCompactDensity ? new Thickness(10, 8) : new Thickness(12, 10);
- public double ConversationAvatarSize => IsCompactDensity ? 26 : 30;
+ public Thickness ConversationRowPadding => IsCompactDensity ? new Thickness(6, 5) : new Thickness(8, 6);
+ public Thickness MessageBubblePadding => IsCompactDensity ? new Thickness(10, 7) : new Thickness(12, 9);
+ public double ConversationAvatarSize => IsCompactDensity ? 28 : 32;
public double ComposerMinHeight => IsCompactDensity ? 48 : 58;
public string ComposerCounterText => $"{ComposerText.Trim().Length}";
- public string SearchWatermark => "대화 검색";
+ public string SearchWatermark => "검색";
public string InspectorStatusText => HasDetachedWindows
? $"{RealtimeStatusGlyph} {DetachedWindowBadgeText}"
: RealtimeStatusGlyph;
public string WorkspaceModeText => HasDetachedWindows ? $"분리 창 {DetachedWindowBadgeText}" : "단일 창";
public string StatusSummaryText => string.IsNullOrWhiteSpace(StatusLine) ? RealtimeStatusText : StatusLine;
- public string ComposerPlaceholderText => HasSelectedConversation ? "메시지 보내기" : "왼쪽에서 대화를 고르세요";
+ public string ComposerPlaceholderText => HasSelectedConversation ? "메시지" : "대화 선택";
public string ComposerActionText => Messages.Count == 0 ? "시작" : "보내기";
public bool ShowMessageEmptyState => Messages.Count == 0;
- public string MessageEmptyStateTitle => HasSelectedConversation ? "첫 메시지부터 시작" : "대화를 먼저 고르세요";
+ public string MessageEmptyStateTitle => HasSelectedConversation ? "첫 메시지" : "대화 선택";
public string MessageEmptyStateText => HasSelectedConversation
- ? "짧게 한 줄만 남겨도 바로 이어집니다."
- : "받은함에서 대화를 고르거나 창으로 분리해 집중할 수 있습니다.";
+ ? "짧게 남기세요."
+ : "목록에서 선택";
public async Task InitializeAsync()
{
@@ -270,6 +277,11 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
OnPropertyChanged(nameof(InspectorPaneWidth));
_ = PersistWorkspaceLayoutAsync();
}
+ partial void OnConversationPaneWidthValueChanged(double value)
+ {
+ OnPropertyChanged(nameof(ConversationPaneWidth));
+ _ = PersistWorkspaceLayoutAsync();
+ }
partial void OnIsConversationPaneCollapsedChanged(bool value)
{
OnPropertyChanged(nameof(ConversationPaneGlyph));
@@ -292,7 +304,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
{
UpdateSelectedConversationState(value?.ConversationId);
SelectedConversationTitle = value?.Title ?? "KoTalk";
- SelectedConversationSubtitle = value?.Subtitle ?? "받은함과 대화를 한 화면에서 관리합니다.";
+ SelectedConversationSubtitle = value?.Subtitle ?? "대화";
OnPropertyChanged(nameof(SelectedConversationGlyph));
OnPropertyChanged(nameof(HasSelectedConversation));
OnPropertyChanged(nameof(HasSelectedConversationUnread));
@@ -317,7 +329,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
$"desktop-{Environment.MachineName.ToLowerInvariant()}",
"windows",
Environment.MachineName,
- "0.1.0"));
+ "0.1.0-alpha.4"));
var response = await _apiClient.RegisterAlphaQuickAsync(apiBaseUrl, request, CancellationToken.None);
ApiBaseUrl = apiBaseUrl;
@@ -370,7 +382,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
SelectedListFilter = "all";
SelectedConversation = null;
SelectedConversationTitle = "KoTalk";
- SelectedConversationSubtitle = "대화를 열면 바로 이어집니다.";
+ SelectedConversationSubtitle = "준비";
NotifyConversationMetricsChanged();
}
@@ -381,10 +393,18 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
return;
}
+ Messages.Clear();
+ NotifyMessageStateChanged();
+
await RunBusyAsync(async () =>
{
var items = await _apiClient.GetMessagesAsync(_session.ApiBaseUrl, _session.AccessToken, value.ConversationId, CancellationToken.None);
+ if (!string.Equals(SelectedConversation?.ConversationId, value.ConversationId, StringComparison.Ordinal))
+ {
+ return;
+ }
+
Messages.Clear();
foreach (var item in items.Items)
{
@@ -667,6 +687,27 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
OnPropertyChanged(nameof(PinnedConversationCount));
}
+ public void UpdateConversationPaneWidth(double width)
+ {
+ if (IsConversationPaneCollapsed)
+ {
+ return;
+ }
+
+ var clamped = Math.Clamp(Math.Round(width), 280, 480);
+ if (Math.Abs(clamped - ConversationPaneWidthValue) > 1)
+ {
+ ConversationPaneWidthValue = clamped;
+ }
+ }
+
+ private void ApplyQuickDraft(string template)
+ {
+ ComposerText = string.IsNullOrWhiteSpace(ComposerText)
+ ? template
+ : ComposerText.TrimEnd() + Environment.NewLine + template;
+ }
+
private void LoadSampleWorkspace()
{
Conversations.Clear();
@@ -676,6 +717,13 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
CurrentUserDisplayName = "이안";
DisplayName = "이안";
InviteCode = string.Empty;
+ _currentUserId = "sample-user";
+ _session = new DesktopSession(
+ DefaultApiBaseUrl,
+ "sample-access",
+ "sample-refresh",
+ CurrentUserDisplayName,
+ "sample-ops");
RealtimeState = RealtimeConnectionState.Connected;
RealtimeStatusText = "연결됨";
StatusLine = "준비";
@@ -683,6 +731,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
IsCompactDensity = true;
IsInspectorVisible = false;
IsConversationPaneCollapsed = false;
+ ConversationPaneWidthValue = 348;
DetachedWindowCount = 1;
ErrorText = null;
@@ -691,8 +740,8 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
{
ConversationId = "sample-ops",
Title = "제품 운영",
- Subtitle = "스크린샷 기준으로 레이아웃을 다시 정리했습니다.",
- LastMessageText = "스크린샷 기준으로 레이아웃을 다시 정리했습니다.",
+ Subtitle = "레이아웃 검수 메모를 확인해 주세요.",
+ LastMessageText = "레이아웃 검수 메모를 확인해 주세요.",
MetaText = FormatConversationMeta(now.AddMinutes(-5), 2),
UnreadCount = 2,
IsPinned = true,
@@ -703,8 +752,8 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
{
ConversationId = "sample-review",
Title = "디자인 리뷰",
- Subtitle = "오후 2시에 검수 포인트만 다시 볼게요.",
- LastMessageText = "오후 2시에 검수 포인트만 다시 볼게요.",
+ Subtitle = "오후 2시에 포인트만 다시 볼게요.",
+ LastMessageText = "오후 2시에 포인트만 다시 볼게요.",
MetaText = FormatConversationMeta(now.AddMinutes(-22), 0),
UnreadCount = 0,
IsPinned = false,
@@ -715,14 +764,38 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
{
ConversationId = "sample-friends",
Title = "주말 약속",
- Subtitle = "토요일 브런치 장소만 정하면 끝.",
- LastMessageText = "토요일 브런치 장소만 정하면 끝.",
+ Subtitle = "브런치 장소만 정하면 끝.",
+ LastMessageText = "브런치 장소만 정하면 끝.",
MetaText = FormatConversationMeta(now.AddMinutes(-54), 0),
UnreadCount = 0,
IsPinned = false,
LastReadSequence = 3,
SortKey = now.AddMinutes(-54)
});
+ Conversations.Add(new ConversationRowViewModel
+ {
+ ConversationId = "sample-team",
+ Title = "운영 팀",
+ Subtitle = "오후 공유본만 마지막으로 확인해 주세요.",
+ LastMessageText = "오후 공유본만 마지막으로 확인해 주세요.",
+ MetaText = FormatConversationMeta(now.AddHours(-2), 1),
+ UnreadCount = 1,
+ IsPinned = false,
+ LastReadSequence = 7,
+ SortKey = now.AddHours(-2)
+ });
+ Conversations.Add(new ConversationRowViewModel
+ {
+ ConversationId = "sample-files",
+ Title = "자료 모음",
+ Subtitle = "최신 캡처와 빌드 경로를 정리해 두었습니다.",
+ LastMessageText = "최신 캡처와 빌드 경로를 정리해 두었습니다.",
+ MetaText = FormatConversationMeta(now.AddHours(-5), 0),
+ UnreadCount = 0,
+ IsPinned = true,
+ LastReadSequence = 9,
+ SortKey = now.AddHours(-5)
+ });
NotifyConversationMetricsChanged();
RefreshConversationFilter("sample-ops");
@@ -735,7 +808,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
{
MessageId = "sample-msg-1",
SenderName = "민지",
- Text = "회의 전에 이슈만 짧게 정리해 주세요.",
+ Text = "회의 전에 레이아웃 이슈만 짧게 정리해 주세요.",
MetaText = "08:54",
IsMine = false,
ServerSequence = 13
@@ -744,7 +817,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
{
MessageId = "sample-msg-2",
SenderName = "이안",
- Text = "레이아웃 구조를 다시 줄였습니다. 우측 빈 패널도 없앴어요.",
+ Text = "리스트 폭을 다시 줄이고 우측 빈 패널도 없앴어요.",
MetaText = "08:56",
IsMine = true,
ServerSequence = 14
@@ -753,7 +826,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
{
MessageId = "sample-msg-3",
SenderName = "민지",
- Text = "좋아요. 지금 화면이면 바로 검수할 수 있겠네요.",
+ Text = "좋아요. 지금 화면이면 검수하기 좋겠네요.",
MetaText = "08:58",
IsMine = false,
ServerSequence = 15
@@ -762,7 +835,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
{
MessageId = "sample-msg-4",
SenderName = "이안",
- Text = "스크린샷 기준으로 레이아웃도 바로 수정했습니다.",
+ Text = "스크린샷 기준으로 밀도도 같이 맞췄습니다.",
MetaText = "09:05",
IsMine = true,
ServerSequence = 16
@@ -771,7 +844,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
{
MessageId = "sample-msg-5",
SenderName = "민지",
- Text = "좋아요. 바로 확인 가능한 흐름으로 정리됐어요.",
+ Text = "좋아요. 확인 흐름이 더 짧아졌어요.",
MetaText = "09:06",
IsMine = false,
ServerSequence = 17
@@ -780,7 +853,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
{
MessageId = "sample-msg-6",
SenderName = "이안",
- Text = "분리 창도 한 번에 열리도록 남겨 두었습니다.",
+ Text = "분리 창은 상단 액션으로 남겨 두었습니다.",
MetaText = "09:07",
IsMine = true,
ServerSequence = 18
@@ -793,6 +866,42 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
MetaText = "09:08",
IsMine = false,
ServerSequence = 19
+ },
+ new MessageRowViewModel
+ {
+ MessageId = "sample-msg-8",
+ SenderName = "이안",
+ Text = "검색과 필터는 한 줄 안에서 끝나도록 다시 정리할게요.",
+ MetaText = "09:10",
+ IsMine = true,
+ ServerSequence = 20
+ },
+ new MessageRowViewModel
+ {
+ MessageId = "sample-msg-9",
+ SenderName = "민지",
+ Text = "좋아요. 설명보다 눌리는 구조가 더 중요해요.",
+ MetaText = "09:11",
+ IsMine = false,
+ ServerSequence = 21
+ },
+ new MessageRowViewModel
+ {
+ MessageId = "sample-msg-10",
+ SenderName = "이안",
+ Text = "작성창도 짧은 액션만 남기고 텍스트는 줄였습니다.",
+ MetaText = "09:12",
+ IsMine = true,
+ ServerSequence = 22
+ },
+ new MessageRowViewModel
+ {
+ MessageId = "sample-msg-11",
+ SenderName = "민지",
+ Text = "이제 목록과 대화가 한 화면에서 훨씬 빠르게 읽히네요.",
+ MetaText = "09:13",
+ IsMine = false,
+ ServerSequence = 23
}
})
{
@@ -1021,6 +1130,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
IsCompactDensity = true;
IsInspectorVisible = false;
IsConversationPaneCollapsed = false;
+ ConversationPaneWidthValue = 348;
StatusLine = "초기화";
}
@@ -1029,6 +1139,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
IsCompactDensity = layout.IsCompactDensity;
IsInspectorVisible = layout.IsInspectorVisible;
IsConversationPaneCollapsed = layout.IsConversationPaneCollapsed;
+ ConversationPaneWidthValue = Math.Clamp(layout.ConversationPaneWidth, 280, 480);
}
private Task PersistWorkspaceLayoutAsync()
@@ -1036,7 +1147,8 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
return _workspaceLayoutStore.SaveAsync(new DesktopWorkspaceLayout(
IsCompactDensity,
IsInspectorVisible,
- IsConversationPaneCollapsed));
+ IsConversationPaneCollapsed,
+ ConversationPaneWidthValue));
}
public async ValueTask DisposeAsync()
diff --git a/src/PhysOn.Desktop/Views/ConversationWindow.axaml b/src/PhysOn.Desktop/Views/ConversationWindow.axaml
index f15305e..fb6bbba 100644
--- a/src/PhysOn.Desktop/Views/ConversationWindow.axaml
+++ b/src/PhysOn.Desktop/Views/ConversationWindow.axaml
@@ -3,95 +3,98 @@
xmlns:vm="using:PhysOn.Desktop.ViewModels"
x:Class="PhysOn.Desktop.Views.ConversationWindow"
x:DataType="vm:ConversationWindowViewModel"
- Width="460"
- Height="760"
- MinWidth="360"
+ Width="404"
+ Height="748"
+ MinWidth="340"
MinHeight="520"
- Background="#F6F7F8"
+ Background="#F3F4F6"
Title="{Binding ConversationTitle}">
-
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
+ Foreground="#111418" />
+
-
-
-
-
-
-
+
+
+
+
+
-
-
+
+
@@ -123,17 +126,18 @@
-
+
diff --git a/src/PhysOn.Desktop/Views/MainWindow.axaml b/src/PhysOn.Desktop/Views/MainWindow.axaml
index 582de93..7a798ce 100644
--- a/src/PhysOn.Desktop/Views/MainWindow.axaml
+++ b/src/PhysOn.Desktop/Views/MainWindow.axaml
@@ -12,9 +12,9 @@
Title="KoTalk"
Width="1440"
Height="900"
- MinWidth="1040"
- MinHeight="680"
- Background="#F6F7F8">
+ MinWidth="980"
+ MinHeight="640"
+ Background="#F3F4F6">
@@ -22,39 +22,73 @@
-
-
+
+
+
+
+
+
+
+
@@ -69,68 +103,68 @@
@@ -140,13 +174,13 @@
@@ -161,194 +195,85 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
+ MaxWidth="380">
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
-
+
-
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
+
+
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
+ Padding="10"
+ IsVisible="{Binding IsConversationPaneExpanded}"
+ SizeChanged="ConversationPaneHost_OnSizeChanged">
+
+
+
@@ -423,7 +319,6 @@
Classes="search-input"
PlaceholderText="{Binding SearchWatermark}"
Text="{Binding ConversationSearchText}" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -564,72 +444,62 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
-
+
+
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
+
+
-
-
-
+
+
+
+
+
+
+
-
+
+
-
-
-
+
+
+
+
diff --git a/src/PhysOn.Desktop/Views/MainWindow.axaml.cs b/src/PhysOn.Desktop/Views/MainWindow.axaml.cs
index 23bcf32..3a5e017 100644
--- a/src/PhysOn.Desktop/Views/MainWindow.axaml.cs
+++ b/src/PhysOn.Desktop/Views/MainWindow.axaml.cs
@@ -66,6 +66,14 @@ public partial class MainWindow : Window
}
}
+ private void ConversationPaneHost_OnSizeChanged(object? sender, SizeChangedEventArgs e)
+ {
+ if (DataContext is MainWindowViewModel viewModel && sender is Control control && control.IsVisible)
+ {
+ viewModel.UpdateConversationPaneWidth(control.Bounds.Width);
+ }
+ }
+
protected override void OnClosed(EventArgs e)
{
if (_boundViewModel is not null)
diff --git a/src/PhysOn.Web/package-lock.json b/src/PhysOn.Web/package-lock.json
index 0e86d09..ca9700d 100644
--- a/src/PhysOn.Web/package-lock.json
+++ b/src/PhysOn.Web/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "physon-web",
- "version": "0.1.0-alpha.2",
+ "version": "0.1.0-alpha.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "physon-web",
- "version": "0.1.0-alpha.2",
+ "version": "0.1.0-alpha.4",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
diff --git a/src/PhysOn.Web/package.json b/src/PhysOn.Web/package.json
index cf76598..0bebf94 100644
--- a/src/PhysOn.Web/package.json
+++ b/src/PhysOn.Web/package.json
@@ -1,7 +1,7 @@
{
"name": "physon-web",
"private": true,
- "version": "0.1.0-alpha.2",
+ "version": "0.1.0-alpha.4",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
diff --git a/src/PhysOn.Web/src/App.css b/src/PhysOn.Web/src/App.css
index 218bf06..a576793 100644
--- a/src/PhysOn.Web/src/App.css
+++ b/src/PhysOn.Web/src/App.css
@@ -14,9 +14,11 @@
.onboarding {
display: grid;
- gap: 16px;
+ gap: 10px;
width: 100%;
- padding: 16px 16px calc(20px + env(safe-area-inset-bottom));
+ padding: 12px 12px calc(16px + env(safe-area-inset-bottom));
+ max-width: 460px;
+ margin: 0 auto;
}
.onboarding__chrome,
@@ -25,7 +27,7 @@
display: flex;
align-items: center;
justify-content: space-between;
- gap: 12px;
+ gap: 8px;
}
.brand-lockup,
@@ -33,7 +35,7 @@
.chat-appbar__leading {
display: flex;
align-items: center;
- gap: 12px;
+ gap: 10px;
min-width: 0;
}
@@ -49,7 +51,7 @@
.appbar__title h2,
.chat-appbar__title strong {
margin: 0;
- font-size: 17px;
+ font-size: 16px;
line-height: 1.2;
letter-spacing: -0.02em;
}
@@ -84,9 +86,9 @@
}
.brand-mark {
- width: 42px;
- height: 42px;
- border-radius: 10px;
+ width: 38px;
+ height: 38px;
+ border-radius: 2px;
}
.brand-mark svg,
@@ -105,9 +107,9 @@
}
.brand-mark--small {
- width: 36px;
- height: 36px;
- border-radius: 10px;
+ width: 32px;
+ height: 32px;
+ border-radius: 2px;
}
.surface-badge,
@@ -117,10 +119,10 @@
display: inline-flex;
align-items: center;
gap: 6px;
- min-height: 32px;
- padding: 0 12px;
+ min-height: 28px;
+ padding: 0 10px;
border: 1px solid var(--border-subtle);
- border-radius: 999px;
+ border-radius: 2px;
background: var(--surface-base);
}
@@ -128,13 +130,13 @@
.onboarding__panel,
.pane {
border: 1px solid var(--border-subtle);
- border-radius: 12px;
+ border-radius: 2px;
background: var(--surface-base);
}
.onboarding__hero,
.onboarding__panel {
- padding: 20px 18px;
+ padding: 14px;
}
.onboarding__hero {
@@ -197,7 +199,7 @@
.panel__heading {
display: grid;
- gap: 6px;
+ gap: 4px;
}
.panel__label {
@@ -207,8 +209,8 @@
.onboarding__form {
display: grid;
- gap: 12px;
- margin-top: 18px;
+ gap: 10px;
+ margin-top: 12px;
}
.field {
@@ -227,10 +229,10 @@
width: 100%;
min-width: 0;
border: 1px solid var(--border-strong);
- border-radius: 10px;
+ border-radius: 2px;
background: var(--surface-base);
color: var(--text-strong);
- padding: 13px 14px;
+ padding: 11px 12px;
outline: none;
}
@@ -262,8 +264,8 @@
}
.primary-button {
- min-height: 48px;
- border-radius: 12px;
+ min-height: 42px;
+ border-radius: 2px;
background: var(--text-strong);
color: #fff;
font-weight: 700;
@@ -271,9 +273,9 @@
.secondary-button,
.ghost-button {
- min-height: 44px;
- padding: 0 14px;
- border-radius: 12px;
+ min-height: 38px;
+ padding: 0 12px;
+ border-radius: 2px;
}
.secondary-button {
@@ -298,7 +300,7 @@
display: flex;
justify-content: space-between;
gap: 12px;
- margin-top: 14px;
+ margin-top: 10px;
}
.status-text {
@@ -309,13 +311,13 @@
display: grid;
grid-template-columns: minmax(0, 1fr);
width: 100%;
- padding-bottom: calc(72px + env(safe-area-inset-bottom));
+ padding-bottom: calc(66px + env(safe-area-inset-bottom));
}
.pane {
display: grid;
grid-template-rows: auto auto auto auto 1fr;
- gap: 12px;
+ gap: 8px;
min-width: 0;
width: 100%;
min-height: 100svh;
@@ -324,11 +326,11 @@
}
.pane--list {
- padding: 18px 16px 0;
+ padding: 12px 12px 0;
}
.pane--chat {
- padding: 18px 16px 14px;
+ padding: 12px;
border-left: 0;
}
@@ -339,13 +341,13 @@
.appbar__actions {
display: flex;
align-items: center;
- gap: 8px;
+ gap: 6px;
}
.toolbar-strip {
display: flex;
align-items: center;
- gap: 8px;
+ gap: 6px;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
@@ -358,7 +360,7 @@
.toolbar-strip__group {
display: inline-flex;
align-items: center;
- gap: 8px;
+ gap: 6px;
flex: none;
}
@@ -373,10 +375,10 @@
.icon-button {
display: grid;
place-items: center;
- width: 38px;
- height: 38px;
+ width: 34px;
+ height: 34px;
border: 1px solid var(--border-subtle);
- border-radius: 12px;
+ border-radius: 2px;
background: var(--surface-base);
color: var(--text-soft);
}
@@ -386,16 +388,16 @@
}
.icon-button--soft {
- width: 34px;
- height: 34px;
- border-radius: 10px;
+ width: 30px;
+ height: 30px;
+ border-radius: 2px;
background: var(--surface-muted);
}
.status-chip {
color: var(--text-soft);
- min-height: 34px;
- padding: 0 10px;
+ min-height: 28px;
+ padding: 0 8px;
background: var(--surface-muted);
}
@@ -408,7 +410,7 @@
.status-dot {
width: 7px;
height: 7px;
- border-radius: 999px;
+ border-radius: 2px;
background: #9ca3af;
}
@@ -429,10 +431,10 @@
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
- gap: 10px;
- padding: 0 14px;
+ gap: 8px;
+ padding: 0 12px;
border: 1px solid var(--border-subtle);
- border-radius: 12px;
+ border-radius: 2px;
background: var(--surface-muted);
}
@@ -444,14 +446,14 @@
.search-field input {
border: 0;
- padding: 12px 0;
+ padding: 10px 0;
background: transparent;
}
.quick-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 8px;
+ gap: 6px;
}
.quick-grid--triple {
@@ -460,11 +462,11 @@
.mini-panel {
display: grid;
- gap: 6px;
- min-height: 84px;
- padding: 14px 12px;
+ gap: 4px;
+ min-height: 72px;
+ padding: 11px 10px;
border: 1px solid var(--border-subtle);
- border-radius: 14px;
+ border-radius: 2px;
background: var(--surface-base);
text-align: left;
}
@@ -494,21 +496,25 @@
.conversation-list {
display: grid;
align-content: start;
- gap: 8px;
+ gap: 6px;
overflow: auto;
min-width: 0;
- padding-bottom: 16px;
+ padding-bottom: 12px;
+}
+
+.conversation-list--saved {
+ gap: 10px;
}
.conversation-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
- gap: 10px;
+ gap: 8px;
align-items: center;
width: 100%;
- padding: 9px 10px;
+ padding: 8px 9px;
border: 1px solid var(--border-subtle);
- border-radius: 10px;
+ border-radius: 2px;
background: var(--surface-base);
text-align: left;
}
@@ -520,6 +526,11 @@
gap: 8px;
}
+.search-results--discovery,
+.search-results--matches {
+ align-content: start;
+}
+
.saved-section__header {
display: flex;
align-items: center;
@@ -543,11 +554,11 @@
.search-result {
display: grid;
- gap: 8px;
+ gap: 6px;
width: 100%;
- padding: 11px 12px;
+ padding: 9px 10px;
border: 1px solid var(--border-subtle);
- border-radius: 10px;
+ border-radius: 2px;
background: var(--surface-base);
text-align: left;
}
@@ -593,16 +604,16 @@
}
.avatar {
- width: 38px;
- height: 38px;
- border-radius: 10px;
+ width: 34px;
+ height: 34px;
+ border-radius: 2px;
font-weight: 700;
letter-spacing: -0.03em;
}
.avatar--header {
- width: 34px;
- height: 34px;
+ width: 30px;
+ height: 30px;
}
.conversation-row__body,
@@ -666,7 +677,7 @@
min-width: 22px;
height: 22px;
padding: 0 6px;
- border-radius: 999px;
+ border-radius: 2px;
background: var(--text-strong);
color: #fff;
font-style: normal;
@@ -677,7 +688,7 @@
.row-pin {
width: 7px;
height: 7px;
- border-radius: 999px;
+ border-radius: 2px;
background: var(--text-muted);
}
@@ -691,8 +702,8 @@
display: flex;
align-items: center;
flex-wrap: wrap;
- gap: 10px;
- min-height: 32px;
+ gap: 8px;
+ min-height: 28px;
}
.chat-overview span {
@@ -710,9 +721,9 @@
.mini-pill {
display: inline-flex;
align-items: center;
- min-height: 30px;
- padding: 0 12px;
- border-radius: 999px;
+ min-height: 26px;
+ padding: 0 9px;
+ border-radius: 2px;
background: var(--surface-muted);
border: 1px solid var(--border-subtle);
}
@@ -720,10 +731,10 @@
.message-stream {
display: grid;
align-content: start;
- gap: 8px;
+ gap: 6px;
overflow: auto;
min-width: 0;
- padding: 4px 0 12px;
+ padding: 2px 0 10px;
}
.message-stream--empty {
@@ -744,10 +755,10 @@
.empty-panel,
.profile-card {
display: grid;
- gap: 8px;
- padding: 12px;
+ gap: 6px;
+ padding: 10px;
border: 1px solid var(--border-subtle);
- border-radius: 10px;
+ border-radius: 2px;
background: var(--surface-base);
}
@@ -789,9 +800,9 @@
}
.avatar--profile {
- width: 44px;
- height: 44px;
- border-radius: 12px;
+ width: 40px;
+ height: 40px;
+ border-radius: 2px;
}
.profile-card__body {
@@ -845,9 +856,9 @@
}
.message-bubble__body {
- padding: 11px 12px;
+ padding: 9px 10px;
border: 1px solid var(--border-subtle);
- border-radius: 10px;
+ border-radius: 2px;
background: var(--surface-muted);
}
@@ -868,8 +879,8 @@
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto auto;
- gap: 10px;
- padding-top: 10px;
+ gap: 8px;
+ padding-top: 8px;
border-top: 1px solid var(--border-subtle);
}
@@ -877,7 +888,7 @@
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
- gap: 8px;
+ gap: 6px;
}
.composer__field {
@@ -885,7 +896,7 @@
}
.composer textarea {
- min-height: 48px;
+ min-height: 44px;
max-height: 120px;
resize: none;
}
@@ -893,9 +904,9 @@
.send-button {
display: grid;
place-items: center;
- width: 48px;
- height: 48px;
- border-radius: 10px;
+ width: 44px;
+ height: 44px;
+ border-radius: 2px;
background: var(--text-strong);
color: #fff;
}
@@ -903,8 +914,8 @@
.bottom-bar {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 8px;
- padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
+ gap: 6px;
+ padding: 8px 12px calc(8px + env(safe-area-inset-bottom));
border-top: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.98);
}
@@ -923,16 +934,18 @@
justify-items: center;
align-content: center;
gap: 2px;
- min-height: 46px;
+ min-height: 42px;
padding: 6px 4px;
- border-radius: 12px;
+ border-radius: 2px;
background: transparent;
color: var(--text-muted);
}
.nav-button span {
- font-size: 11px;
+ display: none;
+ font-size: 10px;
line-height: 1.2;
+ font-weight: 600;
}
.nav-button--active {
@@ -940,14 +953,18 @@
color: var(--text-strong);
}
+.nav-button--active span {
+ display: block;
+}
+
.nav-button--active::before {
content: '';
position: absolute;
- top: 0;
- left: 18%;
- width: 64%;
+ top: 4px;
+ left: 22%;
+ width: 56%;
height: 2px;
- border-radius: 999px;
+ border-radius: 2px;
background: var(--text-strong);
}
@@ -959,7 +976,7 @@
max-width: calc(100vw - 32px);
padding: 12px 14px;
border: 1px solid var(--border-strong);
- border-radius: 12px;
+ border-radius: 2px;
background: rgba(255, 255, 255, 0.97);
color: var(--text-soft);
font-size: 13px;
@@ -995,37 +1012,56 @@
@media (min-width: 900px) {
.onboarding {
- max-width: 1200px;
- margin: 0 auto;
- grid-template-columns: minmax(0, 1.2fr) minmax(360px, 400px);
- align-items: stretch;
- padding: 24px;
+ max-width: 500px;
+ grid-template-columns: minmax(0, 1fr);
+ padding: 18px;
}
.shell {
- max-width: 1480px;
+ max-width: 1320px;
margin: 0 auto;
- grid-template-columns: 88px 340px minmax(640px, 1fr);
- gap: 18px;
+ grid-template-columns: 64px minmax(272px, 340px) minmax(0, 1fr);
+ gap: 10px;
align-items: stretch;
- padding: 18px;
- padding-bottom: 18px;
+ padding: 10px;
+ padding-bottom: 10px;
}
.pane {
- min-height: calc(100svh - 36px);
+ min-height: calc(100svh - 20px);
margin: 0;
- border-radius: 14px;
+ border-radius: 2px;
}
.pane--list {
- padding-bottom: 16px;
+ padding-bottom: 10px;
}
.pane--chat {
display: grid;
}
+ .pane--search .search-results--discovery,
+ .pane--search .search-results--matches,
+ .pane--saved .conversation-list--saved {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ align-content: start;
+ gap: 8px;
+ }
+
+ .pane--search .saved-section,
+ .pane--saved .saved-section {
+ align-content: start;
+ padding: 8px;
+ border: 1px solid var(--border-subtle);
+ border-radius: 2px;
+ background: var(--surface-muted);
+ }
+
+ .pane--me .profile-card {
+ max-width: 420px;
+ }
+
.pane--hidden {
display: grid;
}
@@ -1034,13 +1070,13 @@
position: sticky;
inset: 0 auto auto 0;
width: auto;
- height: calc(100svh - 36px);
- padding: 12px 10px;
+ height: calc(100svh - 20px);
+ padding: 8px 6px;
grid-template-columns: 1fr;
align-content: start;
- gap: 10px;
+ gap: 6px;
border: 1px solid var(--border-subtle);
- border-radius: 14px;
+ border-radius: 2px;
background: var(--surface-base);
}
@@ -1055,17 +1091,21 @@
}
.nav-button {
- min-height: 58px;
- padding: 8px 4px;
- border-radius: 12px;
- gap: 4px;
+ min-height: 48px;
+ padding: 7px 4px;
+ border-radius: 2px;
+ gap: 2px;
}
.bottom-bar--shell .nav-button span {
- display: block;
+ display: none;
font-size: 10px;
}
+ .bottom-bar--shell .nav-button--active span {
+ display: block;
+ }
+
.appbar__title span,
.chat-appbar__title span {
display: none;
@@ -1073,21 +1113,22 @@
.appbar,
.chat-appbar {
- min-height: 48px;
+ min-height: 40px;
}
.toolbar-strip {
overflow: visible;
flex-wrap: wrap;
+ gap: 4px;
}
.conversation-list {
- gap: 6px;
+ gap: 4px;
}
.search-results,
.saved-section,
.saved-section__body {
- gap: 6px;
+ gap: 4px;
}
}
diff --git a/src/PhysOn.Web/src/App.tsx b/src/PhysOn.Web/src/App.tsx
index bebbed5..0f60ef5 100644
--- a/src/PhysOn.Web/src/App.tsx
+++ b/src/PhysOn.Web/src/App.tsx
@@ -53,7 +53,7 @@ type IconName =
| 'group'
const DEFAULT_API_BASE_URL = import.meta.env.VITE_API_BASE_URL?.trim() ?? ''
-const APP_VERSION = 'web-0.1.0-alpha.3'
+const APP_VERSION = 'web-0.1.0-alpha.4'
const CONNECTION_LABEL: Record = {
idle: '준비 중',
@@ -81,6 +81,18 @@ function sortConversations(items: ConversationSummaryDto[]): ConversationSummary
return [...items].sort(compareConversations)
}
+function dedupeConversationsById(items: ConversationSummaryDto[]): ConversationSummaryDto[] {
+ const seen = new Set()
+ return items.filter((item) => {
+ if (seen.has(item.conversationId)) {
+ return false
+ }
+
+ seen.add(item.conversationId)
+ return true
+ })
+}
+
function upsertConversation(
items: ConversationSummaryDto[],
nextConversation: ConversationSummaryDto,
@@ -415,10 +427,6 @@ function App() {
messages: messageMatches.slice(0, 8),
}
}, [conversations, messagesByConversation, normalizedSearchQuery])
- const savedConversations = useMemo(
- () => conversations.filter((conversation) => conversation.isPinned || conversation.unreadCount > 0),
- [conversations],
- )
const replyNeededConversations = useMemo(
() => conversations.filter((conversation) => conversation.unreadCount > 0).slice(0, 4),
[conversations],
@@ -431,6 +439,24 @@ function App() {
() => conversations.slice(0, 4),
[conversations],
)
+ const savedReplyQueue = replyNeededConversations
+ const savedPinnedQueue = useMemo(
+ () => dedupeConversationsById(pinnedConversations.filter((conversation) => !replyNeededConversations.some((item) => item.conversationId === conversation.conversationId))),
+ [pinnedConversations, replyNeededConversations],
+ )
+ const savedRecentQueue = useMemo(
+ () => dedupeConversationsById(
+ recentConversations.filter((conversation) =>
+ !replyNeededConversations.some((item) => item.conversationId === conversation.conversationId) &&
+ !pinnedConversations.some((item) => item.conversationId === conversation.conversationId),
+ ),
+ ),
+ [pinnedConversations, recentConversations, replyNeededConversations],
+ )
+ const savedConversations = useMemo(
+ () => dedupeConversationsById([...savedReplyQueue, ...savedPinnedQueue, ...savedRecentQueue]),
+ [savedPinnedQueue, savedRecentQueue, savedReplyQueue],
+ )
const searchResultTotal = searchResults.conversations.length + searchResults.messages.length
const primaryResumeConversation = selectedConversation ?? conversations[0] ?? null
@@ -958,11 +984,11 @@ function App() {
const destinationMeta: Record = {
inbox: {
title: '받은함',
- subtitle: me?.displayName ? `${me.displayName}` : '최근 대화',
+ subtitle: me?.displayName ? `${me.displayName}` : '최근',
},
search: {
title: '검색',
- subtitle: '대화 다시 찾기',
+ subtitle: '다시 찾기',
},
saved: {
title: '보관',
@@ -970,7 +996,7 @@ function App() {
},
me: {
title: '내 공간',
- subtitle: '세션과 기기',
+ subtitle: '세션',
},
}
const activeDestinationMeta = destinationMeta[bottomDestination]
@@ -1038,41 +1064,19 @@ function App() {
KoTalk
- 가볍게 이어지는 대화
WEB
-
- KO · TALK
-
- 열면
-
- 바로 대화.
-
- 표시 이름과 참여 키만 넣고 바로 시작합니다.
-
-
- 빠른 시작
- 작업 대화
- 기기 기억
-
-
-
-
- Join
-
시작
-
-
-
- 세션은 이 기기에만
- 서버는 고급에서만
-
-
{statusMessage ? {statusMessage}
: null}
@@ -1156,7 +1155,7 @@ function App() {
-
+
@@ -1190,32 +1189,6 @@ function App() {
{bottomDestination === 'inbox' ? (
<>
-
-
-
- {CONNECTION_DESCRIPTION[connectionState]}
-
- {formatRelativeConnection(storedSession?.savedAt ?? null)}
-
-
- openDestination('search')}
- >
-
-
- openDestination('me')}
- >
-
-
-
-
{pinnedTotal}
+
+ openDestination('search')}
+ >
+
+
+
+
+
+
@@ -1290,7 +1281,7 @@ function App() {
{
if (primaryResumeConversation) {
selectConversation(primaryResumeConversation.conversationId, 'chat')
@@ -1326,13 +1317,41 @@ function App() {
{!normalizedSearchQuery ? (
-
대화와 메시지를 다시 찾아보세요.
+
+ {recentConversations.length > 0 ? (
+
+
+ 최근
+ {recentConversations.length}개
+
+ {renderConversationRows(recentConversations)}
+
+ ) : null}
+ {replyNeededConversations.length > 0 ? (
+
+
+ 안읽음
+ {replyNeededConversations.length}개
+
+ {renderConversationRows(replyNeededConversations)}
+
+ ) : null}
+ {pinnedConversations.length > 0 ? (
+
+
+ 고정
+ {pinnedConversations.length}개
+
+ {renderConversationRows(pinnedConversations)}
+
+ ) : null}
+
) : null}
{normalizedSearchQuery && searchResultTotal === 0 ? (
결과가 없습니다. 다른 단어로 다시 찾아보세요.
) : null}
{normalizedSearchQuery && searchResultTotal > 0 ? (
-
+
{searchResults.messages.length > 0 ? (
@@ -1365,42 +1384,42 @@ function App() {
{bottomDestination === 'saved' ? (
<>
- {replyNeededConversations.length}
- {pinnedConversations.length}
+ {savedReplyQueue.length}
+ {savedPinnedQueue.length}
-
+
{savedConversations.length === 0 ? (
지금 보관된 후속 작업이 없습니다.
) : null}
- {replyNeededConversations.length > 0 ? (
+ {savedReplyQueue.length > 0 ? (
답장
- {replyNeededConversations.length}개
+ {savedReplyQueue.length}개
- {renderConversationRows(replyNeededConversations)}
+ {renderConversationRows(savedReplyQueue)}
) : null}
- {pinnedConversations.length > 0 ? (
+ {savedPinnedQueue.length > 0 ? (
- 고정
- {pinnedConversations.length}개
+ 중요
+ {savedPinnedQueue.length}개
- {renderConversationRows(pinnedConversations)}
+ {renderConversationRows(savedPinnedQueue)}
) : null}
- {recentConversations.length > 0 ? (
+ {savedRecentQueue.length > 0 ? (
최근
- {recentConversations.length}개
+ {savedRecentQueue.length}개
- {renderConversationRows(recentConversations)}
+ {renderConversationRows(savedRecentQueue)}
) : null}
@@ -1481,8 +1500,7 @@ function App() {
{loadingConversationId !== selectedConversation.conversationId && selectedMessages.length === 0 ? (
-
{selectedConversation.memberCount <= 1 ? '첫 메모부터 시작' : '첫 메시지부터 시작'}
-
짧게 한 줄만 남겨도 대화가 바로 이어집니다.
+
{selectedConversation.memberCount <= 1 ? '첫 메모' : '첫 메시지'}
applyQuickDraft('확인 후 답드릴게요.')}>
- 확인 후 회신
+ 확인
applyQuickDraft('자료 공유드립니다.\n- ')}>
- 자료 공유
+ 공유
applyQuickDraft('잠시 뒤 다시 말씀드릴게요.')}>
- 잠시 후
+ 나중