공개: alpha.4 기준선 갱신

This commit is contained in:
Ian 2026-04-16 11:14:22 +09:00
commit b63832706b
37 changed files with 1839 additions and 822 deletions

View file

@ -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

View file

@ -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 \

View file

@ -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를 대중용 첫인상 기준으로 다시 구성하고 다운로드는 공식 미러와 저장소 릴리즈를 함께 표기
- 보안/신뢰 문서에서 운영 힌트와 공유 접근값 노출을 줄이고 공개 범위를 재정의

View file

@ -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

View file

@ -18,7 +18,7 @@
<a href="문서/README.md"><img alt="docs" src="https://img.shields.io/badge/docs-master%20plan%20%2B%20atlas-111827"></a>
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-Apache--2.0-1F2937"></a>
<a href="https://vstalk.phy.kr"><img alt="web" src="https://img.shields.io/badge/live-vstalk.phy.kr-0F766E"></a>
<a href="https://download-vstalk.phy.kr"><img alt="download" src="https://img.shields.io/badge/download-mirror%20reserved-F59E0B"></a>
<a href="https://download-vstalk.phy.kr"><img alt="download" src="https://img.shields.io/badge/download-mirror%20live-166534"></a>
<a href="PROJECT_STATUS.md"><img alt="verified" src="https://img.shields.io/badge/verified-2026--04--16-6B7280"></a>
</p>
@ -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

View file

@ -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)

View file

@ -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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Before After
Before After

View file

@ -6,7 +6,7 @@
- 같은 버전 번호 아래에 Windows와 Android 산출물을 병렬로 보관합니다.
- `latest/`는 최신 포인터, `releases/<version>/`는 불변 이력으로 구분합니다.
- 원격 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/<version>/windows/x64/...`
- 버전별 Android: `https://download-vs-messanger.phy.kr/releases/<version>/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/<version>/windows/x64/...`
- 버전별 Android: `https://download-vstalk.phy.kr/releases/<version>/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/<version>`에 배포합니다.

View file

@ -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" "$@"

View file

@ -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"

5
scripts/create-release-tag.sh Executable file
View file

@ -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" "$@"

View file

@ -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" "$@"

View file

@ -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" "$@"

View file

@ -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 <git-ref> Target ref or commit. Default: public/main
--message <text> Annotated tag message
--remote <name> 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"

View file

@ -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"

View file

@ -7,17 +7,23 @@ Usage:
./scripts/release/release-publish-forge.sh --version v0.2.0-alpha.1 [options]
Options:
--remote <name> Git remote name. Default: public-stage
--base-url <url> Forge base URL. Example: https://forge.example.com
--repo <owner/name> Repository in owner/name form
--token <token> Gitea API token
--target-commitish <ref> Branch or commit to associate when creating the tag
--notes <path> 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

View file

@ -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 <name> Git remote name. Default: github-public
--repo <owner/name> GitHub repository in owner/name form
--token <token> GitHub token
--target-commitish <ref> Branch or commit to associate when creating the tag
--notes <path> 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."

View file

@ -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 <name> Source branch to publish. Default: public/main
--target-branch <name> Target branch name on public remotes. Default: main
--stage-remote <name> Stage remote. Default: public-stage
--github-remote <name> GitHub remote. Default: github-public
--notes <path> 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

View file

@ -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();
}
}
}

View file

@ -3,4 +3,5 @@ namespace PhysOn.Desktop.Models;
public sealed record DesktopWorkspaceLayout(
bool IsCompactDensity,
bool IsInspectorVisible,
bool IsConversationPaneCollapsed);
bool IsConversationPaneCollapsed,
double ConversationPaneWidth = 348);

View file

@ -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)

View file

@ -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()

View file

