공개: alpha.11 검색과 전환 개선 반영
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 79 KiB |
|
|
@ -242,6 +242,14 @@ async function installSessionMocks(page) {
|
|||
|
||||
window.localStorage.setItem('vs-talk.session', JSON.stringify(session))
|
||||
window.localStorage.setItem('vs-talk.invite-code', 'ALPHA')
|
||||
window.localStorage.setItem('vs-talk.recent-conversations', JSON.stringify(['conv-team', 'conv-friends']))
|
||||
window.localStorage.setItem(
|
||||
'vs-talk.follow-up',
|
||||
JSON.stringify({
|
||||
'conv-team': 'today',
|
||||
'conv-friends': 'later',
|
||||
}),
|
||||
)
|
||||
window.WebSocket = FakeWebSocket
|
||||
}, storedSession)
|
||||
|
||||
|
|
@ -355,6 +363,8 @@ async function captureSearch(browser) {
|
|||
await page.waitForSelector('.bottom-bar')
|
||||
await page.click('.bottom-bar .nav-button:nth-child(2)')
|
||||
await page.waitForSelector('.search-field')
|
||||
await page.type('.search-field input', '공유안')
|
||||
await page.waitForSelector('.search-result')
|
||||
const app = await page.waitForSelector('.shell')
|
||||
await app.screenshot({
|
||||
path: path.join(outputDir, 'vstalk-web-search.png'),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ set -euo pipefail
|
|||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/release/build-android-apk.sh --version 2026.04.16-alpha.6 [options]
|
||||
./scripts/release/build-android-apk.sh --version 2026.04.16-alpha.11 [options]
|
||||
|
||||
Options:
|
||||
--configuration <name> Build configuration. Default: Release
|
||||
|
|
@ -103,7 +103,11 @@ fi
|
|||
-p:JavaSdkDirectory="${JAVA_HOME:-}" \
|
||||
-o "$publish_dir"
|
||||
|
||||
apk_source="$(find "$publish_dir" -type f -name '*.apk' | head -n 1)"
|
||||
apk_source="$(find "$publish_dir" -type f -name '*-Signed.apk' | sort | head -n 1)"
|
||||
if [[ -z "$apk_source" ]]; then
|
||||
apk_source="$(find "$publish_dir" -type f -name '*.apk' | sort | head -n 1)"
|
||||
fi
|
||||
|
||||
if [[ -z "$apk_source" ]]; then
|
||||
echo "Android publish did not produce an APK." >&2
|
||||
exit 1
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ set -euo pipefail
|
|||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/release/build-windows-distributions.sh --version 2026.04.16-alpha.6 [options]
|
||||
./scripts/release/build-windows-distributions.sh --version 2026.04.16-alpha.11 [options]
|
||||
|
||||
Options:
|
||||
--configuration <name> Build configuration. Default: Release
|
||||
|
|
|
|||
|
|
@ -4,4 +4,7 @@ public sealed record DesktopWorkspaceLayout(
|
|||
bool IsCompactDensity,
|
||||
bool IsInspectorVisible,
|
||||
bool IsConversationPaneCollapsed,
|
||||
double ConversationPaneWidth = 348);
|
||||
double ConversationPaneWidth = 304,
|
||||
double? WindowWidth = null,
|
||||
double? WindowHeight = null,
|
||||
bool IsWindowMaximized = false);
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@
|
|||
<Product>KoTalk</Product>
|
||||
<Description>한국어 중심의 차분한 메시징 경험을 다시 설계하는 Windows-first 메신저</Description>
|
||||
<AssemblyTitle>KoTalk</AssemblyTitle>
|
||||
<AssemblyVersion>0.1.0.6</AssemblyVersion>
|
||||
<FileVersion>0.1.0.6</FileVersion>
|
||||
<Version>0.1.0-alpha.6</Version>
|
||||
<InformationalVersion>0.1.0-alpha.6</InformationalVersion>
|
||||
<AssemblyVersion>0.1.0.11</AssemblyVersion>
|
||||
<FileVersion>0.1.0.11</FileVersion>
|
||||
<Version>0.1.0-alpha.11</Version>
|
||||
<InformationalVersion>0.1.0-alpha.11</InformationalVersion>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -15,14 +15,28 @@ namespace PhysOn.Desktop.ViewModels;
|
|||
public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||
{
|
||||
private const string DefaultApiBaseUrl = "https://vstalk.phy.kr";
|
||||
private const string DefaultPublicAlphaKey = "ALPHA-OPEN-2026";
|
||||
private const double DefaultWorkspaceWindowWidth = 1180;
|
||||
private const double DefaultWorkspaceWindowHeight = 760;
|
||||
private const double DefaultOnboardingWindowWidth = 420;
|
||||
private const double DefaultOnboardingWindowHeight = 508;
|
||||
private const double MinWorkspaceWindowWidth = 840;
|
||||
private const double MinWorkspaceWindowHeight = 560;
|
||||
private const double MinOnboardingWindowWidth = 404;
|
||||
private const double MinOnboardingWindowHeight = 468;
|
||||
private const double DefaultConversationPaneWidth = 304;
|
||||
private const double MinConversationPaneWidth = 248;
|
||||
private const double MaxConversationPaneWidth = 360;
|
||||
private readonly PhysOnApiClient _apiClient = new();
|
||||
private readonly SessionStore _sessionStore = new();
|
||||
private readonly PhysOnRealtimeClient _realtimeClient = new();
|
||||
private readonly WorkspaceLayoutStore _workspaceLayoutStore;
|
||||
private readonly IConversationWindowManager _conversationWindowManager;
|
||||
private readonly Dictionary<string, List<MessageRowViewModel>> _messageCache = new(StringComparer.Ordinal);
|
||||
|
||||
private DesktopSession? _session;
|
||||
private string? _currentUserId;
|
||||
private bool _isSampleWorkspace;
|
||||
|
||||
public MainWindowViewModel()
|
||||
: this(new ConversationWindowManager(), new WorkspaceLayoutStore())
|
||||
|
|
@ -116,8 +130,12 @@ 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 bool isConversationLoading;
|
||||
[ObservableProperty] private double conversationPaneWidthValue = DefaultConversationPaneWidth;
|
||||
[ObservableProperty] private int detachedWindowCount;
|
||||
[ObservableProperty] private double? windowWidth;
|
||||
[ObservableProperty] private double? windowHeight;
|
||||
[ObservableProperty] private bool isWindowMaximized;
|
||||
|
||||
public bool ShowOnboarding => !IsAuthenticated;
|
||||
public bool ShowShell => IsAuthenticated;
|
||||
|
|
@ -128,7 +146,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
public int UnreadConversationCount => Conversations.Count(item => item.UnreadCount > 0);
|
||||
public int PinnedConversationCount => Conversations.Count(item => item.IsPinned);
|
||||
public bool ShowConversationEmptyState => !HasFilteredConversations;
|
||||
public string AdvancedSettingsButtonText => ShowAdvancedSettings ? "기본" : "고급";
|
||||
public string AdvancedSettingsButtonText => ShowAdvancedSettings ? "옵션 닫기" : "옵션";
|
||||
public string CurrentUserMonogram =>
|
||||
string.IsNullOrWhiteSpace(CurrentUserDisplayName) ? "KO" : CurrentUserDisplayName.Trim()[..Math.Min(2, CurrentUserDisplayName.Trim().Length)];
|
||||
public string AllFilterButtonText => "◎";
|
||||
|
|
@ -162,12 +180,14 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
public bool IsConversationPaneExpanded => !IsConversationPaneCollapsed;
|
||||
public double ConversationPaneWidth => IsConversationPaneCollapsed ? 0 : ConversationPaneWidthValue;
|
||||
public double InspectorPaneWidth => IsInspectorVisible ? (IsCompactDensity ? 92 : 108) : 0;
|
||||
public bool HasPersistedWindowBounds => WindowWidth is > 0 && WindowHeight is > 0;
|
||||
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 bool HasConversationSearchText => !string.IsNullOrWhiteSpace(ConversationSearchText);
|
||||
public string InspectorStatusText => HasDetachedWindows
|
||||
? $"{RealtimeStatusGlyph} {DetachedWindowBadgeText}"
|
||||
: RealtimeStatusGlyph;
|
||||
|
|
@ -175,12 +195,32 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
public string StatusSummaryText => string.IsNullOrWhiteSpace(StatusLine) ? RealtimeStatusText : StatusLine;
|
||||
public string ComposerPlaceholderText => HasSelectedConversation ? "메시지" : "대화 선택";
|
||||
public string ComposerActionText => Messages.Count == 0 ? "시작" : "보내기";
|
||||
public bool ShowMessageEmptyState => Messages.Count == 0;
|
||||
public bool ShowMessageEmptyState => Messages.Count == 0 && !IsConversationLoading;
|
||||
public bool ShowConversationLoadingState => HasSelectedConversation && IsConversationLoading;
|
||||
public string MessageEmptyStateTitle => HasSelectedConversation ? "첫 메시지" : "대화 선택";
|
||||
public string MessageEmptyStateText => HasSelectedConversation
|
||||
? "짧게 남기세요."
|
||||
: "목록에서 선택";
|
||||
|
||||
public (double Width, double Height, bool IsMaximized) GetSuggestedWindowLayout()
|
||||
{
|
||||
if (HasPersistedWindowBounds)
|
||||
{
|
||||
return (WindowWidth!.Value, WindowHeight!.Value, IsWindowMaximized);
|
||||
}
|
||||
|
||||
return ShowOnboarding
|
||||
? (DefaultOnboardingWindowWidth, DefaultOnboardingWindowHeight, false)
|
||||
: (DefaultWorkspaceWindowWidth, DefaultWorkspaceWindowHeight, false);
|
||||
}
|
||||
|
||||
public (double MinWidth, double MinHeight) GetSuggestedWindowConstraints()
|
||||
{
|
||||
return ShowOnboarding
|
||||
? (MinOnboardingWindowWidth, MinOnboardingWindowHeight)
|
||||
: (MinWorkspaceWindowWidth, MinWorkspaceWindowHeight);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
if (string.Equals(Environment.GetEnvironmentVariable("KOTALK_DESKTOP_SAMPLE_MODE"), "1", StringComparison.Ordinal))
|
||||
|
|
@ -222,6 +262,37 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
}
|
||||
}
|
||||
|
||||
public void ClearSearch()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ConversationSearchText))
|
||||
{
|
||||
ConversationSearchText = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ActivateSearchResultAsync(bool detach)
|
||||
{
|
||||
if (!HasConversationSearchText)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var target = FilteredConversations.FirstOrDefault();
|
||||
if (target is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (detach)
|
||||
{
|
||||
await DetachConversationRowAsync(target);
|
||||
return;
|
||||
}
|
||||
|
||||
SelectedConversation = target;
|
||||
ClearSearch();
|
||||
}
|
||||
|
||||
partial void OnIsAuthenticatedChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowOnboarding));
|
||||
|
|
@ -236,7 +307,11 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
partial void OnDisplayNameChanged(string value) => SignInCommand.NotifyCanExecuteChanged();
|
||||
partial void OnInviteCodeChanged(string value) => SignInCommand.NotifyCanExecuteChanged();
|
||||
partial void OnShowAdvancedSettingsChanged(bool value) => OnPropertyChanged(nameof(AdvancedSettingsButtonText));
|
||||
partial void OnConversationSearchTextChanged(string value) => RefreshConversationFilter();
|
||||
partial void OnConversationSearchTextChanged(string value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasConversationSearchText));
|
||||
RefreshConversationFilter();
|
||||
}
|
||||
partial void OnSelectedListFilterChanged(string value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsAllFilterSelected));
|
||||
|
|
@ -299,6 +374,11 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
OnPropertyChanged(nameof(InspectorStatusText));
|
||||
OnPropertyChanged(nameof(WorkspaceModeText));
|
||||
}
|
||||
partial void OnIsConversationLoadingChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowConversationLoadingState));
|
||||
NotifyMessageStateChanged();
|
||||
}
|
||||
|
||||
partial void OnSelectedConversationChanged(ConversationRowViewModel? value)
|
||||
{
|
||||
|
|
@ -324,12 +404,12 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
var apiBaseUrl = ResolveApiBaseUrl();
|
||||
var request = new RegisterAlphaQuickRequest(
|
||||
DisplayName.Trim(),
|
||||
InviteCode.Trim(),
|
||||
InviteCode.Trim() is { Length: > 0 } inviteCode ? inviteCode : DefaultPublicAlphaKey,
|
||||
new DeviceRegistrationDto(
|
||||
$"desktop-{Environment.MachineName.ToLowerInvariant()}",
|
||||
"windows",
|
||||
Environment.MachineName,
|
||||
"0.1.0-alpha.6"));
|
||||
"0.1.0-alpha.11"));
|
||||
|
||||
var response = await _apiClient.RegisterAlphaQuickAsync(apiBaseUrl, request, CancellationToken.None);
|
||||
ApiBaseUrl = apiBaseUrl;
|
||||
|
|
@ -371,7 +451,10 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
Conversations.Clear();
|
||||
FilteredConversations.Clear();
|
||||
Messages.Clear();
|
||||
_messageCache.Clear();
|
||||
IsAuthenticated = false;
|
||||
IsConversationLoading = false;
|
||||
_isSampleWorkspace = false;
|
||||
CurrentUserDisplayName = "KO";
|
||||
StatusLine = string.Empty;
|
||||
RealtimeState = RealtimeConnectionState.Idle;
|
||||
|
|
@ -390,26 +473,48 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
{
|
||||
if (!IsAuthenticated || value is null || _session is null)
|
||||
{
|
||||
IsConversationLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var conversationId = value.ConversationId;
|
||||
if (_isSampleWorkspace)
|
||||
{
|
||||
if (_messageCache.TryGetValue(conversationId, out var sampleItems))
|
||||
{
|
||||
ReplaceMessages(sampleItems);
|
||||
}
|
||||
|
||||
IsConversationLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_messageCache.TryGetValue(conversationId, out var cachedItems))
|
||||
{
|
||||
ReplaceMessages(cachedItems);
|
||||
}
|
||||
else
|
||||
{
|
||||
Messages.Clear();
|
||||
NotifyMessageStateChanged();
|
||||
}
|
||||
|
||||
IsConversationLoading = true;
|
||||
|
||||
try
|
||||
{
|
||||
await RunBusyAsync(async () =>
|
||||
{
|
||||
var items = await _apiClient.GetMessagesAsync(_session.ApiBaseUrl, _session.AccessToken, value.ConversationId, CancellationToken.None);
|
||||
var items = await _apiClient.GetMessagesAsync(_session.ApiBaseUrl, _session.AccessToken, conversationId, CancellationToken.None);
|
||||
var mappedItems = items.Items.Select(MapMessage).ToList();
|
||||
|
||||
if (!string.Equals(SelectedConversation?.ConversationId, value.ConversationId, StringComparison.Ordinal))
|
||||
if (!string.Equals(SelectedConversation?.ConversationId, conversationId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Messages.Clear();
|
||||
foreach (var item in items.Items)
|
||||
{
|
||||
Messages.Add(MapMessage(item));
|
||||
}
|
||||
ReplaceMessages(mappedItems);
|
||||
_messageCache[conversationId] = CloneMessages(mappedItems);
|
||||
|
||||
if (Messages.Count > 0)
|
||||
{
|
||||
|
|
@ -419,18 +524,18 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
await _apiClient.UpdateReadCursorAsync(
|
||||
_session.ApiBaseUrl,
|
||||
_session.AccessToken,
|
||||
value.ConversationId,
|
||||
conversationId,
|
||||
new UpdateReadCursorRequest(lastSequence),
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
NotifyConversationMetricsChanged();
|
||||
NotifyMessageStateChanged();
|
||||
RefreshConversationFilter(value.ConversationId);
|
||||
RefreshConversationFilter(conversationId);
|
||||
|
||||
if (_session is not null)
|
||||
{
|
||||
_session = _session with { LastConversationId = value.ConversationId };
|
||||
_session = _session with { LastConversationId = conversationId };
|
||||
if (RememberSession)
|
||||
{
|
||||
await _sessionStore.SaveAsync(_session);
|
||||
|
|
@ -438,6 +543,14 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
}
|
||||
}, clearMessages: false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (string.Equals(SelectedConversation?.ConversationId, conversationId, StringComparison.Ordinal))
|
||||
{
|
||||
IsConversationLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendMessageAsync()
|
||||
{
|
||||
|
|
@ -529,8 +642,12 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
|
||||
private void ApplyBootstrap(BootstrapResponse bootstrap, string displayName, string? preferredConversationId)
|
||||
{
|
||||
_isSampleWorkspace = false;
|
||||
_currentUserId = bootstrap.Me.UserId;
|
||||
CurrentUserDisplayName = displayName;
|
||||
_messageCache.Clear();
|
||||
Messages.Clear();
|
||||
IsConversationLoading = false;
|
||||
Conversations.Clear();
|
||||
|
||||
foreach (var item in bootstrap.Conversations.Items)
|
||||
|
|
@ -568,8 +685,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
|
||||
private bool CanSignIn() =>
|
||||
!IsBusy &&
|
||||
!string.IsNullOrWhiteSpace(DisplayName) &&
|
||||
!string.IsNullOrWhiteSpace(InviteCode);
|
||||
!string.IsNullOrWhiteSpace(DisplayName);
|
||||
|
||||
private bool CanSendMessage() =>
|
||||
!IsBusy &&
|
||||
|
|
@ -612,9 +728,10 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
private void RefreshConversationFilter(string? preferredConversationId = null)
|
||||
{
|
||||
var search = ConversationSearchText.Trim();
|
||||
var hasSearch = !string.IsNullOrWhiteSpace(search);
|
||||
var filtered = Conversations
|
||||
.Where(PassesSelectedFilter)
|
||||
.Where(item => string.IsNullOrWhiteSpace(search) || MatchesConversationSearch(item, search))
|
||||
.Where(item => !hasSearch || MatchesConversationSearch(item, search))
|
||||
.ToList();
|
||||
|
||||
FilteredConversations.Clear();
|
||||
|
|
@ -624,7 +741,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
}
|
||||
|
||||
HasFilteredConversations = filtered.Count > 0;
|
||||
ConversationEmptyStateText = string.IsNullOrWhiteSpace(search)
|
||||
ConversationEmptyStateText = !hasSearch
|
||||
? (SelectedListFilter switch
|
||||
{
|
||||
"unread" => "안읽음 대화가 없습니다.",
|
||||
|
|
@ -640,7 +757,16 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
|
||||
if (target is null)
|
||||
{
|
||||
target = FilteredConversations.FirstOrDefault();
|
||||
target = hasSearch
|
||||
? null
|
||||
: FilteredConversations.FirstOrDefault();
|
||||
}
|
||||
|
||||
if (hasSearch && preferredConversationId is null && target is null)
|
||||
{
|
||||
UpdateSelectedConversationState(SelectedConversation?.ConversationId);
|
||||
NotifyMessageStateChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(SelectedConversation, target))
|
||||
|
|
@ -664,11 +790,19 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
};
|
||||
}
|
||||
|
||||
private static bool MatchesConversationSearch(ConversationRowViewModel item, string search)
|
||||
private bool MatchesConversationSearch(ConversationRowViewModel item, string search)
|
||||
{
|
||||
return item.Title.Contains(search, StringComparison.CurrentCultureIgnoreCase) ||
|
||||
if (item.Title.Contains(search, StringComparison.CurrentCultureIgnoreCase) ||
|
||||
item.LastMessageText.Contains(search, StringComparison.CurrentCultureIgnoreCase) ||
|
||||
item.Subtitle.Contains(search, StringComparison.CurrentCultureIgnoreCase);
|
||||
item.Subtitle.Contains(search, StringComparison.CurrentCultureIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return _messageCache.TryGetValue(item.ConversationId, out var cachedItems) &&
|
||||
cachedItems.Any(message =>
|
||||
message.Text.Contains(search, StringComparison.CurrentCultureIgnoreCase) ||
|
||||
message.SenderName.Contains(search, StringComparison.CurrentCultureIgnoreCase));
|
||||
}
|
||||
|
||||
private void UpdateSelectedConversationState(string? conversationId)
|
||||
|
|
@ -694,13 +828,29 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
var clamped = Math.Clamp(Math.Round(width), 280, 480);
|
||||
var clamped = Math.Clamp(Math.Round(width), MinConversationPaneWidth, MaxConversationPaneWidth);
|
||||
if (Math.Abs(clamped - ConversationPaneWidthValue) > 1)
|
||||
{
|
||||
ConversationPaneWidthValue = clamped;
|
||||
}
|
||||
}
|
||||
|
||||
public void CaptureWindowLayout(double width, double height, bool maximized)
|
||||
{
|
||||
if (width > 0)
|
||||
{
|
||||
WindowWidth = Math.Round(width);
|
||||
}
|
||||
|
||||
if (height > 0)
|
||||
{
|
||||
WindowHeight = Math.Round(height);
|
||||
}
|
||||
|
||||
IsWindowMaximized = maximized;
|
||||
_ = PersistWorkspaceLayoutAsync();
|
||||
}
|
||||
|
||||
private void ApplyQuickDraft(string template)
|
||||
{
|
||||
ComposerText = string.IsNullOrWhiteSpace(ComposerText)
|
||||
|
|
@ -710,6 +860,8 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
|
||||
private void LoadSampleWorkspace()
|
||||
{
|
||||
_isSampleWorkspace = true;
|
||||
_messageCache.Clear();
|
||||
Conversations.Clear();
|
||||
FilteredConversations.Clear();
|
||||
Messages.Clear();
|
||||
|
|
@ -908,6 +1060,8 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
Messages.Add(item);
|
||||
}
|
||||
|
||||
_messageCache["sample-ops"] = CloneMessages(Messages);
|
||||
|
||||
OnPropertyChanged(nameof(CurrentUserMonogram));
|
||||
NotifyMessageStateChanged();
|
||||
}
|
||||
|
|
@ -920,6 +1074,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
private void NotifyMessageStateChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowMessageEmptyState));
|
||||
OnPropertyChanged(nameof(ShowConversationLoadingState));
|
||||
OnPropertyChanged(nameof(MessageEmptyStateTitle));
|
||||
OnPropertyChanged(nameof(MessageEmptyStateText));
|
||||
OnPropertyChanged(nameof(ComposerPlaceholderText));
|
||||
|
|
@ -1107,6 +1262,39 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
{
|
||||
Messages.Add(item);
|
||||
}
|
||||
|
||||
if (SelectedConversation is not null)
|
||||
{
|
||||
_messageCache[SelectedConversation.ConversationId] = CloneMessages(ordered);
|
||||
}
|
||||
}
|
||||
|
||||
private void ReplaceMessages(IEnumerable<MessageRowViewModel> items)
|
||||
{
|
||||
Messages.Clear();
|
||||
foreach (var item in items)
|
||||
{
|
||||
Messages.Add(CloneMessage(item));
|
||||
}
|
||||
}
|
||||
|
||||
private static List<MessageRowViewModel> CloneMessages(IEnumerable<MessageRowViewModel> items) =>
|
||||
items.Select(CloneMessage).ToList();
|
||||
|
||||
private static MessageRowViewModel CloneMessage(MessageRowViewModel item)
|
||||
{
|
||||
return new MessageRowViewModel
|
||||
{
|
||||
MessageId = item.MessageId,
|
||||
ClientMessageId = item.ClientMessageId,
|
||||
Text = item.Text,
|
||||
SenderName = item.SenderName,
|
||||
MetaText = item.MetaText,
|
||||
IsMine = item.IsMine,
|
||||
IsPending = item.IsPending,
|
||||
IsFailed = item.IsFailed,
|
||||
ServerSequence = item.ServerSequence
|
||||
};
|
||||
}
|
||||
|
||||
private void ReorderConversations(string? selectedConversationId)
|
||||
|
|
@ -1130,7 +1318,10 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
IsCompactDensity = true;
|
||||
IsInspectorVisible = false;
|
||||
IsConversationPaneCollapsed = false;
|
||||
ConversationPaneWidthValue = 348;
|
||||
ConversationPaneWidthValue = DefaultConversationPaneWidth;
|
||||
WindowWidth = null;
|
||||
WindowHeight = null;
|
||||
IsWindowMaximized = false;
|
||||
StatusLine = "초기화";
|
||||
}
|
||||
|
||||
|
|
@ -1139,7 +1330,10 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
IsCompactDensity = layout.IsCompactDensity;
|
||||
IsInspectorVisible = layout.IsInspectorVisible;
|
||||
IsConversationPaneCollapsed = layout.IsConversationPaneCollapsed;
|
||||
ConversationPaneWidthValue = Math.Clamp(layout.ConversationPaneWidth, 280, 480);
|
||||
ConversationPaneWidthValue = Math.Clamp(layout.ConversationPaneWidth, MinConversationPaneWidth, MaxConversationPaneWidth);
|
||||
WindowWidth = layout.WindowWidth;
|
||||
WindowHeight = layout.WindowHeight;
|
||||
IsWindowMaximized = layout.IsWindowMaximized;
|
||||
}
|
||||
|
||||
private Task PersistWorkspaceLayoutAsync()
|
||||
|
|
@ -1148,7 +1342,10 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
|||
IsCompactDensity,
|
||||
IsInspectorVisible,
|
||||
IsConversationPaneCollapsed,
|
||||
ConversationPaneWidthValue));
|
||||
ConversationPaneWidthValue,
|
||||
WindowWidth,
|
||||
WindowHeight,
|
||||
IsWindowMaximized));
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
xmlns:vm="using:PhysOn.Desktop.ViewModels"
|
||||
x:Class="PhysOn.Desktop.Views.ConversationWindow"
|
||||
x:DataType="vm:ConversationWindowViewModel"
|
||||
Width="404"
|
||||
Height="748"
|
||||
MinWidth="340"
|
||||
MinHeight="520"
|
||||
Width="560"
|
||||
Height="720"
|
||||
MinWidth="420"
|
||||
MinHeight="500"
|
||||
Background="#F7F3EE"
|
||||
Title="{Binding ConversationTitle}">
|
||||
|
||||
|
|
@ -59,8 +59,8 @@
|
|||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<Grid Margin="10" RowDefinitions="Auto,Auto,*,Auto" RowSpacing="8">
|
||||
<Border Classes="surface" Padding="10">
|
||||
<Grid Margin="6" RowDefinitions="Auto,Auto,*,Auto" RowSpacing="6">
|
||||
<Border Classes="surface" Padding="8">
|
||||
<Grid ColumnDefinitions="40,*,Auto,Auto" ColumnSpacing="10">
|
||||
<Border Width="40" Height="40" Classes="muted">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
|
|
@ -94,7 +94,7 @@
|
|||
|
||||
<Border Grid.Row="1"
|
||||
Classes="surface"
|
||||
Padding="9"
|
||||
Padding="8"
|
||||
IsVisible="{Binding HasErrorText}">
|
||||
<TextBlock Text="{Binding ErrorText}"
|
||||
Foreground="#C9573C"
|
||||
|
|
@ -102,8 +102,8 @@
|
|||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="2" Classes="surface" Padding="8">
|
||||
<ScrollViewer Name="MessagesScrollViewer" MaxWidth="360" HorizontalAlignment="Center">
|
||||
<Border Grid.Row="2" Classes="surface" Padding="6">
|
||||
<ScrollViewer Name="MessagesScrollViewer" HorizontalAlignment="Stretch">
|
||||
<ItemsControl ItemsSource="{Binding Messages}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MessageRowViewModel">
|
||||
|
|
@ -126,7 +126,7 @@
|
|||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="3" Classes="muted" Padding="8" MaxWidth="360" HorizontalAlignment="Center">
|
||||
<Border Grid.Row="3" Classes="muted" Padding="6" HorizontalAlignment="Stretch">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
|
||||
<TextBox Name="ComposerTextBox"
|
||||
PlaceholderText="메시지"
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@
|
|||
x:Name="RootWindow"
|
||||
x:Class="PhysOn.Desktop.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
d:DesignWidth="1440"
|
||||
d:DesignHeight="900"
|
||||
d:DesignWidth="1180"
|
||||
d:DesignHeight="760"
|
||||
Title="KoTalk"
|
||||
Width="1440"
|
||||
Height="900"
|
||||
MinWidth="980"
|
||||
MinHeight="640"
|
||||
Width="1180"
|
||||
Height="760"
|
||||
MinWidth="404"
|
||||
MinHeight="468"
|
||||
Background="#F7F3EE">
|
||||
|
||||
<Design.DataContext>
|
||||
|
|
@ -73,7 +73,7 @@
|
|||
<Setter Property="Background" Value="#FBF7F2" />
|
||||
<Setter Property="BorderBrush" Value="#E6D8CC" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="MaxWidth" Value="560" />
|
||||
<Setter Property="MaxWidth" Value="680" />
|
||||
<Setter Property="Margin" Value="0,0,0,6" />
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
</Style>
|
||||
|
|
@ -174,15 +174,15 @@
|
|||
<Setter Property="BorderBrush" Value="#394350" />
|
||||
</Style>
|
||||
<Style Selector="Button.rail-button">
|
||||
<Setter Property="Width" Value="38" />
|
||||
<Setter Property="Height" Value="38" />
|
||||
<Setter Property="Width" Value="30" />
|
||||
<Setter Property="Height" Value="30" />
|
||||
<Setter Property="CornerRadius" Value="2" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="Foreground" Value="#394350" />
|
||||
<Setter Property="BorderBrush" Value="#E8DDD2" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
</Style>
|
||||
<Style Selector="Button.rail-button.active">
|
||||
<Setter Property="Background" Value="#394350" />
|
||||
|
|
@ -206,17 +206,17 @@
|
|||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<Grid Margin="12">
|
||||
<Grid Margin="4">
|
||||
<Grid IsVisible="{Binding ShowOnboarding}"
|
||||
HorizontalAlignment="Center"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center"
|
||||
MaxWidth="380">
|
||||
<Border Classes="surface" Padding="22">
|
||||
<StackPanel Spacing="14">
|
||||
<StackPanel Spacing="8">
|
||||
MaxWidth="404">
|
||||
<Border Classes="surface" Padding="16">
|
||||
<StackPanel Spacing="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="KO · TALK" Classes="eyebrow" />
|
||||
<Border Width="50" Height="50" Classes="surface-muted" HorizontalAlignment="Left" Padding="7">
|
||||
<Image Source="avares://PhysOn.Desktop/Assets/kotalk-mark-128.png"
|
||||
<Border Width="46" Height="46" Classes="surface-muted" HorizontalAlignment="Left" Padding="7">
|
||||
<Image Source="avares://KoTalk/Assets/kotalk-mark-128.png"
|
||||
Stretch="Uniform" />
|
||||
</Border>
|
||||
<TextBlock Text="KoTalk" Classes="display-title" />
|
||||
|
|
@ -226,23 +226,22 @@
|
|||
PlaceholderText="표시 이름"
|
||||
Text="{Binding DisplayName}" />
|
||||
|
||||
<TextBox Classes="input"
|
||||
PlaceholderText="참여 키"
|
||||
Text="{Binding InviteCode}" />
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="10">
|
||||
<Button Classes="secondary-button"
|
||||
Command="{Binding ToggleAdvancedSettingsCommand}"
|
||||
Content="{Binding AdvancedSettingsButtonText}" />
|
||||
<TextBox Grid.Column="1"
|
||||
Classes="input"
|
||||
IsVisible="{Binding ShowAdvancedSettings}"
|
||||
PlaceholderText="서버를 바꿀 때만 입력"
|
||||
<StackPanel Grid.Column="1"
|
||||
Spacing="8"
|
||||
IsVisible="{Binding ShowAdvancedSettings}">
|
||||
<TextBox Classes="input"
|
||||
PlaceholderText="참여 키"
|
||||
Text="{Binding InviteCode}" />
|
||||
<TextBox Classes="input"
|
||||
PlaceholderText="서버 주소"
|
||||
Text="{Binding ApiBaseUrl}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<CheckBox Content="유지" IsChecked="{Binding RememberSession}" />
|
||||
|
||||
<Border Classes="inline-alert" IsVisible="{Binding HasErrorText}">
|
||||
<TextBlock Text="{Binding ErrorText}"
|
||||
Classes="caption"
|
||||
|
|
@ -260,17 +259,17 @@
|
|||
|
||||
<Grid IsVisible="{Binding ShowShell}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="56" />
|
||||
<ColumnDefinition Width="44" />
|
||||
<ColumnDefinition Width="{Binding ConversationPaneWidth}" />
|
||||
<ColumnDefinition Width="6" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0" Classes="rail-surface" Padding="8">
|
||||
<Border Grid.Column="0" Classes="rail-surface" Padding="6">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<StackPanel Spacing="8">
|
||||
<Border Width="38" Height="38" Classes="surface-muted" Padding="6">
|
||||
<Image Source="avares://PhysOn.Desktop/Assets/kotalk-mark-128.png"
|
||||
<Border Width="30" Height="30" Classes="surface-muted" Padding="5">
|
||||
<Image Source="avares://KoTalk/Assets/kotalk-mark-128.png"
|
||||
Stretch="Uniform" />
|
||||
</Border>
|
||||
|
||||
|
|
@ -280,10 +279,11 @@
|
|||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="2" Spacing="8">
|
||||
<Border Width="38" Height="38" Classes="surface-muted">
|
||||
<Border Width="30" Height="30" Classes="surface-muted">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding CurrentUserMonogram}"
|
||||
FontSize="11.5"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#394350" />
|
||||
</Border>
|
||||
|
|
@ -299,19 +299,21 @@
|
|||
<Border x:Name="ConversationPaneHost"
|
||||
Grid.Column="1"
|
||||
Classes="surface"
|
||||
Padding="10"
|
||||
Padding="8"
|
||||
IsVisible="{Binding IsConversationPaneExpanded}"
|
||||
SizeChanged="ConversationPaneHost_OnSizeChanged">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*" RowSpacing="8">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*" RowSpacing="6">
|
||||
<Grid ColumnDefinitions="*" ColumnSpacing="10">
|
||||
<TextBlock Text="받은함" Classes="section-title" />
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||
<TextBox Grid.Column="0"
|
||||
<TextBox x:Name="ConversationSearchTextBox"
|
||||
Grid.Column="0"
|
||||
Classes="search-input"
|
||||
PlaceholderText="{Binding SearchWatermark}"
|
||||
Text="{Binding ConversationSearchText}" />
|
||||
Text="{Binding ConversationSearchText}"
|
||||
KeyDown="ConversationSearchTextBox_OnKeyDown" />
|
||||
<Button Grid.Column="1"
|
||||
Classes="icon-button"
|
||||
ToolTip.Tip="밀도 전환"
|
||||
|
|
@ -353,7 +355,7 @@
|
|||
|
||||
<Grid Grid.Row="3">
|
||||
<Border Classes="surface-muted"
|
||||
Padding="18"
|
||||
Padding="12"
|
||||
IsVisible="{Binding ShowConversationEmptyState}">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="{Binding ConversationEmptyStateText}" Classes="section-title" />
|
||||
|
|
@ -372,8 +374,11 @@
|
|||
<ItemsControl ItemsSource="{Binding FilteredConversations}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConversationRowViewModel">
|
||||
<Button Classes="row-button"
|
||||
Margin="0,0,0,6"
|
||||
<Grid Margin="0,0,0,6"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="6">
|
||||
<Button Grid.Column="0"
|
||||
Classes="row-button"
|
||||
Command="{Binding $parent[Window].DataContext.SelectConversationCommand}"
|
||||
CommandParameter="{Binding .}">
|
||||
<Border Classes="row-card"
|
||||
|
|
@ -426,6 +431,15 @@
|
|||
</Grid>
|
||||
</Border>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="1"
|
||||
Classes="icon-button compact"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="분리"
|
||||
Command="{Binding $parent[Window].DataContext.DetachConversationRowCommand}"
|
||||
CommandParameter="{Binding .}"
|
||||
Content="↗" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
|
@ -441,8 +455,8 @@
|
|||
ResizeDirection="Columns"
|
||||
ShowsPreview="True" />
|
||||
|
||||
<Border Grid.Column="3" Classes="surface" Padding="10">
|
||||
<Grid RowDefinitions="Auto,*,Auto" RowSpacing="10">
|
||||
<Border Grid.Column="3" Classes="surface" Padding="8">
|
||||
<Grid RowDefinitions="Auto,*,Auto" RowSpacing="8">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="8">
|
||||
<Border Width="38" Height="38" Classes="surface-muted">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
|
|
@ -501,9 +515,8 @@
|
|||
</Border>
|
||||
|
||||
<ScrollViewer Name="MessagesScrollViewer"
|
||||
Margin="0,12,0,0"
|
||||
MaxWidth="860"
|
||||
HorizontalAlignment="Center">
|
||||
Margin="0,6,0,0"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ItemsControl ItemsSource="{Binding Messages}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MessageRowViewModel">
|
||||
|
|
@ -530,10 +543,10 @@
|
|||
</ScrollViewer>
|
||||
|
||||
<Border Classes="surface-muted"
|
||||
Width="280"
|
||||
HorizontalAlignment="Center"
|
||||
MaxWidth="220"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
Padding="14"
|
||||
Padding="10"
|
||||
IsVisible="{Binding ShowMessageEmptyState}">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="{Binding MessageEmptyStateTitle}" Classes="section-title" />
|
||||
|
|
@ -548,13 +561,26 @@
|
|||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="surface-muted"
|
||||
MaxWidth="220"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Padding="10"
|
||||
IsVisible="{Binding ShowConversationLoadingState}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="불러오는 중" Classes="section-title" />
|
||||
<TextBlock Text="현재 대화를 맞추고 있습니다."
|
||||
Classes="caption"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="2"
|
||||
Classes="surface-muted"
|
||||
Padding="8"
|
||||
MaxWidth="860"
|
||||
HorizontalAlignment="Center">
|
||||
Padding="6"
|
||||
HorizontalAlignment="Stretch">
|
||||
<Grid RowDefinitions="Auto,Auto" RowSpacing="6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="quick-button"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
|
@ -9,12 +10,14 @@ namespace PhysOn.Desktop.Views;
|
|||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private bool _initialLayoutApplied;
|
||||
private MainWindowViewModel? _boundViewModel;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
Opened += OnOpened;
|
||||
}
|
||||
|
||||
private async void ComposerTextBox_OnKeyDown(object? sender, KeyEventArgs e)
|
||||
|
|
@ -33,6 +36,15 @@ public partial class MainWindow : Window
|
|||
{
|
||||
base.OnKeyDown(e);
|
||||
|
||||
if (e.Key == Key.K &&
|
||||
e.KeyModifiers.HasFlag(KeyModifiers.Control) &&
|
||||
DataContext is MainWindowViewModel)
|
||||
{
|
||||
FocusConversationSearch(selectAll: true);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key == Key.O &&
|
||||
e.KeyModifiers.HasFlag(KeyModifiers.Control) &&
|
||||
e.KeyModifiers.HasFlag(KeyModifiers.Shift) &&
|
||||
|
|
@ -43,11 +55,33 @@ public partial class MainWindow : Window
|
|||
}
|
||||
}
|
||||
|
||||
private async void ConversationSearchTextBox_OnKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel viewModel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
await viewModel.ActivateSearchResultAsync(detach: e.KeyModifiers.HasFlag(KeyModifiers.Control));
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
viewModel.ClearSearch();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_boundViewModel is not null)
|
||||
{
|
||||
_boundViewModel.Messages.CollectionChanged -= Messages_OnCollectionChanged;
|
||||
_boundViewModel.PropertyChanged -= ViewModel_OnPropertyChanged;
|
||||
}
|
||||
|
||||
_boundViewModel = DataContext as MainWindowViewModel;
|
||||
|
|
@ -55,6 +89,20 @@ public partial class MainWindow : Window
|
|||
if (_boundViewModel is not null)
|
||||
{
|
||||
_boundViewModel.Messages.CollectionChanged += Messages_OnCollectionChanged;
|
||||
_boundViewModel.PropertyChanged += ViewModel_OnPropertyChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOpened(object? sender, EventArgs e)
|
||||
{
|
||||
ApplySuggestedWindowLayout(force: true);
|
||||
}
|
||||
|
||||
private void ViewModel_OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is nameof(MainWindowViewModel.ShowOnboarding) or nameof(MainWindowViewModel.ShowShell))
|
||||
{
|
||||
ApplySuggestedWindowLayout(force: !_initialLayoutApplied || !(_boundViewModel?.HasPersistedWindowBounds ?? true));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +126,9 @@ public partial class MainWindow : Window
|
|||
{
|
||||
if (_boundViewModel is not null)
|
||||
{
|
||||
_boundViewModel.CaptureWindowLayout(Width, Height, WindowState == WindowState.Maximized);
|
||||
_boundViewModel.Messages.CollectionChanged -= Messages_OnCollectionChanged;
|
||||
_boundViewModel.PropertyChanged -= ViewModel_OnPropertyChanged;
|
||||
_ = _boundViewModel.DisposeAsync();
|
||||
}
|
||||
|
||||
|
|
@ -92,4 +142,41 @@ public partial class MainWindow : Window
|
|||
scrollViewer.Offset = new Vector(scrollViewer.Offset.X, scrollViewer.Extent.Height);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplySuggestedWindowLayout(bool force)
|
||||
{
|
||||
if (_boundViewModel is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_initialLayoutApplied && !force)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var (minWidth, minHeight) = _boundViewModel.GetSuggestedWindowConstraints();
|
||||
MinWidth = minWidth;
|
||||
MinHeight = minHeight;
|
||||
|
||||
var (suggestedWidth, suggestedHeight, maximized) = _boundViewModel.GetSuggestedWindowLayout();
|
||||
Width = suggestedWidth;
|
||||
Height = suggestedHeight;
|
||||
WindowState = maximized ? WindowState.Maximized : WindowState.Normal;
|
||||
_initialLayoutApplied = true;
|
||||
}
|
||||
|
||||
private void FocusConversationSearch(bool selectAll)
|
||||
{
|
||||
if (this.FindControl<TextBox>("ConversationSearchTextBox") is not { } searchBox)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
searchBox.Focus();
|
||||
if (selectAll)
|
||||
{
|
||||
searchBox.SelectAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ namespace PhysOn.Mobile.Android;
|
|||
ConfigChanges.Density)]
|
||||
public class MainActivity : Activity
|
||||
{
|
||||
private const string AppVersion = "0.1.0-alpha.6";
|
||||
private const string AppVersion = "0.1.0-alpha.11";
|
||||
private const string HomeUrl = "https://vstalk.phy.kr";
|
||||
|
||||
private static readonly HashSet<string> AllowedHosts = new(StringComparer.OrdinalIgnoreCase)
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
<RootNamespace>PhysOn.Mobile.Android</RootNamespace>
|
||||
<AssemblyName>KoTalk.Mobile.Android</AssemblyName>
|
||||
<ApplicationId>kr.physia.kotalk</ApplicationId>
|
||||
<ApplicationVersion>6</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>0.1.0-alpha.6</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>11</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>0.1.0-alpha.11</ApplicationDisplayVersion>
|
||||
<Company>PHYSIA</Company>
|
||||
<Authors>PHYSIA</Authors>
|
||||
<Product>KoTalk</Product>
|
||||
|
|
|
|||
4
src/PhysOn.Web/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "physon-web",
|
||||
"version": "0.1.0-alpha.6",
|
||||
"version": "0.1.0-alpha.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "physon-web",
|
||||
"version": "0.1.0-alpha.6",
|
||||
"version": "0.1.0-alpha.11",
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "physon-web",
|
||||
"private": true,
|
||||
"version": "0.1.0-alpha.6",
|
||||
"version": "0.1.0-alpha.11",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
.shell {
|
||||
min-height: 100svh;
|
||||
min-width: 0;
|
||||
max-width: min(100%, var(--app-frame-width));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.onboarding {
|
||||
|
|
@ -17,8 +19,6 @@
|
|||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px 12px calc(16px + env(safe-area-inset-bottom));
|
||||
max-width: 460px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.onboarding__chrome,
|
||||
|
|
@ -322,26 +322,26 @@
|
|||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
width: 100%;
|
||||
padding-bottom: calc(66px + env(safe-area-inset-bottom));
|
||||
padding-bottom: var(--bottom-bar-safe-height);
|
||||
}
|
||||
|
||||
.pane {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto auto 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
min-height: 100svh;
|
||||
min-height: calc(100svh - var(--bottom-bar-safe-height));
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pane--list {
|
||||
padding: 12px 12px 0;
|
||||
padding: 10px 12px 0;
|
||||
}
|
||||
|
||||
.pane--chat {
|
||||
padding: 12px;
|
||||
padding: 10px 12px calc(12px + var(--bottom-bar-safe-height));
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
|
|
@ -355,6 +355,12 @@
|
|||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-appbar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -405,6 +411,12 @@
|
|||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.icon-button--active {
|
||||
background: var(--text-strong);
|
||||
border-color: var(--text-strong);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
color: var(--text-soft);
|
||||
min-height: 28px;
|
||||
|
|
@ -506,15 +518,75 @@
|
|||
|
||||
.conversation-list {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.recent-work-strip {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(160px, 1fr);
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.recent-work-strip::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.recent-work-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 56px;
|
||||
padding: 8px 9px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 2px;
|
||||
background: var(--surface-base);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.recent-work-card__body {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recent-work-card__body strong {
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.recent-work-card__body span {
|
||||
min-width: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.avatar--compact {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.conversation-list--saved {
|
||||
gap: 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.conversation-row {
|
||||
|
|
@ -534,7 +606,7 @@
|
|||
.saved-section,
|
||||
.saved-section__body {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.search-results--discovery,
|
||||
|
|
@ -563,6 +635,12 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-history {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
|
|
@ -574,6 +652,18 @@
|
|||
text-align: left;
|
||||
}
|
||||
|
||||
.search-result--message {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.search-result--link {
|
||||
border-color: #dec5b1;
|
||||
}
|
||||
|
||||
.search-result--person {
|
||||
border-color: #d6d7dc;
|
||||
}
|
||||
|
||||
.search-result__topline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -598,6 +688,15 @@
|
|||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.search-result small {
|
||||
color: var(--text-soft);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.search-result p {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
|
|
@ -681,6 +780,40 @@
|
|||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.queue-badge {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 2px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.queue-badge svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.queue-badge--today {
|
||||
color: #8d5b2a;
|
||||
}
|
||||
|
||||
.queue-badge--later {
|
||||
color: #6c84ab;
|
||||
}
|
||||
|
||||
.queue-badge--done {
|
||||
color: #698869;
|
||||
}
|
||||
|
||||
.message-bubble--highlight {
|
||||
border-color: #c46b44;
|
||||
background: #fbf1e6;
|
||||
}
|
||||
|
||||
.conversation-row__tail em {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -741,9 +874,11 @@
|
|||
|
||||
.message-stream {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 2px 0 10px;
|
||||
}
|
||||
|
|
@ -767,7 +902,7 @@
|
|||
.profile-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 2px;
|
||||
background: var(--surface-base);
|
||||
|
|
@ -927,15 +1062,17 @@
|
|||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
padding: 8px 12px calc(8px + env(safe-area-inset-bottom));
|
||||
min-height: var(--bottom-bar-safe-height);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
}
|
||||
|
||||
.bottom-bar--shell {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: min(100%, var(--app-frame-width));
|
||||
transform: translateX(-50%);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
|
|
@ -981,10 +1118,10 @@
|
|||
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
left: 50%;
|
||||
bottom: calc(86px + env(safe-area-inset-bottom));
|
||||
left: 16px;
|
||||
max-width: calc(100vw - 32px);
|
||||
width: min(calc(100vw - 32px), calc(var(--app-frame-width) - 24px));
|
||||
transform: translateX(-50%);
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 2px;
|
||||
|
|
@ -1022,124 +1159,15 @@
|
|||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.app {
|
||||
padding-inline: 24px;
|
||||
}
|
||||
|
||||
.onboarding {
|
||||
max-width: 500px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 18px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.shell {
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
grid-template-columns: 64px minmax(272px, 340px) minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
padding: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.pane {
|
||||
min-height: calc(100svh - 20px);
|
||||
margin: 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.pane--list {
|
||||
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;
|
||||
}
|
||||
|
||||
.bottom-bar--shell {
|
||||
position: sticky;
|
||||
inset: 0 auto auto 0;
|
||||
width: auto;
|
||||
height: calc(100svh - 20px);
|
||||
padding: 8px 6px;
|
||||
grid-template-columns: 1fr;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 2px;
|
||||
background: var(--surface-base);
|
||||
}
|
||||
|
||||
.toast {
|
||||
right: 24px;
|
||||
left: auto;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
min-height: 48px;
|
||||
padding: 7px 4px;
|
||||
border-radius: 2px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bottom-bar--shell .nav-button span {
|
||||
display: none;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.bottom-bar--shell .nav-button--active span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.appbar__title span,
|
||||
.chat-appbar__title span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.appbar,
|
||||
.chat-appbar {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.toolbar-strip {
|
||||
overflow: visible;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.conversation-list {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.search-results,
|
||||
.saved-section,
|
||||
.saved-section__body {
|
||||
gap: 4px;
|
||||
padding-bottom: calc(66px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,22 @@ import { buildBrowserWsUrl, parseRealtimeEvent } from './lib/realtime'
|
|||
import {
|
||||
clearConversationDraft,
|
||||
clearConversationDrafts,
|
||||
clearConversationFollowUps,
|
||||
clearRecentConversationIds,
|
||||
clearRecentSearchQueries,
|
||||
clearSavedInviteCode,
|
||||
clearStoredSession,
|
||||
getInstallId,
|
||||
readConversationDraft,
|
||||
readConversationFollowUps,
|
||||
readRecentConversationIds,
|
||||
readRecentSearchQueries,
|
||||
readSavedInviteCode,
|
||||
readStoredSession,
|
||||
pushRecentConversationId,
|
||||
pushRecentSearchQuery,
|
||||
writeConversationDraft,
|
||||
writeConversationFollowUp,
|
||||
writeSavedInviteCode,
|
||||
writeStoredSession,
|
||||
} from './lib/storage'
|
||||
|
|
@ -27,14 +38,18 @@ type ConnectionState = 'idle' | 'connecting' | 'connected' | 'fallback'
|
|||
type ConversationFilter = 'all' | 'unread' | 'pinned'
|
||||
type MobileView = 'list' | 'chat'
|
||||
type BottomDestination = 'inbox' | 'search' | 'saved' | 'me'
|
||||
type FollowUpBucket = 'today' | 'later' | 'done'
|
||||
type SearchScope = 'all' | 'messages' | 'links' | 'people' | 'conversations'
|
||||
type SearchResultItem = {
|
||||
key: string
|
||||
kind: 'conversation' | 'message'
|
||||
kind: 'conversation' | 'message' | 'link' | 'person'
|
||||
conversationId: string
|
||||
messageId?: string
|
||||
title: string
|
||||
excerpt: string
|
||||
meta: string
|
||||
timestamp: string
|
||||
accent?: string
|
||||
}
|
||||
type IconName =
|
||||
| 'mark'
|
||||
|
|
@ -53,7 +68,13 @@ type IconName =
|
|||
| 'group'
|
||||
|
||||
const DEFAULT_API_BASE_URL = import.meta.env.VITE_API_BASE_URL?.trim() ?? ''
|
||||
const APP_VERSION = 'web-0.1.0-alpha.6'
|
||||
const DEFAULT_PUBLIC_ALPHA_KEY = 'ALPHA-OPEN-2026'
|
||||
const APP_VERSION = 'web-0.1.0-alpha.11'
|
||||
const FOLLOW_UP_META: Record<FollowUpBucket, { label: string; icon: IconName }> = {
|
||||
today: { label: '오늘', icon: 'spark' },
|
||||
later: { label: '나중', icon: 'clock' },
|
||||
done: { label: '완료', icon: 'check' },
|
||||
}
|
||||
|
||||
const CONNECTION_LABEL: Record<ConnectionState, string> = {
|
||||
idle: '준비 중',
|
||||
|
|
@ -210,11 +231,43 @@ function createClientId(): string {
|
|||
: `client-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
|
||||
}
|
||||
|
||||
function readBootstrapInviteCode(): string {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const fromQuery = (params.get('invite') ?? params.get('key') ?? '').trim().toUpperCase()
|
||||
if (fromQuery) {
|
||||
return fromQuery
|
||||
}
|
||||
|
||||
return readSavedInviteCode().trim().toUpperCase() || DEFAULT_PUBLIC_ALPHA_KEY
|
||||
}
|
||||
|
||||
function getDefaultDeviceName(): string {
|
||||
const platform = /android/i.test(window.navigator.userAgent) ? 'Android' : 'Mobile Web'
|
||||
return `${platform} 브라우저`
|
||||
}
|
||||
|
||||
function isFollowUpBucket(value: string | null | undefined): value is FollowUpBucket {
|
||||
return value === 'today' || value === 'later' || value === 'done'
|
||||
}
|
||||
|
||||
function dedupeStrings(values: string[]): string[] {
|
||||
return [...new Set(values.filter((value) => value.trim().length > 0))]
|
||||
}
|
||||
|
||||
function extractUrls(value: string): string[] {
|
||||
const matches = value.match(/https?:\/\/[^\s)]+/gi)
|
||||
return matches ? dedupeStrings(matches.map((item) => item.trim())) : []
|
||||
}
|
||||
|
||||
function formatLinkLabel(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return parsed.hostname.replace(/^www\./i, '')
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
function Icon({ name }: { name: IconName }) {
|
||||
switch (name) {
|
||||
case 'mark':
|
||||
|
|
@ -314,10 +367,22 @@ function Icon({ name }: { name: IconName }) {
|
|||
|
||||
function App() {
|
||||
const initialSession = useMemo(() => readStoredSession(), [])
|
||||
const initialInviteCode = useMemo(() => readBootstrapInviteCode(), [])
|
||||
const initialFollowUpMap = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(readConversationFollowUps()).filter((entry): entry is [string, FollowUpBucket] =>
|
||||
isFollowUpBucket(entry[1]),
|
||||
),
|
||||
),
|
||||
[],
|
||||
)
|
||||
const initialRecentConversationIds = useMemo(() => readRecentConversationIds(), [])
|
||||
const initialRecentSearchQueries = useMemo(() => readRecentSearchQueries(), [])
|
||||
const [storedSession, setStoredSession] = useState<StoredSession | null>(initialSession)
|
||||
const [apiBaseUrl, setApiBaseUrl] = useState(initialSession?.apiBaseUrl ?? DEFAULT_API_BASE_URL)
|
||||
const [displayName, setDisplayName] = useState(initialSession?.bootstrap.me.displayName ?? '')
|
||||
const [inviteCode, setInviteCode] = useState('')
|
||||
const [inviteCode, setInviteCode] = useState(initialInviteCode)
|
||||
const [bootstrap, setBootstrap] = useState<BootstrapResponse | null>(initialSession?.bootstrap ?? null)
|
||||
const [conversations, setConversations] = useState<ConversationSummaryDto[]>(
|
||||
sortConversations(initialSession?.bootstrap.conversations.items ?? []),
|
||||
|
|
@ -338,9 +403,15 @@ function App() {
|
|||
const [composerText, setComposerText] = useState('')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchScope, setSearchScope] = useState<SearchScope>('all')
|
||||
const [refreshTick, setRefreshTick] = useState(0)
|
||||
const [followUpMap, setFollowUpMap] = useState<Record<string, FollowUpBucket>>(initialFollowUpMap)
|
||||
const [recentConversationIds, setRecentConversationIds] = useState<string[]>(initialRecentConversationIds)
|
||||
const [recentSearchQueries, setRecentSearchQueries] = useState<string[]>(initialRecentSearchQueries)
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null)
|
||||
const storedSessionRef = useRef(storedSession)
|
||||
const refreshSessionPromiseRef = useRef<Promise<StoredSession> | null>(null)
|
||||
const prefetchingConversationIdsRef = useRef<Set<string>>(new Set())
|
||||
const pendingReadCursorRef = useRef<Record<string, number>>({})
|
||||
const pendingAutoScrollRef = useRef(true)
|
||||
const messageStreamRef = useRef<HTMLDivElement | null>(null)
|
||||
|
|
@ -380,11 +451,15 @@ function App() {
|
|||
return {
|
||||
conversations: [] as SearchResultItem[],
|
||||
messages: [] as SearchResultItem[],
|
||||
links: [] as SearchResultItem[],
|
||||
people: [] as SearchResultItem[],
|
||||
}
|
||||
}
|
||||
|
||||
const conversationMatches: SearchResultItem[] = []
|
||||
const messageMatches: SearchResultItem[] = []
|
||||
const linkMatches: SearchResultItem[] = []
|
||||
const peopleMatches = new Map<string, SearchResultItem>()
|
||||
|
||||
for (const conversation of conversations) {
|
||||
const matchMeta: string[] = []
|
||||
|
|
@ -412,7 +487,42 @@ function App() {
|
|||
|
||||
const loadedMessages = messagesByConversation[conversation.conversationId] ?? []
|
||||
for (const message of loadedMessages) {
|
||||
const senderMatches = message.sender.displayName.toLocaleLowerCase('ko-KR').includes(query)
|
||||
if (senderMatches) {
|
||||
const existing = peopleMatches.get(message.sender.userId)
|
||||
if (!existing || new Date(message.createdAt).getTime() > new Date(existing.timestamp).getTime()) {
|
||||
peopleMatches.set(message.sender.userId, {
|
||||
key: `person-${message.sender.userId}`,
|
||||
kind: 'person',
|
||||
conversationId: conversation.conversationId,
|
||||
messageId: message.messageId,
|
||||
title: message.sender.displayName,
|
||||
excerpt: conversation.title,
|
||||
meta: '보낸 사람',
|
||||
timestamp: message.createdAt,
|
||||
accent: conversation.title,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!message.text.toLocaleLowerCase('ko-KR').includes(query)) {
|
||||
for (const url of extractUrls(message.text)) {
|
||||
if (!url.toLocaleLowerCase('ko-KR').includes(query) && !formatLinkLabel(url).toLocaleLowerCase('ko-KR').includes(query)) {
|
||||
continue
|
||||
}
|
||||
|
||||
linkMatches.push({
|
||||
key: `link-${message.messageId}-${url}`,
|
||||
kind: 'link',
|
||||
conversationId: conversation.conversationId,
|
||||
messageId: message.messageId,
|
||||
title: formatLinkLabel(url),
|
||||
excerpt: message.text,
|
||||
meta: conversation.title,
|
||||
timestamp: message.createdAt,
|
||||
accent: url,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -420,21 +530,44 @@ function App() {
|
|||
key: `message-${message.messageId}`,
|
||||
kind: 'message',
|
||||
conversationId: conversation.conversationId,
|
||||
messageId: message.messageId,
|
||||
title: conversation.title,
|
||||
excerpt: message.text,
|
||||
meta: message.isMine ? '내 메시지' : message.sender.displayName,
|
||||
timestamp: message.createdAt,
|
||||
})
|
||||
|
||||
for (const url of extractUrls(message.text)) {
|
||||
linkMatches.push({
|
||||
key: `link-${message.messageId}-${url}`,
|
||||
kind: 'link',
|
||||
conversationId: conversation.conversationId,
|
||||
messageId: message.messageId,
|
||||
title: formatLinkLabel(url),
|
||||
excerpt: message.text,
|
||||
meta: conversation.title,
|
||||
timestamp: message.createdAt,
|
||||
accent: url,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conversationMatches.sort((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime())
|
||||
messageMatches.sort((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime())
|
||||
linkMatches.sort((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime())
|
||||
const peopleItems = [...peopleMatches.values()].sort(
|
||||
(left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime(),
|
||||
)
|
||||
|
||||
const include = (scope: SearchScope, section: Exclude<SearchScope, 'all'>) => scope === 'all' || scope === section
|
||||
return {
|
||||
conversations: conversationMatches.slice(0, 8),
|
||||
messages: messageMatches.slice(0, 8),
|
||||
conversations: include(searchScope, 'conversations') ? conversationMatches.slice(0, 8) : [],
|
||||
messages: include(searchScope, 'messages') ? messageMatches.slice(0, 8) : [],
|
||||
links: include(searchScope, 'links') ? linkMatches.slice(0, 8) : [],
|
||||
people: include(searchScope, 'people') ? peopleItems.slice(0, 8) : [],
|
||||
}
|
||||
}, [conversations, messagesByConversation, normalizedSearchQuery])
|
||||
}, [conversations, messagesByConversation, normalizedSearchQuery, searchScope])
|
||||
const replyNeededConversations = useMemo(
|
||||
() => conversations.filter((conversation) => conversation.unreadCount > 0).slice(0, 4),
|
||||
[conversations],
|
||||
|
|
@ -447,6 +580,25 @@ function App() {
|
|||
() => conversations.slice(0, 4),
|
||||
[conversations],
|
||||
)
|
||||
const recentWorkConversations = useMemo(() => {
|
||||
const orderedFromVisits = recentConversationIds
|
||||
.map((conversationId) => conversations.find((item) => item.conversationId === conversationId) ?? null)
|
||||
.filter((item): item is ConversationSummaryDto => item !== null)
|
||||
|
||||
return dedupeConversationsById([...orderedFromVisits, ...recentConversations]).slice(0, 4)
|
||||
}, [conversations, recentConversationIds, recentConversations])
|
||||
const todayQueue = useMemo(
|
||||
() => conversations.filter((conversation) => followUpMap[conversation.conversationId] === 'today'),
|
||||
[conversations, followUpMap],
|
||||
)
|
||||
const laterQueue = useMemo(
|
||||
() => conversations.filter((conversation) => followUpMap[conversation.conversationId] === 'later'),
|
||||
[conversations, followUpMap],
|
||||
)
|
||||
const doneQueue = useMemo(
|
||||
() => conversations.filter((conversation) => followUpMap[conversation.conversationId] === 'done'),
|
||||
[conversations, followUpMap],
|
||||
)
|
||||
const savedReplyQueue = replyNeededConversations
|
||||
const savedPinnedQueue = useMemo(
|
||||
() => dedupeConversationsById(pinnedConversations.filter((conversation) => !replyNeededConversations.some((item) => item.conversationId === conversation.conversationId))),
|
||||
|
|
@ -462,10 +614,56 @@ function App() {
|
|||
[pinnedConversations, recentConversations, replyNeededConversations],
|
||||
)
|
||||
const savedConversations = useMemo(
|
||||
() => dedupeConversationsById([...savedReplyQueue, ...savedPinnedQueue, ...savedRecentQueue]),
|
||||
[savedPinnedQueue, savedRecentQueue, savedReplyQueue],
|
||||
() => dedupeConversationsById([...todayQueue, ...laterQueue, ...doneQueue, ...savedReplyQueue, ...savedPinnedQueue, ...savedRecentQueue]),
|
||||
[doneQueue, laterQueue, savedPinnedQueue, savedRecentQueue, savedReplyQueue, todayQueue],
|
||||
)
|
||||
const searchResultTotal = searchResults.conversations.length + searchResults.messages.length
|
||||
const discoveryReplyQueue = useMemo(
|
||||
() => replyNeededConversations.filter((conversation) => !recentWorkConversations.some((item) => item.conversationId === conversation.conversationId)),
|
||||
[recentWorkConversations, replyNeededConversations],
|
||||
)
|
||||
const discoveryLaterQueue = useMemo(
|
||||
() => laterQueue.filter(
|
||||
(conversation) =>
|
||||
!recentWorkConversations.some((item) => item.conversationId === conversation.conversationId) &&
|
||||
!replyNeededConversations.some((item) => item.conversationId === conversation.conversationId),
|
||||
),
|
||||
[laterQueue, recentWorkConversations, replyNeededConversations],
|
||||
)
|
||||
const savedReplyDisplayQueue = useMemo(
|
||||
() => savedReplyQueue.filter(
|
||||
(conversation) =>
|
||||
!todayQueue.some((item) => item.conversationId === conversation.conversationId) &&
|
||||
!laterQueue.some((item) => item.conversationId === conversation.conversationId) &&
|
||||
!doneQueue.some((item) => item.conversationId === conversation.conversationId),
|
||||
),
|
||||
[doneQueue, laterQueue, savedReplyQueue, todayQueue],
|
||||
)
|
||||
const savedPinnedDisplayQueue = useMemo(
|
||||
() => savedPinnedQueue.filter(
|
||||
(conversation) =>
|
||||
!todayQueue.some((item) => item.conversationId === conversation.conversationId) &&
|
||||
!laterQueue.some((item) => item.conversationId === conversation.conversationId) &&
|
||||
!doneQueue.some((item) => item.conversationId === conversation.conversationId) &&
|
||||
!savedReplyDisplayQueue.some((item) => item.conversationId === conversation.conversationId),
|
||||
),
|
||||
[doneQueue, laterQueue, savedPinnedQueue, savedReplyDisplayQueue, todayQueue],
|
||||
)
|
||||
const savedRecentDisplayQueue = useMemo(
|
||||
() => savedRecentQueue.filter(
|
||||
(conversation) =>
|
||||
!todayQueue.some((item) => item.conversationId === conversation.conversationId) &&
|
||||
!laterQueue.some((item) => item.conversationId === conversation.conversationId) &&
|
||||
!doneQueue.some((item) => item.conversationId === conversation.conversationId) &&
|
||||
!savedReplyDisplayQueue.some((item) => item.conversationId === conversation.conversationId) &&
|
||||
!savedPinnedDisplayQueue.some((item) => item.conversationId === conversation.conversationId),
|
||||
),
|
||||
[doneQueue, laterQueue, savedPinnedDisplayQueue, savedRecentQueue, savedReplyDisplayQueue, todayQueue],
|
||||
)
|
||||
const searchResultTotal =
|
||||
searchResults.conversations.length +
|
||||
searchResults.messages.length +
|
||||
searchResults.links.length +
|
||||
searchResults.people.length
|
||||
const primaryResumeConversation = selectedConversation ?? conversations[0] ?? null
|
||||
|
||||
const persistSession = useCallback((nextSession: StoredSession) => {
|
||||
|
|
@ -533,6 +731,7 @@ function App() {
|
|||
setComposerText(readConversationDraft(conversationId))
|
||||
pendingAutoScrollRef.current = true
|
||||
setSelectedConversationId(conversationId)
|
||||
setRecentConversationIds(pushRecentConversationId(conversationId))
|
||||
if (nextMobileView) {
|
||||
setMobileView(nextMobileView)
|
||||
}
|
||||
|
|
@ -543,6 +742,21 @@ function App() {
|
|||
setMobileView('list')
|
||||
}, [])
|
||||
|
||||
const setConversationFollowUpBucket = useCallback((conversationId: string, nextBucket: FollowUpBucket) => {
|
||||
setFollowUpMap((current) => {
|
||||
const resolvedBucket = current[conversationId] === nextBucket ? null : nextBucket
|
||||
const next: Record<string, FollowUpBucket> = { ...current }
|
||||
if (resolvedBucket) {
|
||||
next[conversationId] = resolvedBucket
|
||||
} else {
|
||||
delete next[conversationId]
|
||||
}
|
||||
|
||||
writeConversationFollowUp(conversationId, resolvedBucket)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleReconnect = useCallback(() => {
|
||||
setStatusMessage('현재 화면은 그대로 두고 대화와 연결 상태를 다시 확인하고 있어요.')
|
||||
setRefreshTick((value) => value + 1)
|
||||
|
|
@ -579,6 +793,18 @@ function App() {
|
|||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [bottomDestination, mobileView])
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedSearchQuery || searchResultTotal === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setRecentSearchQueries(pushRecentSearchQuery(normalizedSearchQuery))
|
||||
}, 700)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [normalizedSearchQuery, searchResultTotal])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedConversationId && !conversations.some((item) => item.conversationId === selectedConversationId)) {
|
||||
const fallbackConversationId = conversations[0]?.conversationId ?? null
|
||||
|
|
@ -607,6 +833,30 @@ function App() {
|
|||
pendingAutoScrollRef.current = false
|
||||
}, [selectedConversationId, selectedMessages])
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightedMessageId || !selectedConversationId || selectedMessages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const target = messageStreamRef.current?.querySelector<HTMLElement>(`[data-message-id="${highlightedMessageId}"]`)
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
target.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
})
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setHighlightedMessageId((current) => (current === highlightedMessageId ? null : current))
|
||||
}, 2600)
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame)
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [highlightedMessageId, selectedConversationId, selectedMessages])
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) {
|
||||
return
|
||||
|
|
@ -807,6 +1057,80 @@ function App() {
|
|||
}
|
||||
}, [accessToken, messagesByConversation, selectedConversationId, storedSession, withRecoveredSession])
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !normalizedSearchQuery) {
|
||||
return
|
||||
}
|
||||
|
||||
const prioritizedConversationIds = dedupeStrings([
|
||||
...recentWorkConversations.map((conversation) => conversation.conversationId),
|
||||
...replyNeededConversations.map((conversation) => conversation.conversationId),
|
||||
...todayQueue.map((conversation) => conversation.conversationId),
|
||||
...laterQueue.map((conversation) => conversation.conversationId),
|
||||
...conversations.map((conversation) => conversation.conversationId),
|
||||
])
|
||||
|
||||
const pendingConversationIds = prioritizedConversationIds
|
||||
.filter((conversationId) => !messagesByConversation[conversationId])
|
||||
.slice(0, 12)
|
||||
|
||||
if (pendingConversationIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let disposed = false
|
||||
|
||||
const prefetch = async () => {
|
||||
for (const conversationId of pendingConversationIds) {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (prefetchingConversationIdsRef.current.has(conversationId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
prefetchingConversationIdsRef.current.add(conversationId)
|
||||
try {
|
||||
const { result: response } = await withRecoveredSession((session) =>
|
||||
getMessages(session.apiBaseUrl, session.tokens.accessToken, conversationId),
|
||||
)
|
||||
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
setMessagesByConversation((current) => ({
|
||||
...current,
|
||||
[conversationId]: mergeMessages(current[conversationId], response.items),
|
||||
}))
|
||||
} catch {
|
||||
// Search prefetch is best-effort only.
|
||||
} finally {
|
||||
prefetchingConversationIdsRef.current.delete(conversationId)
|
||||
}
|
||||
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 120))
|
||||
}
|
||||
}
|
||||
|
||||
void prefetch()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [
|
||||
accessToken,
|
||||
conversations,
|
||||
laterQueue,
|
||||
messagesByConversation,
|
||||
normalizedSearchQuery,
|
||||
recentWorkConversations,
|
||||
replyNeededConversations,
|
||||
todayQueue,
|
||||
withRecoveredSession,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !selectedConversation || selectedMessages.length === 0) {
|
||||
return
|
||||
|
|
@ -876,7 +1200,7 @@ function App() {
|
|||
try {
|
||||
const response: RegisterAlphaQuickResponse = await registerAlphaQuick(apiBaseUrl, {
|
||||
displayName: displayName.trim(),
|
||||
inviteCode: inviteCode.trim(),
|
||||
inviteCode: inviteCode.trim() || DEFAULT_PUBLIC_ALPHA_KEY,
|
||||
device: {
|
||||
installId: getInstallId(),
|
||||
platform: 'web-mobile',
|
||||
|
|
@ -892,7 +1216,12 @@ function App() {
|
|||
savedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
writeSavedInviteCode(inviteCode.trim())
|
||||
const resolvedInviteCode = inviteCode.trim() || DEFAULT_PUBLIC_ALPHA_KEY
|
||||
if (resolvedInviteCode === DEFAULT_PUBLIC_ALPHA_KEY) {
|
||||
clearSavedInviteCode()
|
||||
} else {
|
||||
writeSavedInviteCode(resolvedInviteCode)
|
||||
}
|
||||
persistSession(nextSession)
|
||||
const firstConversationId = response.bootstrap.conversations.items[0]?.conversationId ?? null
|
||||
if (firstConversationId) {
|
||||
|
|
@ -958,6 +1287,7 @@ function App() {
|
|||
}
|
||||
|
||||
clearStoredSession()
|
||||
clearSavedInviteCode()
|
||||
setStoredSession(null)
|
||||
setBootstrap(null)
|
||||
setConversations([])
|
||||
|
|
@ -968,6 +1298,12 @@ function App() {
|
|||
setBottomDestination('inbox')
|
||||
setMobileView('list')
|
||||
clearConversationDrafts()
|
||||
clearConversationFollowUps()
|
||||
clearRecentConversationIds()
|
||||
clearRecentSearchQueries()
|
||||
setFollowUpMap({})
|
||||
setRecentConversationIds([])
|
||||
setRecentSearchQueries([])
|
||||
setStatusMessage('세션을 정리했습니다.')
|
||||
}
|
||||
|
||||
|
|
@ -1008,9 +1344,20 @@ function App() {
|
|||
},
|
||||
}
|
||||
const activeDestinationMeta = destinationMeta[bottomDestination]
|
||||
const renderFollowUpBadge = (bucket: FollowUpBucket) => {
|
||||
const meta = FOLLOW_UP_META[bucket]
|
||||
|
||||
return (
|
||||
<span className={`queue-badge queue-badge--${bucket}`} title={meta.label}>
|
||||
<Icon name={meta.icon} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const renderConversationRows = (items: ConversationSummaryDto[]) =>
|
||||
items.map((conversation) => {
|
||||
const active = conversation.conversationId === selectedConversationId
|
||||
const followUpBucket = followUpMap[conversation.conversationId]
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
@ -1032,6 +1379,7 @@ function App() {
|
|||
<div className="conversation-row__meta">
|
||||
<span>{conversation.lastMessage?.text ?? conversation.subtitle}</span>
|
||||
<div className="conversation-row__tail">
|
||||
{followUpBucket ? renderFollowUpBadge(followUpBucket) : null}
|
||||
{conversation.isPinned ? <i className="row-pin" aria-hidden="true" /> : null}
|
||||
{conversation.unreadCount > 0 ? <em>{conversation.unreadCount}</em> : null}
|
||||
</div>
|
||||
|
|
@ -1041,13 +1389,39 @@ function App() {
|
|||
)
|
||||
})
|
||||
|
||||
const renderRecentWorkStrip = () =>
|
||||
recentWorkConversations.length > 0 ? (
|
||||
<div className="recent-work-strip" aria-label="최근 작업">
|
||||
{recentWorkConversations.map((conversation) => {
|
||||
const followUpBucket = followUpMap[conversation.conversationId]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={conversation.conversationId}
|
||||
className="recent-work-card"
|
||||
type="button"
|
||||
onClick={() => selectConversation(conversation.conversationId, 'chat')}
|
||||
>
|
||||
<div className="avatar avatar--compact">{getConversationInitials(conversation.title)}</div>
|
||||
<div className="recent-work-card__body">
|
||||
<strong>{conversation.title}</strong>
|
||||
<span>{conversation.unreadCount > 0 ? `안읽음 ${conversation.unreadCount}` : '최근 열람'}</span>
|
||||
</div>
|
||||
{followUpBucket ? renderFollowUpBadge(followUpBucket) : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const renderSearchResultRows = (items: SearchResultItem[]) =>
|
||||
items.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className="search-result"
|
||||
className={`search-result search-result--${item.kind}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHighlightedMessageId(item.kind === 'conversation' ? null : item.messageId ?? null)
|
||||
selectConversation(item.conversationId, 'chat')
|
||||
setBottomDestination('inbox')
|
||||
}}
|
||||
|
|
@ -1058,6 +1432,7 @@ function App() {
|
|||
</div>
|
||||
<p>{item.excerpt}</p>
|
||||
<span>{item.meta}</span>
|
||||
{item.accent ? <small>{item.accent}</small> : null}
|
||||
</button>
|
||||
))
|
||||
|
||||
|
|
@ -1091,22 +1466,21 @@ function App() {
|
|||
/>
|
||||
</label>
|
||||
|
||||
<button className="text-action" type="button" onClick={() => setShowAdvanced((value) => !value)}>
|
||||
{showAdvanced ? '옵션 닫기' : '옵션'}
|
||||
</button>
|
||||
|
||||
{showAdvanced ? (
|
||||
<>
|
||||
<label className="field">
|
||||
<input
|
||||
aria-label="참여 키"
|
||||
autoCapitalize="characters"
|
||||
placeholder="참여 키"
|
||||
required
|
||||
value={inviteCode}
|
||||
onChange={(event) => setInviteCode(event.target.value.toUpperCase())}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button className="text-action" type="button" onClick={() => setShowAdvanced((value) => !value)}>
|
||||
{showAdvanced ? '기본 보기' : '고급'}
|
||||
</button>
|
||||
|
||||
{showAdvanced ? (
|
||||
<label className="field">
|
||||
<input
|
||||
aria-label="서버 주소"
|
||||
|
|
@ -1116,6 +1490,7 @@ function App() {
|
|||
onChange={(event) => setApiBaseUrl(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<button className="primary-button" disabled={registering} type="submit">
|
||||
|
|
@ -1238,6 +1613,8 @@ function App() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{renderRecentWorkStrip()}
|
||||
|
||||
<div className="conversation-list">
|
||||
{bootstrapping ? <p className="empty-state">동기화 중</p> : null}
|
||||
{!bootstrapping && conversations.length === 0 ? (
|
||||
|
|
@ -1286,6 +1663,41 @@ function App() {
|
|||
</label>
|
||||
|
||||
<div className="toolbar-strip toolbar-strip--utility" aria-label="검색 빠른 이동">
|
||||
<button
|
||||
className={`summary-chip ${searchScope === 'all' ? 'summary-chip--active' : ''}`}
|
||||
type="button"
|
||||
onClick={() => setSearchScope('all')}
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
<button
|
||||
className={`summary-chip ${searchScope === 'messages' ? 'summary-chip--active' : ''}`}
|
||||
type="button"
|
||||
onClick={() => setSearchScope('messages')}
|
||||
>
|
||||
메시지
|
||||
</button>
|
||||
<button
|
||||
className={`summary-chip ${searchScope === 'links' ? 'summary-chip--active' : ''}`}
|
||||
type="button"
|
||||
onClick={() => setSearchScope('links')}
|
||||
>
|
||||
링크
|
||||
</button>
|
||||
<button
|
||||
className={`summary-chip ${searchScope === 'people' ? 'summary-chip--active' : ''}`}
|
||||
type="button"
|
||||
onClick={() => setSearchScope('people')}
|
||||
>
|
||||
사람
|
||||
</button>
|
||||
<button
|
||||
className={`summary-chip ${searchScope === 'conversations' ? 'summary-chip--active' : ''}`}
|
||||
type="button"
|
||||
onClick={() => setSearchScope('conversations')}
|
||||
>
|
||||
방
|
||||
</button>
|
||||
<button
|
||||
className="icon-button icon-button--soft"
|
||||
type="button"
|
||||
|
|
@ -1326,31 +1738,51 @@ function App() {
|
|||
<div className="conversation-list">
|
||||
{!normalizedSearchQuery ? (
|
||||
<div className="search-results search-results--discovery">
|
||||
{recentConversations.length > 0 ? (
|
||||
{recentSearchQueries.length > 0 ? (
|
||||
<section className="saved-section">
|
||||
<div className="saved-section__header">
|
||||
<strong>최근</strong>
|
||||
<span>{recentConversations.length}개</span>
|
||||
<strong>최근 검색</strong>
|
||||
<span>{recentSearchQueries.length}개</span>
|
||||
</div>
|
||||
<div className="saved-section__body search-history">
|
||||
{recentSearchQueries.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
className="summary-chip"
|
||||
type="button"
|
||||
onClick={() => setSearchQuery(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="saved-section__body">{renderConversationRows(recentConversations)}</div>
|
||||
</section>
|
||||
) : null}
|
||||
{replyNeededConversations.length > 0 ? (
|
||||
{recentWorkConversations.length > 0 ? (
|
||||
<section className="saved-section">
|
||||
<div className="saved-section__header">
|
||||
<strong>최근 작업</strong>
|
||||
<span>{recentWorkConversations.length}개</span>
|
||||
</div>
|
||||
<div className="saved-section__body">{renderConversationRows(recentWorkConversations)}</div>
|
||||
</section>
|
||||
) : null}
|
||||
{discoveryReplyQueue.length > 0 ? (
|
||||
<section className="saved-section">
|
||||
<div className="saved-section__header">
|
||||
<strong>안읽음</strong>
|
||||
<span>{replyNeededConversations.length}개</span>
|
||||
<span>{discoveryReplyQueue.length}개</span>
|
||||
</div>
|
||||
<div className="saved-section__body">{renderConversationRows(replyNeededConversations)}</div>
|
||||
<div className="saved-section__body">{renderConversationRows(discoveryReplyQueue)}</div>
|
||||
</section>
|
||||
) : null}
|
||||
{pinnedConversations.length > 0 ? (
|
||||
{discoveryLaterQueue.length > 0 ? (
|
||||
<section className="saved-section">
|
||||
<div className="saved-section__header">
|
||||
<strong>고정</strong>
|
||||
<span>{pinnedConversations.length}개</span>
|
||||
<strong>나중</strong>
|
||||
<span>{discoveryLaterQueue.length}개</span>
|
||||
</div>
|
||||
<div className="saved-section__body">{renderConversationRows(pinnedConversations)}</div>
|
||||
<div className="saved-section__body">{renderConversationRows(discoveryLaterQueue)}</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -1360,6 +1792,18 @@ function App() {
|
|||
) : null}
|
||||
{normalizedSearchQuery && searchResultTotal > 0 ? (
|
||||
<div className="search-results search-results--matches">
|
||||
{searchResults.people.length > 0 ? (
|
||||
<section className="saved-section">
|
||||
<div className="saved-section__header">
|
||||
<strong>사람</strong>
|
||||
<span>{searchResults.people.length}개</span>
|
||||
</div>
|
||||
<div className="saved-section__body">
|
||||
{renderSearchResultRows(searchResults.people)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{searchResults.messages.length > 0 ? (
|
||||
<section className="saved-section">
|
||||
<div className="saved-section__header">
|
||||
|
|
@ -1372,6 +1816,18 @@ function App() {
|
|||
</section>
|
||||
) : null}
|
||||
|
||||
{searchResults.links.length > 0 ? (
|
||||
<section className="saved-section">
|
||||
<div className="saved-section__header">
|
||||
<strong>링크</strong>
|
||||
<span>{searchResults.links.length}개</span>
|
||||
</div>
|
||||
<div className="saved-section__body">
|
||||
{renderSearchResultRows(searchResults.links)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{searchResults.conversations.length > 0 ? (
|
||||
<section className="saved-section">
|
||||
<div className="saved-section__header">
|
||||
|
|
@ -1392,8 +1848,9 @@ function App() {
|
|||
{bottomDestination === 'saved' ? (
|
||||
<>
|
||||
<div className="toolbar-strip toolbar-strip--utility" aria-label="보관함 요약">
|
||||
<span className="status-chip"><Icon name="spark" /> {savedReplyQueue.length}</span>
|
||||
<span className="status-chip"><Icon name="pin" /> {savedPinnedQueue.length}</span>
|
||||
<span className="status-chip"><Icon name="spark" /> {todayQueue.length}</span>
|
||||
<span className="status-chip"><Icon name="clock" /> {laterQueue.length}</span>
|
||||
<span className="status-chip"><Icon name="check" /> {doneQueue.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="conversation-list conversation-list--saved">
|
||||
|
|
@ -1401,33 +1858,63 @@ function App() {
|
|||
<p className="empty-state empty-state--inline">지금 보관된 후속 작업이 없습니다.</p>
|
||||
) : null}
|
||||
|
||||
{savedReplyQueue.length > 0 ? (
|
||||
{todayQueue.length > 0 ? (
|
||||
<section className="saved-section">
|
||||
<div className="saved-section__header">
|
||||
<strong>오늘</strong>
|
||||
<span>{todayQueue.length}개</span>
|
||||
</div>
|
||||
<div className="saved-section__body">{renderConversationRows(todayQueue)}</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{laterQueue.length > 0 ? (
|
||||
<section className="saved-section">
|
||||
<div className="saved-section__header">
|
||||
<strong>나중</strong>
|
||||
<span>{laterQueue.length}개</span>
|
||||
</div>
|
||||
<div className="saved-section__body">{renderConversationRows(laterQueue)}</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{doneQueue.length > 0 ? (
|
||||
<section className="saved-section">
|
||||
<div className="saved-section__header">
|
||||
<strong>완료</strong>
|
||||
<span>{doneQueue.length}개</span>
|
||||
</div>
|
||||
<div className="saved-section__body">{renderConversationRows(doneQueue)}</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{savedReplyDisplayQueue.length > 0 ? (
|
||||
<section className="saved-section">
|
||||
<div className="saved-section__header">
|
||||
<strong>답장</strong>
|
||||
<span>{savedReplyQueue.length}개</span>
|
||||
<span>{savedReplyDisplayQueue.length}개</span>
|
||||
</div>
|
||||
<div className="saved-section__body">{renderConversationRows(savedReplyQueue)}</div>
|
||||
<div className="saved-section__body">{renderConversationRows(savedReplyDisplayQueue)}</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{savedPinnedQueue.length > 0 ? (
|
||||
{savedPinnedDisplayQueue.length > 0 ? (
|
||||
<section className="saved-section">
|
||||
<div className="saved-section__header">
|
||||
<strong>중요</strong>
|
||||
<span>{savedPinnedQueue.length}개</span>
|
||||
<span>{savedPinnedDisplayQueue.length}개</span>
|
||||
</div>
|
||||
<div className="saved-section__body">{renderConversationRows(savedPinnedQueue)}</div>
|
||||
<div className="saved-section__body">{renderConversationRows(savedPinnedDisplayQueue)}</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{savedRecentQueue.length > 0 ? (
|
||||
{savedRecentDisplayQueue.length > 0 ? (
|
||||
<section className="saved-section">
|
||||
<div className="saved-section__header">
|
||||
<strong>최근</strong>
|
||||
<span>{savedRecentQueue.length}개</span>
|
||||
<span>{savedRecentDisplayQueue.length}개</span>
|
||||
</div>
|
||||
<div className="saved-section__body">{renderConversationRows(savedRecentQueue)}</div>
|
||||
<div className="saved-section__body">{renderConversationRows(savedRecentDisplayQueue)}</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -1498,7 +1985,36 @@ function App() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chat-appbar__actions">
|
||||
<button
|
||||
className={`icon-button icon-button--soft ${followUpMap[selectedConversation.conversationId] === 'today' ? 'icon-button--active' : ''}`}
|
||||
type="button"
|
||||
title="오늘"
|
||||
aria-label="오늘 후속작업으로 표시"
|
||||
onClick={() => setConversationFollowUpBucket(selectedConversation.conversationId, 'today')}
|
||||
>
|
||||
<Icon name="spark" />
|
||||
</button>
|
||||
<button
|
||||
className={`icon-button icon-button--soft ${followUpMap[selectedConversation.conversationId] === 'later' ? 'icon-button--active' : ''}`}
|
||||
type="button"
|
||||
title="나중"
|
||||
aria-label="나중 후속작업으로 표시"
|
||||
onClick={() => setConversationFollowUpBucket(selectedConversation.conversationId, 'later')}
|
||||
>
|
||||
<Icon name="clock" />
|
||||
</button>
|
||||
<button
|
||||
className={`icon-button icon-button--soft ${followUpMap[selectedConversation.conversationId] === 'done' ? 'icon-button--active' : ''}`}
|
||||
type="button"
|
||||
title="완료"
|
||||
aria-label="완료로 표시"
|
||||
onClick={() => setConversationFollowUpBucket(selectedConversation.conversationId, 'done')}
|
||||
>
|
||||
<Icon name="check" />
|
||||
</button>
|
||||
<span className="mini-pill">{CONNECTION_LABEL[connectionState]}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="message-stream" ref={messageStreamRef}>
|
||||
|
|
@ -1537,7 +2053,8 @@ function App() {
|
|||
return (
|
||||
<article
|
||||
key={message.messageId}
|
||||
className={`message-bubble ${message.isMine ? 'message-bubble--mine' : ''}`}
|
||||
data-message-id={message.messageId}
|
||||
className={`message-bubble ${message.isMine ? 'message-bubble--mine' : ''} ${highlightedMessageId === message.messageId ? 'message-bubble--highlight' : ''}`}
|
||||
>
|
||||
{showSender ? <p className="message-bubble__sender">{message.sender.displayName}</p> : null}
|
||||
<div className="message-bubble__body">
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@
|
|||
--text-soft: #3b414b;
|
||||
--text-muted: #82766d;
|
||||
--focus-ring: #c07a43;
|
||||
--app-frame-width: 392px;
|
||||
--bottom-bar-safe-height: calc(74px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ function resolveErrorMessage(status: number, code?: string, fallback?: string):
|
|||
return '지금은 연결이 고르지 않습니다. 잠시 후 다시 시도해 주세요.'
|
||||
}
|
||||
|
||||
if (code === 'invite_code_invalid') {
|
||||
if (code === 'invite_code_invalid' || code === 'invite_invalid') {
|
||||
return '초대코드를 다시 확인해 주세요.'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ const SESSION_KEY = 'vs-talk.session'
|
|||
const INSTALL_ID_KEY = 'vs-talk.install-id'
|
||||
const INVITE_CODE_KEY = 'vs-talk.invite-code'
|
||||
const DRAFTS_KEY = 'vs-talk.drafts'
|
||||
const FOLLOW_UP_KEY = 'vs-talk.follow-up'
|
||||
const RECENT_CONVERSATIONS_KEY = 'vs-talk.recent-conversations'
|
||||
const RECENT_SEARCHES_KEY = 'vs-talk.recent-searches'
|
||||
|
||||
function fallbackRandomId(): string {
|
||||
return `web-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
|
||||
|
|
@ -58,6 +61,10 @@ export function writeSavedInviteCode(value: string): void {
|
|||
window.localStorage.setItem(INVITE_CODE_KEY, value)
|
||||
}
|
||||
|
||||
export function clearSavedInviteCode(): void {
|
||||
window.localStorage.removeItem(INVITE_CODE_KEY)
|
||||
}
|
||||
|
||||
function readDraftMap(): Record<string, string> {
|
||||
const raw = window.localStorage.getItem(DRAFTS_KEY)
|
||||
if (!raw) {
|
||||
|
|
@ -96,3 +103,93 @@ export function clearConversationDraft(conversationId: string): void {
|
|||
export function clearConversationDrafts(): void {
|
||||
window.localStorage.removeItem(DRAFTS_KEY)
|
||||
}
|
||||
|
||||
function readFollowUpMap(): Record<string, string> {
|
||||
const raw = window.localStorage.getItem(FOLLOW_UP_KEY)
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as Record<string, string>
|
||||
} catch {
|
||||
window.localStorage.removeItem(FOLLOW_UP_KEY)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function readConversationFollowUps(): Record<string, string> {
|
||||
return readFollowUpMap()
|
||||
}
|
||||
|
||||
export function writeConversationFollowUp(conversationId: string, bucket: string | null): void {
|
||||
const next = readFollowUpMap()
|
||||
if (bucket) {
|
||||
next[conversationId] = bucket
|
||||
} else {
|
||||
delete next[conversationId]
|
||||
}
|
||||
|
||||
window.localStorage.setItem(FOLLOW_UP_KEY, JSON.stringify(next))
|
||||
}
|
||||
|
||||
export function clearConversationFollowUps(): void {
|
||||
window.localStorage.removeItem(FOLLOW_UP_KEY)
|
||||
}
|
||||
|
||||
export function readRecentConversationIds(): string[] {
|
||||
const raw = window.localStorage.getItem(RECENT_CONVERSATIONS_KEY)
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as string[]
|
||||
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []
|
||||
} catch {
|
||||
window.localStorage.removeItem(RECENT_CONVERSATIONS_KEY)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function pushRecentConversationId(conversationId: string, limit = 6): string[] {
|
||||
const next = [conversationId, ...readRecentConversationIds().filter((item) => item !== conversationId)].slice(0, limit)
|
||||
window.localStorage.setItem(RECENT_CONVERSATIONS_KEY, JSON.stringify(next))
|
||||
return next
|
||||
}
|
||||
|
||||
export function clearRecentConversationIds(): void {
|
||||
window.localStorage.removeItem(RECENT_CONVERSATIONS_KEY)
|
||||
}
|
||||
|
||||
export function readRecentSearchQueries(): string[] {
|
||||
const raw = window.localStorage.getItem(RECENT_SEARCHES_KEY)
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as string[]
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item) => typeof item === 'string' && item.trim().length > 0)
|
||||
: []
|
||||
} catch {
|
||||
window.localStorage.removeItem(RECENT_SEARCHES_KEY)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function pushRecentSearchQuery(value: string, limit = 6): string[] {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return readRecentSearchQueries()
|
||||
}
|
||||
|
||||
const next = [trimmed, ...readRecentSearchQueries().filter((item) => item !== trimmed)].slice(0, limit)
|
||||
window.localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(next))
|
||||
return next
|
||||
}
|
||||
|
||||
export function clearRecentSearchQueries(): void {
|
||||
window.localStorage.removeItem(RECENT_SEARCHES_KEY)
|
||||
}
|
||||
|
|
|
|||
173
문서/115-public-technical-profile-plan.md
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
# 공개 기술 문서 프로파일 기획
|
||||
|
||||
이 문서는 공개 레포에 추가할 `기술적 측면 및 로직 상세` 문서군의 설계안이다.
|
||||
|
||||
목표는 두 가지다.
|
||||
|
||||
- 코드를 직접 읽지 않아도 KoTalk가 실제로 어떻게 동작하는지 이해되게 만들기
|
||||
- 과장 없이 현재 구현된 구조와 흐름만을 근거로 공개 표면을 강화하기
|
||||
|
||||
## 기본 원칙
|
||||
|
||||
- 공개 기술 문서는 `아키텍처 개요 1개 + 실제 흐름 문서 4개` 구성이 가장 읽기 쉽다.
|
||||
- 문서마다 독자를 분리한다. 하나의 문서에 구조, 인증, 메시지, UI 표면을 모두 섞지 않는다.
|
||||
- “현재 구현 기준”과 “향후 계획”을 같은 문단에서 섞지 않는다.
|
||||
- 소스 링크는 실제 근거를 보여 주는 최소 파일만 건다.
|
||||
|
||||
## 도입할 문서군
|
||||
|
||||
| 공개 문서 후보 | 역할 | 주 독자 | 공개 표면에서 증명할 것 |
|
||||
|---|---|---|---|
|
||||
| `TECHNICAL_ARCHITECTURE.md` | 시스템 구성요소와 계층 개요 | 첫 방문 기여자, 기술 평가자 | KoTalk가 제품형 계층 구조를 갖춘 저장소라는 점 |
|
||||
| `AUTH_AND_BOOTSTRAP_FLOW.md` | 가입, 토큰, 세션, bootstrap 흐름 | 백엔드 기여자, 테스트 참여자 | 가입 후 첫 화면까지가 하나의 계약으로 닫혀 있다는 점 |
|
||||
| `CONVERSATION_AND_MESSAGE_MODEL.md` | 대화/메시지/읽음 상태 모델 | 제품 평가자, 기여자 | 단순 UI가 아니라 메신저 도메인 모델이 있다는 점 |
|
||||
| `REALTIME_SYNC_AND_CLIENT_STATE.md` | WebSocket, 세션 복구, 클라이언트 상태 | 프런트엔드 기여자, QA | 실시간 반영과 복구 책임 위치가 분명하다는 점 |
|
||||
| `CLIENT_SURFACES_DESKTOP_AND_WEB.md` | 데스크톱과 웹 표면 전략 | 디자이너, 프런트엔드, 도입 검토자 | 같은 서비스 모델을 다른 표면에 어떻게 번역하는지 |
|
||||
|
||||
## 문서별 설계
|
||||
|
||||
### 1. `TECHNICAL_ARCHITECTURE.md`
|
||||
|
||||
핵심 섹션:
|
||||
|
||||
- 전체 지도: `Desktop / Web / Api / Application / Infrastructure / Domain / Contracts`
|
||||
- 요청 흐름: 입력 -> 엔드포인트 -> 서비스 -> 인프라 -> 응답
|
||||
- 계층 분리 이유: 공개 계약, 테스트 가능성, 클라이언트 병렬 개발성
|
||||
- 현재 구조의 한계와 확장 경계
|
||||
|
||||
소스 근거:
|
||||
|
||||
- `src/PhysOn.Api`
|
||||
- `src/PhysOn.Application`
|
||||
- `src/PhysOn.Infrastructure`
|
||||
- `src/PhysOn.Domain`
|
||||
- `src/PhysOn.Contracts`
|
||||
- `src/PhysOn.Desktop`
|
||||
- `src/PhysOn.Web`
|
||||
|
||||
문서가 보여 줄 이점:
|
||||
|
||||
- 저장소가 데모가 아니라 제품형 구조를 갖추고 있다는 점
|
||||
- 신규 기여자가 어디부터 읽어야 하는지 즉시 판단 가능하다는 점
|
||||
|
||||
### 2. `AUTH_AND_BOOTSTRAP_FLOW.md`
|
||||
|
||||
핵심 섹션:
|
||||
|
||||
- 현재 Alpha 가입 방식: `alpha-quick`
|
||||
- 요청/응답 계약: 표시 이름, 참여 키, 토큰, bootstrap payload
|
||||
- `refresh token`과 세션 연장 흐름
|
||||
- bootstrap이 첫 화면에 필요한 데이터를 왜 한 번에 묶는지
|
||||
- 로컬/운영에서 참여 키가 시드되는 방식
|
||||
|
||||
소스 근거:
|
||||
|
||||
- `src/PhysOn.Api/Endpoints/MessengerEndpoints.cs`
|
||||
- `src/PhysOn.Application/Services/MessengerApplicationService.cs`
|
||||
- `src/PhysOn.Contracts/Auth/AuthContracts.cs`
|
||||
- `src/PhysOn.Infrastructure/Auth/JwtTokenService.cs`
|
||||
- `src/PhysOn.Infrastructure/Persistence/DatabaseInitializer.cs`
|
||||
- `tests/PhysOn.Api.IntegrationTests/VerticalSliceTests.cs`
|
||||
|
||||
문서가 보여 줄 이점:
|
||||
|
||||
- 공개 독자가 “가입 후 바로 대화까지”의 경로를 이해할 수 있다.
|
||||
- 테스트 참여자가 참여 키 구조와 현재 범위를 과장 없이 파악할 수 있다.
|
||||
|
||||
### 3. `CONVERSATION_AND_MESSAGE_MODEL.md`
|
||||
|
||||
핵심 섹션:
|
||||
|
||||
- Conversation, Message, ConversationMember의 역할
|
||||
- 가입 직후 self conversation을 만드는 이유
|
||||
- 메시지 정렬, 읽음 커서, pinned/unread 상태
|
||||
- 계약 모델과 UI 표시 모델이 어떻게 이어지는지
|
||||
- 이후 확장 지점: 파일, 링크, 반응, 검색 인덱스
|
||||
|
||||
소스 근거:
|
||||
|
||||
- `src/PhysOn.Domain/Conversations/Conversation.cs`
|
||||
- `src/PhysOn.Domain/Messages/Message.cs`
|
||||
- `src/PhysOn.Contracts/Conversations/ConversationContracts.cs`
|
||||
- `src/PhysOn.Application/Services/MessengerApplicationService.cs`
|
||||
|
||||
문서가 보여 줄 이점:
|
||||
|
||||
- KoTalk가 단순 채팅 껍데기가 아니라, 실제 메시지 도메인 규칙을 갖고 있다는 점
|
||||
- 기여자가 상태 모델을 잘못 건드리지 않게 해 준다는 점
|
||||
|
||||
### 4. `REALTIME_SYNC_AND_CLIENT_STATE.md`
|
||||
|
||||
핵심 섹션:
|
||||
|
||||
- bootstrap -> realtime ticket -> WebSocket 연결
|
||||
- 실시간 이벤트 계약과 클라이언트 반영 방식
|
||||
- 웹 세션 저장과 세션 복구
|
||||
- 데스크톱 레이아웃/작업 상태 저장
|
||||
- 재연결과 실패 복구에서 기대하는 현재 동작
|
||||
|
||||
소스 근거:
|
||||
|
||||
- `src/PhysOn.Contracts/Realtime/RealtimeContracts.cs`
|
||||
- `src/PhysOn.Api/Endpoints/MessengerEndpoints.cs`
|
||||
- `src/PhysOn.Web/src/lib/api.ts`
|
||||
- `src/PhysOn.Web/src/lib/storage.ts`
|
||||
- `src/PhysOn.Web/src/App.tsx`
|
||||
- `src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs`
|
||||
- `src/PhysOn.Desktop/Services/WorkspaceLayoutStore.cs`
|
||||
|
||||
문서가 보여 줄 이점:
|
||||
|
||||
- “다시 열었을 때 이어진다”는 체감이 어떤 구조에서 나오는지 설명 가능하다.
|
||||
- QA가 세션/실시간 이슈를 추적할 때 기준 문서가 생긴다.
|
||||
|
||||
### 5. `CLIENT_SURFACES_DESKTOP_AND_WEB.md`
|
||||
|
||||
핵심 섹션:
|
||||
|
||||
- Desktop와 Web의 역할 차이
|
||||
- Desktop의 멀티 윈도우와 폭 저장
|
||||
- Web의 모바일형 정보 위계와 단일 전환 구조
|
||||
- 두 표면이 공유하는 개념: conversations, messages, bootstrap, reconnect
|
||||
- 현재 UX 한계와 리팩터링 방향
|
||||
|
||||
소스 근거:
|
||||
|
||||
- `src/PhysOn.Desktop/Views/MainWindow.axaml`
|
||||
- `src/PhysOn.Desktop/Views/ConversationWindow.axaml`
|
||||
- `src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs`
|
||||
- `src/PhysOn.Web/src/App.tsx`
|
||||
- `src/PhysOn.Web/src/App.css`
|
||||
|
||||
문서가 보여 줄 이점:
|
||||
|
||||
- 독자가 “왜 데스크톱과 웹이 동시에 존재하는가”를 납득할 수 있다.
|
||||
- 공개 스크린샷이 단순한 이미지 모음이 아니라 표면 전략으로 읽힌다.
|
||||
|
||||
## 권장 공개 순서
|
||||
|
||||
1. `TECHNICAL_ARCHITECTURE.md`
|
||||
2. `AUTH_AND_BOOTSTRAP_FLOW.md`
|
||||
3. `CONVERSATION_AND_MESSAGE_MODEL.md`
|
||||
4. `REALTIME_SYNC_AND_CLIENT_STATE.md`
|
||||
5. `CLIENT_SURFACES_DESKTOP_AND_WEB.md`
|
||||
|
||||
## 공개 문장 원칙
|
||||
|
||||
써야 하는 표현:
|
||||
|
||||
- `현재 구현 기준`
|
||||
- `알파 단계에서 실제 작동하는 흐름`
|
||||
- `이 저장소에서 확인 가능한 구조`
|
||||
|
||||
피해야 하는 표현:
|
||||
|
||||
- `완성형 차세대 메신저`
|
||||
- `카카오톡 완전 대체`
|
||||
- `엔터프라이즈급 플랫폼 완성`
|
||||
|
||||
## 바로 연결할 공개 표면
|
||||
|
||||
- `README.md`에는 기술 문서군의 첫 진입점만 둔다.
|
||||
- `PROJECT_STATUS.md`에는 기술 문서로 이어지는 링크만 둔다.
|
||||
- 세부 문서는 `문서/` 아래에서 단계적으로 확장한다.
|
||||
242
문서/116-public-security-profile-plan.md
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
# 공개 보안 문서 프로파일 기획
|
||||
|
||||
이 문서는 공개 레포에 도입할 `보안 관련 문서군`의 설계안이다.
|
||||
|
||||
핵심 원칙은 명확하다.
|
||||
|
||||
- 보안 문서는 슬로건이 아니라 `이미 코드에 들어간 통제`를 설명해야 한다.
|
||||
- `지금 되는 것`, `제한된 것`, `향후 계획`을 같은 문단에 섞지 않는다.
|
||||
- “안전하다”보다 “무엇을 했고, 무엇을 아직 하지 않았는가”를 분명히 적는다.
|
||||
|
||||
## 현재 코드 기준 보안 주장선
|
||||
|
||||
공개 문서는 아래 수준까지만 주장한다.
|
||||
|
||||
| 영역 | 현재 코드에서 말할 수 있는 것 | 아직 말하면 안 되는 것 | 근거 파일 |
|
||||
|---|---|---|---|
|
||||
| 자체 구축 / 내부망 | `자체 호스팅과 내부망 전용 배포가 가능하도록 설계된 구조` | `기관망 즉시 적합`, `공공기관 수준 검증 완료` | `deploy/compose.mvp.yml`, `deploy/Caddyfile` |
|
||||
| 세션 / 토큰 | `짧은 수명 access token`, `회전형 refresh token`, `서버의 세션 재검증` | `device binding 완성`, `zero trust 완비` | `src/PhysOn.Infrastructure/ServiceCollectionExtensions.cs`, `src/PhysOn.Infrastructure/Auth/JwtTokenService.cs`, `src/PhysOn.Api/Endpoints/MessengerEndpoints.cs` |
|
||||
| 참여 키 흐름 | `알파 단계의 제한된 참여 키 기반 온보딩` | `정식 신원 검증`, `abuse 방어 완비` | `src/PhysOn.Application/Services/MessengerApplicationService.cs`, `src/PhysOn.Infrastructure/Persistence/DatabaseInitializer.cs` |
|
||||
| 릴리즈 무결성 | `공식 다운로드 경로와 SHA-256 체크섬 제공` | `공급망 보안 완결`, `signed build 완비` | `scripts/release/release-prepare-assets.sh`, `scripts/release/release-upload-assets.sh` |
|
||||
| 전송 계층 | `TLS 기반 공식 운영 경로`, `기본 보안 헤더`, `wss 문맥 보정` | `모든 고급 네트워크 공격 방어` | `deploy/Caddyfile`, `src/PhysOn.Mobile.Android/AndroidManifest.xml`, `src/PhysOn.Web/src/lib/realtime.ts` |
|
||||
| 비밀값 처리 | `env 기반 비밀값 주입`, `운영 기본 키 차단`, `Windows DPAPI 보호` | `중앙 secret manager 완비`, `키 회전 체계 완비` | `deploy/.env.example`, `src/PhysOn.Infrastructure/ServiceCollectionExtensions.cs`, `src/PhysOn.Desktop/Services/SessionStore.cs` |
|
||||
| 클라이언트 신뢰 경계 | `웹/데스크톱/Android가 서로 다른 저장 경계를 가진다` | `모든 클라이언트가 동일한 보안 수준` | `src/PhysOn.Web/src/lib/storage.ts`, `src/PhysOn.Desktop/Services/SessionStore.cs`, `src/PhysOn.Mobile.Android/MainActivity.cs` |
|
||||
|
||||
## 공개 보안 문서군의 기본 구조
|
||||
|
||||
| 프로파일 | 공개 문서 후보 | 주 독자 | 핵심 목적 |
|
||||
|---|---|---|---|
|
||||
| 보안 개요 | `SECURITY_OVERVIEW.md` | 모든 공개 독자 | 현재 보안 자세와 허용 가능한 주장선 설명 |
|
||||
| 운영 프로파일 | `SECURITY_PROFILES.md` | 인프라 담당자, 기관/사내 검토자 | 공개 배포 / 자체 구축 / 내부망 / 로컬 개발을 구분 |
|
||||
| 세션/인증 모델 | `AUTH_AND_SESSION_MODEL.md` | 백엔드, QA, 기술 검토자 | 참여 키, 토큰, 세션, realtime ticket 흐름 설명 |
|
||||
| 전송/릴리즈 신뢰 | `TRANSPORT_AND_RELEASE_TRUST.md` | 다운로드 사용자, 운영자 | TLS, 헤더, 공식 다운로드, 체크섬 설명 |
|
||||
| 클라이언트 신뢰 경계 | `CLIENT_TRUST_BOUNDARY.md` | 사용자, 평론가, 보안 검토자 | 웹/데스크톱/Android의 보호 경계 차이를 설명 |
|
||||
| 적용된 통제 | `APPLIED_SECURITY_CONTROLS.md` | 일반 사용자, 기술 검토자 | 현재 코드에 실제로 들어간 보안 통제를 설명 |
|
||||
| 위협 모델 | `SECURITY_THREAT_MODEL.md` | 보안 담당자, 기술 도입 검토자 | 자산, 공격면, 완화 조치를 구조적으로 설명 |
|
||||
| 운영 보안 | `SECURITY_OPERATING_PRACTICE.md` | 운영자, 기관/사내 검토자 | 키, 토큰, 배포, 로그, 사고 대응 원칙 설명 |
|
||||
| 자체 구축/내부망 | `SELF_HOSTING_AND_INTERNAL_NETWORK_PROFILE.md` | 인프라 담당자, 기관 검토자 | SaaS 외 운영 가능성과 현재 한계 설명 |
|
||||
| 프라이버시/데이터 | `PRIVACY_AND_DATA_HANDLING_PROFILE.md` | 일반 사용자, 평론가 | 무엇이 서버에 있고, 무엇이 로컬에 있는지 설명 |
|
||||
| 릴리즈 무결성 | `RELEASE_INTEGRITY_AND_TRANSPARENCY.md` | 다운로드 사용자, 오픈소스 기여자 | 태그, 자산, 체크섬, 미러 구조 설명 |
|
||||
| 한계 문서 | `SECURITY_LIMITS_AND_OPEN_GAPS.md` | 모든 공개 독자 | 아직 아닌 것과 향후 과제를 정직하게 분리 |
|
||||
|
||||
## 프로파일별 설계
|
||||
|
||||
### 0. 보안 개요
|
||||
|
||||
핵심 메시지:
|
||||
|
||||
- KoTalk의 공개 보안 문서는 `무엇을 이미 구현했는지`, `무엇을 아직 주장하지 않는지`, `어디까지가 운영자 책임인지`를 먼저 보여 줘야 한다.
|
||||
|
||||
소스 근거:
|
||||
|
||||
- `src/PhysOn.Api`
|
||||
- `src/PhysOn.Infrastructure`
|
||||
- `deploy/`
|
||||
- `scripts/release/`
|
||||
|
||||
반드시 포함할 축:
|
||||
|
||||
- Scope And Security Posture
|
||||
- What KoTalk Implements Today
|
||||
- What Is Explicitly Not Claimed Yet
|
||||
- Current Security Boundaries
|
||||
- Reporting A Vulnerability
|
||||
|
||||
### 1. 적용된 통제
|
||||
|
||||
핵심 메시지:
|
||||
|
||||
- KoTalk는 알파 기준에서도 `JWT 서명 검증`, `세션 활성 검증`, `인증/실시간 rate limit`, `민감 응답 no-store`, `로컬 세션 보호` 같은 통제를 코드에 갖고 있다.
|
||||
|
||||
소스 근거:
|
||||
|
||||
- `src/PhysOn.Infrastructure/ServiceCollectionExtensions.cs`
|
||||
- `src/PhysOn.Api/Program.cs`
|
||||
- `src/PhysOn.Api/Endpoints/MessengerEndpoints.cs`
|
||||
- `src/PhysOn.Desktop/Services/SessionStore.cs`
|
||||
- `tests/PhysOn.Api.IntegrationTests/VerticalSliceTests.cs`
|
||||
|
||||
공개 문장에서 금지할 표현:
|
||||
|
||||
- `군사급 보안`
|
||||
- `정보유출 불가능`
|
||||
- `기관 등급 충족`
|
||||
- `완전한 종단간 암호화`
|
||||
|
||||
### 2. 위협 모델
|
||||
|
||||
핵심 메시지:
|
||||
|
||||
- 자산은 `세션`, `토큰`, `메시지`, `invite`, `릴리즈 산출물`, `운영 비밀값`으로 나뉘고, 각 자산의 공격면과 완화 조치가 다르다.
|
||||
|
||||
다뤄야 할 공격면:
|
||||
|
||||
- invite 남용
|
||||
- refresh token 탈취
|
||||
- WebSocket misuse
|
||||
- 다운로드 미러 위조
|
||||
- 운영 환경의 비밀값 누출
|
||||
- 클라이언트 로컬 세션 탈취
|
||||
|
||||
공개 문장에서 금지할 표현:
|
||||
|
||||
- `완전한 위협 차단`
|
||||
- `모든 공격 시나리오 대응 완료`
|
||||
|
||||
### 3. 운영 보안
|
||||
|
||||
핵심 메시지:
|
||||
|
||||
- KoTalk는 운영 환경에서 `환경변수 기반 비밀값`, `키 교체 가능성`, `세션/토큰 분리`, `릴리즈 체크섬` 같은 운영 원칙을 요구한다.
|
||||
|
||||
다뤄야 할 항목:
|
||||
|
||||
- JWT issuer/audience/signing key
|
||||
- bootstrap invite seed
|
||||
- 비밀값을 공개 문서에 두지 않는 기준
|
||||
- 취약점 제보와 사고 대응 흐름
|
||||
- 로그에 메시지 본문을 기본값으로 남기지 않는 원칙
|
||||
|
||||
소스 근거:
|
||||
|
||||
- `deploy/compose.mvp.yml`
|
||||
- `deploy/.env.example`
|
||||
- `src/PhysOn.Api/appsettings.json`
|
||||
- `src/PhysOn.Api/appsettings.Development.json`
|
||||
|
||||
### 4. 자체 구축 / 내부망
|
||||
|
||||
핵심 메시지:
|
||||
|
||||
- 현재 KoTalk는 `단일 API + reverse proxy + 환경변수 + 파일 기반 DB` 구조로 작동하는 작은 배포 단위를 갖고 있어 내부망 PoC나 자체 운영 검토에 유리하다.
|
||||
|
||||
반드시 같이 적을 제한:
|
||||
|
||||
- 현재는 SQLite 단일 노드 MVP
|
||||
- 기관망/폐쇄망 검증 완료 상태는 아님
|
||||
- 대규모 HA/DR, 다중 리전 같은 운영 수준은 아직 문서화 단계가 아님
|
||||
|
||||
소스 근거:
|
||||
|
||||
- `deploy/compose.mvp.yml`
|
||||
- `src/PhysOn.Api/Program.cs`
|
||||
- `src/PhysOn.Infrastructure/Persistence/VsMessengerDbContext.cs`
|
||||
|
||||
### 5. 프라이버시 / 데이터 처리
|
||||
|
||||
핵심 메시지:
|
||||
|
||||
- 지금 단계의 KoTalk는 무엇을 수집하고 어디에 저장하는지 설명 가능한 작은 표면을 갖고 있다.
|
||||
|
||||
반드시 포함할 문장:
|
||||
|
||||
- 현재는 E2EE가 아니다.
|
||||
- 서버 저장 구조가 존재한다.
|
||||
- 운영자가 책임지는 영역이 있다.
|
||||
- Windows 로컬 세션 저장과 웹 세션 저장의 경계가 다르다.
|
||||
|
||||
소스 근거:
|
||||
|
||||
- `src/PhysOn.Application/Services/MessengerApplicationService.cs`
|
||||
- `src/PhysOn.Desktop/Services/SessionStore.cs`
|
||||
- `src/PhysOn.Web/src/lib/storage.ts`
|
||||
- `src/PhysOn.Mobile.Android/MainActivity.cs`
|
||||
|
||||
금지할 표현:
|
||||
|
||||
- `추적하지 않는다` 단정
|
||||
- `메타데이터를 남기지 않는다`
|
||||
- `프라이버시 완전 보호`
|
||||
|
||||
### 6. 릴리즈 무결성과 운영 투명성
|
||||
|
||||
핵심 메시지:
|
||||
|
||||
- 릴리즈는 `버전`, `자산`, `체크섬`, `다운로드 미러`, `릴리즈 노트` 기준으로 추적 가능해야 한다.
|
||||
|
||||
소스 근거:
|
||||
|
||||
- `scripts/release/release-prepare-assets.sh`
|
||||
- `scripts/release/release-publish-forge.sh`
|
||||
- `scripts/release/release-publish-github.sh`
|
||||
- `release-assets/`
|
||||
- `artifacts/builds/`
|
||||
|
||||
금지할 표현:
|
||||
|
||||
- `공급망 공격 차단 완료`
|
||||
- `릴리즈 무결성 완전 보장`
|
||||
|
||||
### 7. 한계와 열린 과제
|
||||
|
||||
핵심 메시지:
|
||||
|
||||
- KoTalk는 지금 어디까지 구현됐고, 무엇은 아직 아니다.
|
||||
|
||||
반드시 적어야 할 현재 갭:
|
||||
|
||||
- Android는 현재 WebView 셸
|
||||
- 가입은 아직 참여 키 기반 alpha flow
|
||||
- 단일 DB / 단일 API MVP
|
||||
- E2EE 미구현
|
||||
- 기관 운영 검증 미완료
|
||||
|
||||
## 기존 공개 문서와의 연결
|
||||
|
||||
- `SECURITY.md`는 계속 허브 문서로 유지한다.
|
||||
- `TRUST_CENTER.md`는 요약/표면 문서로 두고, 세부 보안 프로파일 문서로 분기시킨다.
|
||||
- `PRIVACY_AND_DATA_HANDLING.md`가 이미 있다면, 새 프로파일 문서와 역할을 분리한다.
|
||||
- `RELEASING.md`는 릴리즈 무결성 문서와 상호 링크한다.
|
||||
- `DEVELOPMENT.md`는 테스트 참여 키, 로컬 운영, 비밀값 주입 관련 링크만 남기고 세부 보안 설명은 새 문서군으로 보낸다.
|
||||
|
||||
## 공개 편집 원칙
|
||||
|
||||
써야 하는 표현:
|
||||
|
||||
- `현재 구현 기준`
|
||||
- `알파 단계에서 실제 적용된 통제`
|
||||
- `운영 환경에서는 ...를 요구`
|
||||
- `이 저장소에서 확인 가능한 근거`
|
||||
|
||||
피해야 하는 표현:
|
||||
|
||||
- `근본적으로 해결했다`
|
||||
- `완벽히 안전하다`
|
||||
- `완전한 탈중앙화`
|
||||
- `기관 수준 보안 충족`
|
||||
- `카카오톡 대체 완성`
|
||||
|
||||
## 우선 공개 순서
|
||||
|
||||
1. `SECURITY_OVERVIEW.md`
|
||||
2. `AUTH_AND_SESSION_MODEL.md`
|
||||
3. `TRANSPORT_AND_RELEASE_TRUST.md`
|
||||
4. `CLIENT_TRUST_BOUNDARY.md`
|
||||
5. `SECURITY_PROFILES.md`
|
||||
6. `APPLIED_SECURITY_CONTROLS.md`
|
||||
7. `SECURITY_THREAT_MODEL.md`
|
||||
8. `SECURITY_OPERATING_PRACTICE.md`
|
||||
9. `SELF_HOSTING_AND_INTERNAL_NETWORK_PROFILE.md`
|
||||
10. `PRIVACY_AND_DATA_HANDLING_PROFILE.md`
|
||||
11. `RELEASE_INTEGRITY_AND_TRANSPARENCY.md`
|
||||
12. `SECURITY_LIMITS_AND_OPEN_GAPS.md`
|
||||
129
문서/117-public-doc-surface-and-link-plan.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# 공개 문서 표면 및 링크 체인 기획
|
||||
|
||||
이 문서는 공개 레포에 기술/보안 문서를 넣을 때 독자가 길을 잃지 않도록 문서 표면을 어떻게 재배치할지 정리한다.
|
||||
|
||||
## 기본 정보구조
|
||||
|
||||
공개 표면의 기본 축은 아래 다섯 개다.
|
||||
|
||||
- `README.md`: 첫 진입
|
||||
- `PROJECT_STATUS.md`: 현재 상태
|
||||
- `SECURITY.md`: 보안 허브
|
||||
- `DEVELOPMENT.md`: 개발 허브
|
||||
- `RELEASING.md`: 릴리즈 허브
|
||||
|
||||
여기에 새 문서군을 추가한다.
|
||||
|
||||
- 기술 문서군
|
||||
- `문서/TECHNICAL_ARCHITECTURE.md`
|
||||
- `문서/AUTH_AND_BOOTSTRAP_FLOW.md`
|
||||
- `문서/CONVERSATION_AND_MESSAGE_MODEL.md`
|
||||
- `문서/REALTIME_SYNC_AND_CLIENT_STATE.md`
|
||||
- `문서/CLIENT_SURFACES_DESKTOP_AND_WEB.md`
|
||||
- 보안 문서군
|
||||
- `문서/APPLIED_SECURITY_CONTROLS.md`
|
||||
- `문서/SECURITY_THREAT_MODEL.md`
|
||||
- `문서/SECURITY_OPERATING_PRACTICE.md`
|
||||
- `문서/SELF_HOSTING_AND_INTERNAL_NETWORK_PROFILE.md`
|
||||
- `문서/PRIVACY_AND_DATA_HANDLING_PROFILE.md`
|
||||
- `문서/RELEASE_INTEGRITY_AND_TRANSPARENCY.md`
|
||||
- `문서/SECURITY_LIMITS_AND_OPEN_GAPS.md`
|
||||
|
||||
## README 상단에 고정할 링크 체인
|
||||
|
||||
상단 문서 지도에는 아래 다섯 개를 먼저 둔다.
|
||||
|
||||
- `프로젝트 한눈에 보기` -> `PROJECT_STATUS.md`
|
||||
- `보안이 먼저다` -> `SECURITY.md`
|
||||
- `기술/로직 상세보기` -> `문서/TECHNICAL_ARCHITECTURE.md`
|
||||
- `로컬 실행/개발` -> `DEVELOPMENT.md`
|
||||
- `배포 최신 정책` -> `RELEASING.md`
|
||||
|
||||
그 아래 “심화 읽기” 구역에서 두 갈래로 나눈다.
|
||||
|
||||
- 기술 갈래
|
||||
- `TECHNICAL_ARCHITECTURE.md`
|
||||
- `AUTH_AND_BOOTSTRAP_FLOW.md`
|
||||
- `CONVERSATION_AND_MESSAGE_MODEL.md`
|
||||
- `REALTIME_SYNC_AND_CLIENT_STATE.md`
|
||||
- `CLIENT_SURFACES_DESKTOP_AND_WEB.md`
|
||||
- 보안 갈래
|
||||
- `APPLIED_SECURITY_CONTROLS.md`
|
||||
- `SECURITY_THREAT_MODEL.md`
|
||||
- `SECURITY_OPERATING_PRACTICE.md`
|
||||
- `SELF_HOSTING_AND_INTERNAL_NETWORK_PROFILE.md`
|
||||
- `SECURITY_LIMITS_AND_OPEN_GAPS.md`
|
||||
|
||||
## 기존 문서에 넣을 역링크
|
||||
|
||||
### `PROJECT_STATUS.md`
|
||||
|
||||
- 상태표 아래에 `기술/보안 상세 읽기` 소제목 추가
|
||||
- `기술 구현 흐름` -> `문서/AUTH_AND_BOOTSTRAP_FLOW.md`
|
||||
- `실시간과 복구` -> `문서/REALTIME_SYNC_AND_CLIENT_STATE.md`
|
||||
- `현재 보안 한계` -> `문서/SECURITY_LIMITS_AND_OPEN_GAPS.md`
|
||||
|
||||
### `SECURITY.md`
|
||||
|
||||
- `빠른 이동` 섹션 추가
|
||||
- `적용된 통제` -> `문서/APPLIED_SECURITY_CONTROLS.md`
|
||||
- `위협 모델` -> `문서/SECURITY_THREAT_MODEL.md`
|
||||
- `운영 보안` -> `문서/SECURITY_OPERATING_PRACTICE.md`
|
||||
- `릴리즈 검증` -> `문서/RELEASE_INTEGRITY_AND_TRANSPARENCY.md`
|
||||
|
||||
### `DEVELOPMENT.md`
|
||||
|
||||
- `관련 로직 문서` 섹션 추가
|
||||
- `가입/세션` -> `문서/AUTH_AND_BOOTSTRAP_FLOW.md`
|
||||
- `실시간/상태 저장` -> `문서/REALTIME_SYNC_AND_CLIENT_STATE.md`
|
||||
- `임시 테스트 참여키 운용` -> `문서/SECURITY_OPERATING_PRACTICE.md`
|
||||
|
||||
### `RELEASING.md`
|
||||
|
||||
- `릴리즈 검증 읽기` 섹션 추가
|
||||
- `릴리즈 무결성` -> `문서/RELEASE_INTEGRITY_AND_TRANSPARENCY.md`
|
||||
- `현재 상태` -> `PROJECT_STATUS.md`
|
||||
- `보안 운영` -> `문서/SECURITY_OPERATING_PRACTICE.md`
|
||||
|
||||
## 독자별 읽기 순서
|
||||
|
||||
일반 공개 독자:
|
||||
|
||||
1. `README.md`
|
||||
2. `PROJECT_STATUS.md`
|
||||
3. `SECURITY.md`
|
||||
4. `RELEASING.md`
|
||||
|
||||
기여자:
|
||||
|
||||
1. `README.md`
|
||||
2. `DEVELOPMENT.md`
|
||||
3. `문서/TECHNICAL_ARCHITECTURE.md`
|
||||
4. `문서/REALTIME_SYNC_AND_CLIENT_STATE.md`
|
||||
|
||||
보안/기관 검토자:
|
||||
|
||||
1. `SECURITY.md`
|
||||
2. `문서/APPLIED_SECURITY_CONTROLS.md`
|
||||
3. `문서/SECURITY_THREAT_MODEL.md`
|
||||
4. `문서/SECURITY_OPERATING_PRACTICE.md`
|
||||
5. `PROJECT_STATUS.md`
|
||||
|
||||
## 링크 규칙
|
||||
|
||||
- 공개 문서는 상대경로를 우선한다.
|
||||
- 같은 문서에 링크를 과하게 나열하지 않는다. `관련 문서`는 3개 정도로 제한한다.
|
||||
- `README.md`에서는 절대 URL보다 repo-relative 링크를 우선 사용한다.
|
||||
- 기술 문서와 보안 문서는 서로 링크하되, 역할이 겹치지 않게 한다.
|
||||
|
||||
## 공개 표면에서 피할 것
|
||||
|
||||
- 내부 전략 메모 성격의 문장
|
||||
- 비밀값, 실제 운영 참여키, 관리자 주소
|
||||
- “완벽”, “근본적 해결”, “기관급 충족” 같은 단정형 표현
|
||||
|
||||
## 바로 다음 단계
|
||||
|
||||
- 기획 문서를 먼저 `문서/`에 정리
|
||||
- 이후 공개 문서 초안을 루트 문서와 `문서/`에 나눠 도입
|
||||
- README / SECURITY / DEVELOPMENT / RELEASING 역링크까지 함께 정리
|
||||
89
문서/118-public-technical-security-doc-roadmap.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# 공개 기술·보안 문서 도입 로드맵
|
||||
|
||||
이 문서는 공개 레포에 기술/보안 문서를 실제로 도입할 때의 순서를 정리한다.
|
||||
|
||||
핵심 기준은 세 가지다.
|
||||
|
||||
- 이미 코드에서 증명 가능한 항목부터 공개한다.
|
||||
- 보안 문서는 미사여구보다 경계와 제한을 먼저 밝힌다.
|
||||
- README와 상태 문서가 새 문서군으로 자연스럽게 연결돼야 한다.
|
||||
|
||||
## 1단계: 바로 공개 가능한 문서
|
||||
|
||||
현재 소스 기준으로 근거가 충분한 문서들이다.
|
||||
|
||||
- `문서/TECHNICAL_ARCHITECTURE.md`
|
||||
- `문서/AUTH_AND_BOOTSTRAP_FLOW.md`
|
||||
- `문서/CONVERSATION_AND_MESSAGE_MODEL.md`
|
||||
- `문서/APPLIED_SECURITY_CONTROLS.md`
|
||||
- `문서/SECURITY_THREAT_MODEL.md`
|
||||
|
||||
이 단계의 목적:
|
||||
|
||||
- 구조와 흐름을 먼저 공개한다.
|
||||
- “이미 있는 것”을 바탕으로 신뢰를 만든다.
|
||||
|
||||
## 2단계: 운영/배포 문서 확장
|
||||
|
||||
현재 스크립트와 배포 구조를 근거로 쓸 수 있는 문서들이다.
|
||||
|
||||
- `문서/REALTIME_SYNC_AND_CLIENT_STATE.md`
|
||||
- `문서/SECURITY_OPERATING_PRACTICE.md`
|
||||
- `문서/RELEASE_INTEGRITY_AND_TRANSPARENCY.md`
|
||||
- `문서/SELF_HOSTING_AND_INTERNAL_NETWORK_PROFILE.md`
|
||||
|
||||
이 단계의 목적:
|
||||
|
||||
- 운영자와 기술 검토자가 “어떻게 띄우고, 어떻게 검증하는지”를 볼 수 있게 한다.
|
||||
|
||||
## 3단계: 제한과 장기 계획 분리
|
||||
|
||||
과장 방지를 위해 별도로 두어야 하는 문서들이다.
|
||||
|
||||
- `문서/CLIENT_SURFACES_DESKTOP_AND_WEB.md`
|
||||
- `문서/PRIVACY_AND_DATA_HANDLING_PROFILE.md`
|
||||
- `문서/SECURITY_LIMITS_AND_OPEN_GAPS.md`
|
||||
|
||||
이 단계의 목적:
|
||||
|
||||
- 사용자 체감 이점과 현재 한계를 같은 저장소 안에서 정직하게 보여 준다.
|
||||
|
||||
## 문서별 독자 매핑
|
||||
|
||||
| 문서군 | 주 독자 | 저장소에서 얻는 가치 |
|
||||
|---|---|---|
|
||||
| 기술 개요 | 기여자, 기술 평가자 | 구조와 책임 경계를 빠르게 파악 |
|
||||
| 가입/세션/실시간 | QA, 프런트엔드, 백엔드 | 실제 로직 흐름을 코드와 연결해 이해 |
|
||||
| 적용된 통제 / 위협 모델 | 보안 검토자, 기관/사내 검토자 | 과장 없이 현재 통제를 확인 |
|
||||
| 운영 / 릴리즈 / 자체 구축 | 운영자, 인프라 담당자 | 배포 가능성과 검증 가능성을 읽음 |
|
||||
| 한계 / 프라이버시 | 일반 독자, 평론가 | 무엇이 아직 아닌지 명확히 확인 |
|
||||
|
||||
## 공개 문장 기준
|
||||
|
||||
반드시 넣을 문장:
|
||||
|
||||
- `현재 구현 기준`
|
||||
- `이 저장소에서 확인 가능한 근거`
|
||||
- `알파 단계에서 실제 적용된 통제`
|
||||
- `운영 환경에서는 별도 보강이 필요`
|
||||
|
||||
반드시 피할 문장:
|
||||
|
||||
- `근본적으로 해결`
|
||||
- `완전한 대체`
|
||||
- `완벽한 보안`
|
||||
- `기관망 검증 완료`
|
||||
- `E2EE 수준 보호`
|
||||
|
||||
## README와 함께 바꿔야 할 부분
|
||||
|
||||
- 문서 지도 섹션에 기술/보안 상세 문서 진입점 추가
|
||||
- `Security And Trust` 섹션을 실제 세부 문서군으로 확장
|
||||
- `Reading Paths`를 독자별로 다시 나누기
|
||||
|
||||
## 완료 판단 기준
|
||||
|
||||
- README에서 기술/보안 문서 진입점이 보인다.
|
||||
- 기술 문서가 소스 근거를 최소 3개 이상 갖는다.
|
||||
- 보안 문서가 허용 가능한 주장과 금지 표현을 함께 적는다.
|
||||
- `현재 구현`과 `향후 계획`이 같은 문단에서 섞이지 않는다.
|
||||
|
|
@ -38,3 +38,10 @@
|
|||
- 이 폴더가 최종 기획 기준이다.
|
||||
- `docs/`는 공개 보조 문서와 시각 자산을 다룬다.
|
||||
- 소스 네임스페이스와 프로젝트 파일명 정렬은 별도 구현 작업에서 진행한다.
|
||||
|
||||
## 공개 문서 기획
|
||||
|
||||
- [115-public-technical-profile-plan.md](115-public-technical-profile-plan.md)
|
||||
- [116-public-security-profile-plan.md](116-public-security-profile-plan.md)
|
||||
- [117-public-doc-surface-and-link-plan.md](117-public-doc-surface-and-link-plan.md)
|
||||
- [118-public-technical-security-doc-roadmap.md](118-public-technical-security-doc-roadmap.md)
|
||||
|
|
|
|||