공개: alpha.11 검색과 전환 개선 반영
Some checks are pending
ci / server (push) Waiting to run
ci / web (push) Waiting to run
ci / desktop-windows (push) Waiting to run

This commit is contained in:
Ian 2026-04-16 15:37:41 +09:00
commit b54eca25f8
31 changed files with 1975 additions and 364 deletions

View file

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

View file

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

View file

@ -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,53 +473,83 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
{
if (!IsAuthenticated || value is null || _session is null)
{
IsConversationLoading = false;
return;
}
Messages.Clear();
NotifyMessageStateChanged();
await RunBusyAsync(async () =>
var conversationId = value.ConversationId;
if (_isSampleWorkspace)
{
var items = await _apiClient.GetMessagesAsync(_session.ApiBaseUrl, _session.AccessToken, value.ConversationId, CancellationToken.None);
if (!string.Equals(SelectedConversation?.ConversationId, value.ConversationId, StringComparison.Ordinal))
if (_messageCache.TryGetValue(conversationId, out var sampleItems))
{
return;
ReplaceMessages(sampleItems);
}
IsConversationLoading = false;
return;
}
if (_messageCache.TryGetValue(conversationId, out var cachedItems))
{
ReplaceMessages(cachedItems);
}
else
{
Messages.Clear();
foreach (var item in items.Items)
{
Messages.Add(MapMessage(item));
}
if (Messages.Count > 0)
{
var lastSequence = Messages[^1].ServerSequence;
value.LastReadSequence = lastSequence;
value.UnreadCount = 0;
await _apiClient.UpdateReadCursorAsync(
_session.ApiBaseUrl,
_session.AccessToken,
value.ConversationId,
new UpdateReadCursorRequest(lastSequence),
CancellationToken.None);
}
NotifyConversationMetricsChanged();
NotifyMessageStateChanged();
RefreshConversationFilter(value.ConversationId);
}
if (_session is not null)
IsConversationLoading = true;
try
{
await RunBusyAsync(async () =>
{
_session = _session with { LastConversationId = value.ConversationId };
if (RememberSession)
var items = await _apiClient.GetMessagesAsync(_session.ApiBaseUrl, _session.AccessToken, conversationId, CancellationToken.None);
var mappedItems = items.Items.Select(MapMessage).ToList();
if (!string.Equals(SelectedConversation?.ConversationId, conversationId, StringComparison.Ordinal))
{
await _sessionStore.SaveAsync(_session);
return;
}
ReplaceMessages(mappedItems);
_messageCache[conversationId] = CloneMessages(mappedItems);
if (Messages.Count > 0)
{
var lastSequence = Messages[^1].ServerSequence;
value.LastReadSequence = lastSequence;
value.UnreadCount = 0;
await _apiClient.UpdateReadCursorAsync(
_session.ApiBaseUrl,
_session.AccessToken,
conversationId,
new UpdateReadCursorRequest(lastSequence),
CancellationToken.None);
}
NotifyConversationMetricsChanged();
NotifyMessageStateChanged();
RefreshConversationFilter(conversationId);
if (_session is not null)
{
_session = _session with { LastConversationId = conversationId };
if (RememberSession)
{
await _sessionStore.SaveAsync(_session);
}
}
}, clearMessages: false);
}
finally
{
if (string.Equals(SelectedConversation?.ConversationId, conversationId, StringComparison.Ordinal))
{
IsConversationLoading = false;
}
}, clearMessages: 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) ||
item.LastMessageText.Contains(search, StringComparison.CurrentCultureIgnoreCase) ||
item.Subtitle.Contains(search, StringComparison.CurrentCultureIgnoreCase);
if (item.Title.Contains(search, StringComparison.CurrentCultureIgnoreCase) ||
item.LastMessageText.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()

View file

@ -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="메시지"

View file

@ -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="서버를 바꿀 때만 입력"
Text="{Binding ApiBaseUrl}" />
<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,60 +374,72 @@
<ItemsControl ItemsSource="{Binding FilteredConversations}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConversationRowViewModel">
<Button Classes="row-button"
Margin="0,0,0,6"
Command="{Binding $parent[Window].DataContext.SelectConversationCommand}"
CommandParameter="{Binding .}">
<Border Classes="row-card"
Classes.active="{Binding IsSelected}"
Padding="{Binding $parent[Window].DataContext.ConversationRowPadding}">
<Grid ColumnDefinitions="Auto,*,Auto"
RowDefinitions="Auto,Auto"
ColumnSpacing="9"
RowSpacing="2">
<Border Width="{Binding $parent[Window].DataContext.ConversationAvatarSize}"
Height="{Binding $parent[Window].DataContext.ConversationAvatarSize}"
Classes="surface-muted">
<TextBlock HorizontalAlignment="Center"
VerticalAlignment="Center"
Text="{Binding AvatarText}"
FontWeight="SemiBold"
Foreground="#20242B" />
</Border>
<TextBlock Grid.Column="1"
Text="{Binding Title}"
Classes="body"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2"
Text="{Binding MetaText}"
Classes="caption" />
<TextBlock Grid.Row="1"
Grid.Column="1"
Text="{Binding LastMessageText}"
Classes="caption"
TextTrimming="CharacterEllipsis" />
<StackPanel Grid.Row="1"
Grid.Column="2"
Orientation="Horizontal"
Spacing="6"
HorizontalAlignment="Right">
<TextBlock Text="★"
Classes="caption"
IsVisible="{Binding IsPinned}" />
<Border Classes="unread-badge"
IsVisible="{Binding HasUnread}">
<TextBlock Text="{Binding UnreadBadgeText}"
FontSize="10.5"
Foreground="#FFFFFF" />
<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"
Classes.active="{Binding IsSelected}"
Padding="{Binding $parent[Window].DataContext.ConversationRowPadding}">
<Grid ColumnDefinitions="Auto,*,Auto"
RowDefinitions="Auto,Auto"
ColumnSpacing="9"
RowSpacing="2">
<Border Width="{Binding $parent[Window].DataContext.ConversationAvatarSize}"
Height="{Binding $parent[Window].DataContext.ConversationAvatarSize}"
Classes="surface-muted">
<TextBlock HorizontalAlignment="Center"
VerticalAlignment="Center"
Text="{Binding AvatarText}"
FontWeight="SemiBold"
Foreground="#20242B" />
</Border>
</StackPanel>
</Grid>
</Border>
</Button>
<TextBlock Grid.Column="1"
Text="{Binding Title}"
Classes="body"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2"
Text="{Binding MetaText}"
Classes="caption" />
<TextBlock Grid.Row="1"
Grid.Column="1"
Text="{Binding LastMessageText}"
Classes="caption"
TextTrimming="CharacterEllipsis" />
<StackPanel Grid.Row="1"
Grid.Column="2"
Orientation="Horizontal"
Spacing="6"
HorizontalAlignment="Right">
<TextBlock Text="★"
Classes="caption"
IsVisible="{Binding IsPinned}" />
<Border Classes="unread-badge"
IsVisible="{Binding HasUnread}">
<TextBlock Text="{Binding UnreadBadgeText}"
FontSize="10.5"
Foreground="#FFFFFF" />
</Border>
</StackPanel>
</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"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,31 +1466,31 @@ function App() {
/>
</label>
<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 ? '기본 보기' : '고급'}
{showAdvanced ? '옵션 닫기' : '옵션'}
</button>
{showAdvanced ? (
<label className="field">
<input
aria-label="서버 주소"
inputMode="url"
placeholder="서버 주소"
value={apiBaseUrl}
onChange={(event) => setApiBaseUrl(event.target.value)}
/>
</label>
<>
<label className="field">
<input
aria-label="참여 키"
autoCapitalize="characters"
placeholder="참여 키"
value={inviteCode}
onChange={(event) => setInviteCode(event.target.value.toUpperCase())}
/>
</label>
<label className="field">
<input
aria-label="서버 주소"
inputMode="url"
placeholder="서버 주소"
value={apiBaseUrl}
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>
<span className="mini-pill">{CONNECTION_LABEL[connectionState]}</span>
<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">

View file

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

View file

@ -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 '초대코드를 다시 확인해 주세요.'
}

View file

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