@ -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}">
<Window.Styles>
<Style Selector="Border.surface">
<Setter Property="CornerRadius" Value="10" />
<Setter Property="CornerRadius" Value="2" />
<Setter Property="Background" Value="#FFFFFF" />
<Setter Property="BorderBrush" Value="#E6E8EB" />
<Setter Property="BorderBrush" Value="#E5E7EB" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Border.soft">
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Background" Value="#F7F8F9" />
<Setter Property="BorderBrush" Value="#ECEEF1" />
<Style Selector="Border.muted">
<Setter Property="CornerRadius" Value="2" />
<Setter Property="Background" Value="#F7F8FA" />
<Setter Property="BorderBrush" Value="#E8EAEE" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Button.icon">
<Setter Property="CornerRadius" Value="9" />
<Setter Property="Padding" Value="9,7" />
<Setter Property="Background" Value="#FFFFFF" />
<Setter Property="BorderBrush" Value="#D9DDE2" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Button.primary">
<Setter Property="CornerRadius" Value="9" />
<Setter Property="Padding" Value="12,9" />
<Setter Property="Background" Value="#111418" />
<Setter Property="Foreground" Value="#FFFFFF" />
</Style>
<Style Selector="TextBlock.caption">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="#6A7380" />
</Style>
<Style Selector="TextBlock.body">
<Setter Property="FontSize" Value="13" />
<Setter Property="Foreground" Value="#151A20" />
</Style>
<Style Selector="Border.bubble">
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="8,7" />
<Setter Property="Margin" Value="0,0,0,5" />
<Setter Property="Background" Value="#F6F7F8" />
<Setter Property="BorderBrush" Value="#EAEDF0" />
<Setter Property="CornerRadius" Value="2" />
<Setter Property="Padding" Value="9,7" />
<Setter Property="Margin" Value="0,0,0,6" />
<Setter Property="Background" Value="#F7F8FA" />
<Setter Property="BorderBrush" Value="#E4E7EB" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="HorizontalAlignment" Value="Left" />
</Style>
<Style Selector="Border.bubble.mine">
<Setter Property="Background" Value="#ECEFF3" />
<Setter Property="Background" Value="#EEF1F4" />
<Setter Property="HorizontalAlignment" Value="Right" />
</Style>
<Style Selector="Button.icon">
<Setter Property="CornerRadius" Value="2" />
<Setter Property="Padding" Value="8,6" />
<Setter Property="Background" Value="#F7F8FA" />
<Setter Property="BorderBrush" Value="#D9DDE2" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Button.primary">
<Setter Property="CornerRadius" Value="2" />
<Setter Property="Padding" Value="11,8" />
<Setter Property="Background" Value="#111418" />
<Setter Property="Foreground" Value="#FFFFFF" />
</Style>
<Style Selector="TextBlock.caption">
<Setter Property="FontSize" Value="11.5" />
<Setter Property="Foreground" Value="#69727D" />
</Style>
<Style Selector="TextBlock.body">
<Setter Property="FontSize" Value="13" />
<Setter Property="Foreground" Value="#111418" />
</Style>
</Window.Styles>
<Grid Margin="16" RowDefinitions="Auto,Auto,*,Auto" RowSpacing="10">
<Border Classes="surface" Padding="12">
<Grid ColumnDefinitions="42,*,Auto" ColumnSpacing="12">
<Border Width="42" Height="42" Classes="soft">
<Grid Margin="10" RowDefinitions="Auto,Auto,*,Auto" RowSpacing="8">
<Border Classes="surface" Padding="10">
<Grid ColumnDefinitions="40,*,Auto,Auto" ColumnSpacing="10">
<Border Width="40" Height="40" Classes="muted">
<TextBlock HorizontalAlignment="Center"
VerticalAlignment="Center"
Text="{Binding ConversationGlyph}"
FontWeight="SemiBold"
Foreground="#151A20" />
Foreground="#111418" />
</Border>
<StackPanel Grid.Column="1" Spacing="2">
<TextBlock Text="{Binding ConversationTitle}"
FontSize="15"
FontSize="14.5"
FontWeight="SemiBold"
Foreground="#151A20"
Foreground="#111418"
TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding ConversationSubtitle}"
Classes="caption"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
<Border Classes="soft" Padding="8,5">
<TextBlock Text="{Binding StatusText}" Classes="caption" />
</Border>
<Button Classes="icon"
Command="{Binding ReloadCommand}"
Content="↻" />
</StackPanel>
<Border Grid.Column="2" Classes="muted" Padding="8,4">
<TextBlock Text="{Binding StatusText}" Classes="caption" />
</Border>
<Button Grid.Column="3"
Classes="icon"
Command="{Binding ReloadCommand}"
Content="↻" />
</Grid>
</Border>
<Border Grid.Row="1"
Classes="surface"
Padding="10"
Padding="9"
IsVisible="{Binding HasErrorText}">
<TextBlock Text="{Binding ErrorText}"
Foreground="#C62828"
@ -99,8 +102,8 @@
TextWrapping="Wrap" />
</Border>
<Border Grid.Row="2" Classes="surface" Padding="12">
<ScrollViewer Name="MessagesScrollViewer">
<Border Grid.Row="2" Classes="surface" Padding="8">
<ScrollViewer Name="MessagesScrollViewer" MaxWidth="360" HorizontalAlignment="Center">
<ItemsControl ItemsSource="{Binding Messages}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MessageRowViewModel">
@ -123,17 +126,18 @@
</ScrollViewer>
</Border>
<Border Grid.Row="3" Classes="surface" Padding="10">
<Border Grid.Row="3" Classes="muted" Padding="8" MaxWidth="360" HorizontalAlignment="Center">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
<TextBox Name="ComposerTextBox"
PlaceholderText="메시지"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="46"
MinHeight="44"
Text="{Binding ComposerText}"
KeyDown="ComposerTextBox_OnKeyDown" />
<Button Grid.Column="1"
Classes="primary"
MinWidth="72"
Command="{Binding SendMessageCommand}"
Content="보내기" />
</Grid>

File diff suppressed because it is too large Load diff

View file

@ -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)

View file

@ -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"

View file

@ -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",

View file

@ -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;
}
}

View file

@ -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<ConnectionState, string> = {
idle: '준비 중',
@ -81,6 +81,18 @@ function sortConversations(items: ConversationSummaryDto[]): ConversationSummary
return [...items].sort(compareConversations)
}
function dedupeConversationsById(items: ConversationSummaryDto[]): ConversationSummaryDto[] {
const seen = new Set<string>()
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<BottomDestination, { title: string; subtitle: string }> = {
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() {
</span>
<div className="brand-lockup__text">
<strong>KoTalk</strong>
<span> </span>
</div>
</div>
<span className="surface-badge">WEB</span>
</header>
<section className="onboarding__hero">
<p className="eyebrow">KO · TALK</p>
<h1>
<br />
.
</h1>
<p className="onboarding__summary"> .</p>
<div className="summary-strip summary-strip--hero" aria-label="핵심 특성">
<span className="summary-chip"><Icon name="spark" /> </span>
<span className="summary-chip"><Icon name="chat" /> </span>
<span className="summary-chip"><Icon name="session" /> </span>
</div>
</section>
<section className="onboarding__panel">
<div className="panel__heading">
<span className="panel__label">Join</span>
<h2></h2>
</div>
<form className="onboarding__form" onSubmit={handleRegister}>
<label className="field">
<span> </span>
<input
aria-label="표시 이름"
autoComplete="nickname"
maxLength={20}
placeholder="이름"
placeholder="표시 이름"
required
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
@ -1080,10 +1084,10 @@ function App() {
</label>
<label className="field">
<span> </span>
<input
aria-label="참여 키"
autoCapitalize="characters"
placeholder="참여 키 입력"
placeholder="참여 키"
required
value={inviteCode}
onChange={(event) => setInviteCode(event.target.value.toUpperCase())}
@ -1096,10 +1100,10 @@ function App() {
{showAdvanced ? (
<label className="field">
<span> </span>
<input
aria-label="서버 주소"
inputMode="url"
placeholder="기본 서버를 바꾸는 경우만 입력"
placeholder="서버 주소"
value={apiBaseUrl}
onChange={(event) => setApiBaseUrl(event.target.value)}
/>
@ -1107,15 +1111,10 @@ function App() {
) : null}
<button className="primary-button" disabled={registering} type="submit">
{registering ? '입장 중...' : '대화 열기'}
{registering ? '입장 중...' : '열기'}
</button>
</form>
<div className="panel__foot">
<span> </span>
<span> </span>
</div>
{statusMessage ? <p className="status-text">{statusMessage}</p> : null}
</section>
</main>
@ -1156,7 +1155,7 @@ function App() {
</button>
</aside>
<section className={`pane pane--list ${mobileView === 'chat' ? 'pane--hidden' : ''}`}>
<section className={`pane pane--list pane--${bottomDestination} ${mobileView === 'chat' ? 'pane--hidden' : ''}`}>
<header className="appbar">
<div className="appbar__leading">
<span className="brand-mark brand-mark--small" aria-hidden="true">
@ -1190,32 +1189,6 @@ function App() {
{bottomDestination === 'inbox' ? (
<>
<div className="toolbar-strip" aria-label="받은함 빠른 막대">
<div className="toolbar-strip__group">
<span className={`status-chip status-chip--${connectionState}`}>
<span className="status-dot" aria-hidden="true" />
{CONNECTION_DESCRIPTION[connectionState]}
</span>
<span className="status-panel__time">{formatRelativeConnection(storedSession?.savedAt ?? null)}</span>
</div>
<div className="toolbar-strip__group toolbar-strip__group--actions">
<button
className="icon-button icon-button--soft"
type="button"
aria-label="검색 열기"
onClick={() => openDestination('search')}
>
<Icon name="search" />
</button>
<button
className="icon-button icon-button--soft"
type="button"
aria-label="내 공간 열기"
onClick={() => openDestination('me')}
>
<Icon name="me" />
</button>
</div>
<button
className={`summary-chip ${listFilter === 'all' ? 'summary-chip--active' : ''}`}
type="button"
@ -1237,6 +1210,24 @@ function App() {
>
<Icon name="pin" /> {pinnedTotal}
</button>
<div className="toolbar-strip__group toolbar-strip__group--actions">
<button
className="icon-button icon-button--soft"
type="button"
aria-label="검색"
onClick={() => openDestination('search')}
>
<Icon name="search" />
</button>
<button
className="icon-button icon-button--soft"
type="button"
aria-label="새로고침"
onClick={handleReconnect}
>
<Icon name="refresh" />
</button>
</div>
</div>
<div className="conversation-list">
@ -1290,7 +1281,7 @@ function App() {
<button
className="icon-button icon-button--soft"
type="button"
aria-label="최근 대화 열기"
aria-label="최근 대화"
onClick={() => {
if (primaryResumeConversation) {
selectConversation(primaryResumeConversation.conversationId, 'chat')
@ -1326,13 +1317,41 @@ function App() {
<div className="conversation-list">
{!normalizedSearchQuery ? (
<p className="empty-state empty-state--inline"> .</p>
<div className="search-results search-results--discovery">
{recentConversations.length > 0 ? (
<section className="saved-section">
<div className="saved-section__header">
<strong></strong>
<span>{recentConversations.length}</span>
</div>
<div className="saved-section__body">{renderConversationRows(recentConversations)}</div>
</section>
) : null}
{replyNeededConversations.length > 0 ? (
<section className="saved-section">
<div className="saved-section__header">
<strong></strong>
<span>{replyNeededConversations.length}</span>
</div>
<div className="saved-section__body">{renderConversationRows(replyNeededConversations)}</div>
</section>
) : null}
{pinnedConversations.length > 0 ? (
<section className="saved-section">
<div className="saved-section__header">
<strong></strong>
<span>{pinnedConversations.length}</span>
</div>
<div className="saved-section__body">{renderConversationRows(pinnedConversations)}</div>
</section>
) : null}
</div>
) : null}
{normalizedSearchQuery && searchResultTotal === 0 ? (
<p className="empty-state empty-state--inline"> . .</p>
) : null}
{normalizedSearchQuery && searchResultTotal > 0 ? (
<div className="search-results">
<div className="search-results search-results--matches">
{searchResults.messages.length > 0 ? (
<section className="saved-section">
<div className="saved-section__header">
@ -1365,42 +1384,42 @@ function App() {
{bottomDestination === 'saved' ? (
<>
<div className="toolbar-strip toolbar-strip--utility" aria-label="보관함 요약">
<span className="status-chip"><Icon name="spark" /> {replyNeededConversations.length}</span>
<span className="status-chip"><Icon name="pin" /> {pinnedConversations.length}</span>
<span className="status-chip"><Icon name="spark" /> {savedReplyQueue.length}</span>
<span className="status-chip"><Icon name="pin" /> {savedPinnedQueue.length}</span>
</div>
<div className="conversation-list">
<div className="conversation-list conversation-list--saved">
{savedConversations.length === 0 ? (
<p className="empty-state empty-state--inline"> .</p>
) : null}
{replyNeededConversations.length > 0 ? (
{savedReplyQueue.length > 0 ? (
<section className="saved-section">
<div className="saved-section__header">
<strong></strong>
<span>{replyNeededConversations.length}</span>
<span>{savedReplyQueue.length}</span>
</div>
<div className="saved-section__body">{renderConversationRows(replyNeededConversations)}</div>
<div className="saved-section__body">{renderConversationRows(savedReplyQueue)}</div>
</section>
) : null}
{pinnedConversations.length > 0 ? (
{savedPinnedQueue.length > 0 ? (
<section className="saved-section">
<div className="saved-section__header">
<strong></strong>
<span>{pinnedConversations.length}</span>
<strong></strong>
<span>{savedPinnedQueue.length}</span>
</div>
<div className="saved-section__body">{renderConversationRows(pinnedConversations)}</div>
<div className="saved-section__body">{renderConversationRows(savedPinnedQueue)}</div>
</section>
) : null}
{recentConversations.length > 0 ? (
{savedRecentQueue.length > 0 ? (
<section className="saved-section">
<div className="saved-section__header">
<strong></strong>
<span>{recentConversations.length}</span>
<span>{savedRecentQueue.length}</span>
</div>
<div className="saved-section__body">{renderConversationRows(recentConversations)}</div>
<div className="saved-section__body">{renderConversationRows(savedRecentQueue)}</div>
</section>
) : null}
</div>
@ -1481,8 +1500,7 @@ function App() {
{loadingConversationId !== selectedConversation.conversationId && selectedMessages.length === 0 ? (
<div className="empty-panel empty-panel--chat">
<strong>{selectedConversation.memberCount <= 1 ? '첫 메모부터 시작' : '첫 메시지부터 시작'}</strong>
<p> .</p>
<strong>{selectedConversation.memberCount <= 1 ? '첫 메모' : '첫 메시지'}</strong>
<div className="empty-panel__actions">
<button
className="secondary-button"
@ -1527,13 +1545,13 @@ function App() {
<form className="composer" onSubmit={handleSendMessage}>
<div className="composer-shortcuts" aria-label="빠른 입력">
<button className="ghost-button" type="button" onClick={() => applyQuickDraft('확인 후 답드릴게요.')}>
</button>
<button className="ghost-button" type="button" onClick={() => applyQuickDraft('자료 공유드립니다.\n- ')}>
</button>
<button className="ghost-button" type="button" onClick={() => applyQuickDraft('잠시 뒤 다시 말씀드릴게요.')}>
</button>
</div>
<label className="composer__field">