feat: add chat UI with unread badges, paging, images and reactions

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 08:48:07 +02:00
1 parent 65d4f13773
commit e802ccf760
15 files changed
+1714 -8

No files matched your search

@@ -0,0 +1,50 @@
using System.Globalization;
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Shared.Chat;
namespace FamilyPlanner.Web.Client.Components.Chat;
/// <summary>Anzeigehilfen des Chats (deutsche Texte, Familienzeitzone Europe/Berlin).</summary>
public static class ChatFormat
{
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
public static DateTime Local(DateTimeOffset value) => TimeZoneInfo.ConvertTime(value, FamilyTime.Berlin).DateTime;
public static string Time(DateTimeOffset value) => Local(value).ToString("HH:mm", German);
/// <summary>Trenner zwischen Tagen: „Heute“, „Gestern“, „Mo, 12.10.“ bzw. mit Jahr.</summary>
public static string Day(DateOnly day, DateOnly today) => (today.DayNumber - day.DayNumber) switch
{
0 => "Heute",
1 => "Gestern",
_ when day.Year == today.Year => day.ToString("ddd, dd.MM.", German),
_ => day.ToString("dd.MM.yyyy", German),
};
public static DateOnly DayOf(DateTimeOffset value) => DateOnly.FromDateTime(Local(value));
/// <summary>Kurzvorschau in der Kanalliste.</summary>
public static string Preview(ChatPreviewDto? preview, Guid currentUserId, string? senderName)
{
if (preview is null)
{
return "Noch keine Nachrichten";
}
var content = preview.HasImage ? (string.IsNullOrWhiteSpace(preview.Text) ? "📷 Foto" : $"📷 {preview.Text}") : preview.Text ?? string.Empty;
var prefix = preview.SenderId == currentUserId ? "Du: " : senderName is null ? string.Empty : $"{senderName}: ";
return prefix + content.ReplaceLineEndings(" ");
}
/// <summary>„Mia schreibt …“, „Mia und Ben schreiben …“, „3 Personen schreiben …“.</summary>
public static string Typing(IReadOnlyList<string> names) => names.Count switch
{
0 => string.Empty,
1 => $"{names[0]} schreibt …",
2 => $"{names[0]} und {names[1]} schreiben …",
_ => $"{names.Count} Personen schreiben …",
};
public static string UnreadLabel(int count) => count == 1 ? "1 ungelesene Nachricht" : $"{count} ungelesene Nachrichten";
}
@@ -0,0 +1,111 @@
@using FamilyPlanner.Shared.Chat
@using FamilyPlanner.Domain.Chat
<li class="@($"fp-chat-msg {(IsOwn ? "is-own" : "is-other")}")" id="@($"msg-{Message.Id}")" data-message-id="@Message.Id">
@if (!IsOwn && ShowSender)
{
<UserAvatarView Name="@Message.SenderName" Color="@(SenderColor ?? "#9E9E9E")" AvatarUrl="@SenderAvatarUrl" Size="Size.Small" />
}
<div class="fp-chat-bubble-wrap">
@if (!IsOwn && ShowSender)
{
<span class="fp-chat-sender" style="@($"color:{SenderColor ?? "inherit"}")">@Message.SenderName</span>
}
<div class="fp-chat-bubble">
@if (Message.ImageUrl is { } url)
{
<a href="@url" target="_blank" rel="noopener" class="fp-chat-image-link" aria-label="@($"Bild von {Message.SenderName} öffnen")">
<img src="@url" alt="@(string.IsNullOrWhiteSpace(Message.Text) ? $"Bild von {Message.SenderName}" : Message.Text)" class="fp-chat-image" loading="lazy" />
</a>
}
@if (!string.IsNullOrWhiteSpace(Message.Text))
{
<span class="fp-chat-text">@Message.Text</span>
}
<span class="fp-chat-meta">
<span class="fp-chat-time">@ChatFormat.Time(Message.CreatedAt)</span>
@if (IsOwn)
{
@if (ReadCount > 0)
{
<span class="fp-chat-read" title="@ReadTitle" aria-label="@ReadTitle">
<MudIcon Icon="@Icons.Material.Filled.DoneAll" Size="Size.Small" Color="Color.Info" />
@if (ShowReadCount)
{
<span>@ReadCount</span>
}
</span>
}
else
{
<span class="fp-chat-sent" title="Gesendet" aria-label="Gesendet"><MudIcon Icon="@Icons.Material.Filled.Done" Size="Size.Small" /></span>
}
}
</span>
</div>
<div class="fp-chat-actions">
@foreach (var reaction in Message.Reactions)
{
var mine = reaction.UserIds.Contains(CurrentUserId);
<button type="button" class="@($"fp-chat-reaction {(mine ? "is-mine" : string.Empty)}")" aria-pressed="@(mine ? "true" : "false")"
aria-label="@($"{reaction.Emoji} {reaction.UserIds.Count}, {(mine ? "Reaktion entfernen" : "auch reagieren")}")"
@onclick="() => OnReact.InvokeAsync(reaction.Emoji)">
<span>@reaction.Emoji</span><span class="fp-chat-reaction-count">@reaction.UserIds.Count</span>
</button>
}
<MudMenu Icon="@Icons.Material.Filled.AddReaction" Size="Size.Small" Dense="true" AriaLabel="Reagieren" Class="fp-chat-react-menu"
AnchorOrigin="Origin.TopCenter" TransformOrigin="Origin.BottomCenter">
<div class="fp-chat-emoji-picker" role="group" aria-label="Reaktion wählen">
@foreach (var emoji in ChatRules.Reactions)
{
<button type="button" class="fp-chat-emoji" aria-label="@emoji" @onclick="() => OnReact.InvokeAsync(emoji)">@emoji</button>
}
</div>
</MudMenu>
@if (CanDelete)
{
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline" Size="Size.Small" aria-label="Nachricht löschen" Class="fp-chat-delete"
OnClick="() => OnDelete.InvokeAsync()" />
}
</div>
</div>
</li>
@code {
[Parameter, EditorRequired]
public ChatMessageDto Message { get; set; } = default!;
[Parameter]
public Guid CurrentUserId { get; set; }
/// <summary>Absender und Farbe nur im Familienchat zeigen (Direktnachrichten haben nur ein Gegenüber).</summary>
[Parameter]
public bool ShowSender { get; set; }
[Parameter]
public string? SenderColor { get; set; }
[Parameter]
public string? SenderAvatarUrl { get; set; }
/// <summary>Wie viele andere Personen die Nachricht gelesen haben.</summary>
[Parameter]
public int ReadCount { get; set; }
[Parameter]
public bool ShowReadCount { get; set; }
[Parameter]
public bool CanDelete { get; set; }
[Parameter]
public EventCallback<string> OnReact { get; set; }
[Parameter]
public EventCallback OnDelete { get; set; }
private bool IsOwn => Message.SenderId == CurrentUserId;
private string ReadTitle => ShowReadCount ? $"Gelesen von {ReadCount}" : "Gelesen";
}
@@ -0,0 +1,39 @@
@using FamilyPlanner.Domain
@using FamilyPlanner.Shared.Admin
<MudDialog>
<DialogContent>
@if (Candidates.Count == 0)
{
<MudText Class="fp-chat-no-candidates">Es gibt niemanden, dem du schreiben kannst.</MudText>
}
else
{
<MudList T="Guid" Class="fp-chat-candidates" aria-label="Person auswählen">
@foreach (var person in Candidates)
{
<MudListItem T="Guid" OnClick="() => Select(person)" Class="fp-chat-candidate">
<div class="d-flex align-center gap-3">
<UserAvatarView Name="@person.DisplayName" Color="@person.Color" AvatarUrl="@person.AvatarUrl" Size="Size.Small" />
<span>@person.DisplayName</span>
<MudText Typo="Typo.caption" Class="ml-auto">@Roles.DisplayName(person.Role)</MudText>
</div>
</MudListItem>
}
</MudList>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="() => Dialog?.Cancel()">Abbrechen</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
[Parameter]
public IReadOnlyList<UserDirectoryEntry> Candidates { get; set; } = [];
private void Select(UserDirectoryEntry person) => Dialog?.Close(DialogResult.Ok(person.Id));
}
@@ -1,3 +1,6 @@
@implements IDisposable
@inject ChatStore ChatStore
@* Nur auf kleinen Bildschirmen: große Touch-Ziele für die wichtigsten Bereiche. *@
<MudHidden Breakpoint="Breakpoint.SmAndDown" Invert="true">
<nav class="fp-bottom-nav mud-elevation-4" aria-label="Schnellnavigation">
@@ -5,9 +8,27 @@
{
<NavLink href="@item.Href" class="fp-bottom-nav-item"
Match="@(item.Href.Length == 0 ? NavLinkMatch.All : NavLinkMatch.Prefix)">
<MudIcon Icon="@item.Icon" />
@if (item.Href == "chat" && ChatStore.TotalUnread > 0)
{
<MudBadge Content="@ChatStore.TotalUnread" Color="Color.Primary" Overlap="true" Class="fp-nav-unread"
aria-label="@(ChatStore.TotalUnread == 1 ? "1 ungelesene Nachricht" : $"{ChatStore.TotalUnread} ungelesene Nachrichten")">
<MudIcon Icon="@item.Icon" />
</MudBadge>
}
else
{
<MudIcon Icon="@item.Icon" />
}
<span>@item.ShortTitle</span>
</NavLink>
}
</nav>
</MudHidden>
@code {
protected override void OnInitialized() => ChatStore.Changed += OnChatChanged;
private void OnChatChanged() => InvokeAsync(StateHasChanged);
public void Dispose() => ChatStore.Changed -= OnChatChanged;
}
@@ -1,6 +1,7 @@
@inherits LayoutComponentBase
@implements IDisposable
@inject ThemeState ThemeState
@inject ChatStore ChatStore
<MudLayout Class="fp-app">
<MudAppBar Elevation="1">
@@ -33,7 +34,13 @@
@code {
private bool _drawerOpen = true;
protected override void OnInitialized() => ThemeState.Changed += OnThemeChanged;
protected override async Task OnInitializedAsync()
{
ThemeState.Changed += OnThemeChanged;
// Ungelesen-Zähler der Navigation: die Chat-Verbindung läuft für die ganze Sitzung.
await ChatStore.InitializeAsync();
}
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
@@ -1,10 +1,20 @@
@implements IDisposable
@inject ChatStore ChatStore
<MudNavMenu Class="pt-2" aria-label="Hauptnavigation">
@foreach (var item in NavItems.All)
{
if (item.Policy is null)
{
<MudNavLink Href="@item.Href" Icon="@item.Icon"
Match="@(item.Href.Length == 0 ? NavLinkMatch.All : NavLinkMatch.Prefix)">@item.Title</MudNavLink>
Match="@(item.Href.Length == 0 ? NavLinkMatch.All : NavLinkMatch.Prefix)">
@item.Title
@if (item.Href == "chat" && ChatStore.TotalUnread > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Primary" Class="ml-2 fp-nav-unread"
aria-label="@(ChatStore.TotalUnread == 1 ? "1 ungelesene Nachricht" : $"{ChatStore.TotalUnread} ungelesene Nachrichten")">@ChatStore.TotalUnread</MudChip>
}
</MudNavLink>
}
else
{
@@ -15,3 +25,11 @@
}
}
</MudNavMenu>
@code {
protected override void OnInitialized() => ChatStore.Changed += OnChatChanged;
private void OnChatChanged() => InvokeAsync(StateHasChanged);
public void Dispose() => ChatStore.Changed -= OnChatChanged;
}
+440 -4
View File
@@ -1,7 +1,443 @@
@page "/chat"
@page "/chat/{ChannelId:guid}"
@using FamilyPlanner.Domain.Authorization
@using FamilyPlanner.Domain.Chat
@using FamilyPlanner.Shared.Admin
@using FamilyPlanner.Shared.Chat
@using FamilyPlanner.Web.Client.Components.Chat
@implements IAsyncDisposable
@inject ChatStore Store
@inject IChatApi Api
@inject IJSRuntime JS
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject NavigationManager Navigation
@inject TimeProvider TimeProvider
@inject ApiAuthenticationStateProvider AuthState
<PageTitle>Chat – Familienplaner</PageTitle>
<PageTitle>@(Store.TotalUnread > 0 ? $"({Store.TotalUnread}) " : string.Empty)Chat – Familienplaner</PageTitle>
<MudText Typo="Typo.h4" HtmlTag="h1" Class="mb-4">Chat</MudText>
<ComingSoon Title="Familienchat" Phase="8" Icon="@Icons.Material.Filled.Chat"
Description="Gruppenchat für die Familie und Direktnachrichten – in Echtzeit." />
<div class="@($"fp-chat {(ChannelId is null ? "show-list" : "show-conversation")}")">
<section class="fp-chat-list-pane" aria-label="Unterhaltungen">
<div class="d-flex align-center gap-2 mb-2">
<MudText Typo="Typo.h4" HtmlTag="h1">Chat</MudText>
<MudSpacer />
@if (CanDirect)
{
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="StartDirectAsync"
Class="fp-chat-new">Neue Unterhaltung</MudButton>
}
</div>
@if (Store.IsOffline)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2 fp-chat-offline">Keine Verbindung – der Chat funktioniert nur online.</MudAlert>
}
@if (Store.IsLoading)
{
<LoadingIndicator />
}
else if (Store.Channels.Count == 0)
{
<MudText Class="fp-chat-empty">
@(CanFamilyChat || CanDirect ? "Noch keine Unterhaltungen." : "Der Chat ist für dich nicht freigeschaltet.")
</MudText>
}
else
{
<ul class="fp-chat-channels">
@foreach (var channel in Store.Channels)
{
<li>
<a href="@($"chat/{channel.Id}")" class="@($"fp-chat-channel {(channel.Id == ChannelId ? "is-active" : string.Empty)}")"
aria-current="@(channel.Id == ChannelId ? "page" : null)"
aria-label="@ChannelLabel(channel)" data-channel-id="@channel.Id">
@if (channel.Kind == ChatChannelKind.Family)
{
<MudAvatar Size="Size.Medium" Color="Color.Primary"><MudIcon Icon="@Icons.Material.Filled.Groups" /></MudAvatar>
}
else
{
var partner = Person(channel.PartnerId);
<UserAvatarView Name="@channel.Title" Color="@(partner?.Color ?? "#9E9E9E")" AvatarUrl="@partner?.AvatarUrl" />
}
<span class="fp-chat-channel-body">
<span class="fp-chat-channel-title">@channel.Title</span>
<span class="fp-chat-channel-preview">@ChatFormat.Preview(channel.LastMessage, CurrentUserId, channel.LastMessage is { } p ? Person(p.SenderId)?.DisplayName : null)</span>
</span>
<span class="fp-chat-channel-meta">
@if (channel.LastMessageAt is { } at)
{
<span class="fp-chat-channel-time">@ChatFormat.Day(ChatFormat.DayOf(at), Today)</span>
}
@if (channel.UnreadCount > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Primary" Class="fp-chat-unread">@channel.UnreadCount</MudChip>
}
</span>
</a>
</li>
}
</ul>
}
</section>
<section class="fp-chat-conv-pane" aria-label="Unterhaltung">
@if (ChannelId is null)
{
<div class="fp-chat-placeholder">
<MudIcon Icon="@Icons.Material.Filled.Chat" Size="Size.Large" />
<MudText>Wähle eine Unterhaltung aus.</MudText>
</div>
}
else if (Store.SelectedChannel is not { } channel)
{
@if (Store.IsLoadingConversation || Store.IsLoading)
{
<LoadingIndicator />
}
else
{
<MudAlert Severity="Severity.Info" Class="fp-chat-missing">Diese Unterhaltung gibt es nicht oder du hast keinen Zugriff.</MudAlert>
<MudButton Href="chat" Variant="Variant.Text">Zurück zur Übersicht</MudButton>
}
}
else
{
<header class="fp-chat-header">
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="chat" Class="fp-chat-back" aria-label="Zurück zu den Unterhaltungen" />
<MudText Typo="Typo.h6" HtmlTag="h2">@channel.Title</MudText>
</header>
<div class="fp-chat-messages" id="@ListId" tabindex="0">
@if (Store.HasMore)
{
<div class="text-center py-2">
<MudButton Variant="Variant.Text" Size="Size.Small" OnClick="LoadOlderAsync" Disabled="Store.IsLoadingOlder" Class="fp-chat-older">
@(Store.IsLoadingOlder ? "Wird geladen …" : "Ältere Nachrichten laden")
</MudButton>
</div>
}
@if (Store.IsLoadingConversation)
{
<LoadingIndicator />
}
else if (Store.Messages.Count == 0)
{
<MudText Class="fp-chat-empty text-center py-6">Noch keine Nachrichten – schreib die erste!</MudText>
}
<ol class="fp-chat-log" role="log" aria-live="polite" aria-label="Nachrichten">
@{
DateOnly? previousDay = null;
}
@foreach (var message in Store.Messages)
{
var day = ChatFormat.DayOf(message.CreatedAt);
@if (previousDay != day)
{
<li class="fp-chat-day" role="separator"><span>@ChatFormat.Day(day, Today)</span></li>
previousDay = day;
}
var sender = Person(message.SenderId);
<ChatMessageView @key="message.Id" Message="message" CurrentUserId="CurrentUserId"
ShowSender="@(channel.Kind == ChatChannelKind.Family)" SenderColor="@sender?.Color" SenderAvatarUrl="@sender?.AvatarUrl"
ReadCount="ReadCount(message)" ShowReadCount="@(channel.Kind == ChatChannelKind.Family)"
CanDelete="@ChatRules.CanDelete(message.SenderId, CurrentUserId, IsAdmin, channel.Kind)"
OnReact="emoji => ReactAsync(message, emoji)" OnDelete="() => DeleteAsync(message)" />
}
</ol>
</div>
<div class="fp-chat-typing" role="status" aria-live="polite">@TypingText(channel.Id)</div>
@if (channel.CanSend)
{
<form class="fp-chat-composer" @onsubmit="SendAsync" @onsubmit:preventDefault="true">
<label class="fp-chat-attach" title="Bild senden">
<InputFile OnChange="SendImageAsync" accept="image/png,image/jpeg,image/webp" aria-label="Bild senden" disabled="@_sending" />
<MudIcon Icon="@Icons.Material.Filled.AddPhotoAlternate" />
</label>
<MudTextField T="string" Value="_draft" ValueChanged="OnDraftChangedAsync" Immediate="true" Variant="Variant.Outlined" Margin="Margin.Dense"
Placeholder="Nachricht schreiben …" MaxLength="@ChatMessage.TextMaxLength" Disabled="_sending" Class="fp-chat-input"
InputId="chat-input" aria-label="Nachricht" AutoFocus="true" />
<MudIconButton ButtonType="ButtonType.Submit" Icon="@Icons.Material.Filled.Send" Color="Color.Primary" Disabled="@(_sending || string.IsNullOrWhiteSpace(_draft))"
aria-label="Senden" Class="fp-chat-send" />
</form>
}
else
{
<MudAlert Severity="Severity.Info" Dense="true" Class="fp-chat-cannot-send">Diese Person ist nicht mehr erreichbar – du kannst nur noch den Verlauf lesen.</MudAlert>
}
}
</section>
</div>
@code {
private const string ListId = "fp-chat-messages";
[Parameter]
public Guid? ChannelId { get; set; }
private readonly Dictionary<Guid, UserDirectoryEntry> _people = [];
private DotNetObjectReference<Chat>? _reference;
private ITimer? _timer;
private Guid? _openedChannel;
private string _draft = string.Empty;
private bool _sending;
private bool _newConversation;
private Guid? _lastId;
private Guid? _firstId;
private Guid CurrentUserId => AuthState.CurrentUser?.Id ?? Guid.Empty;
private bool IsAdmin => AuthState.CurrentUser?.Roles.Contains(FamilyPlanner.Domain.Roles.Admin) == true;
private bool CanDirect => AuthState.CurrentUser?.Permissions?.ContainsKey(Permission.DirectMessages) == true;
private bool CanFamilyChat => AuthState.CurrentUser?.Permissions?.ContainsKey(Permission.FamilyChat) == true;
private DateOnly Today => ChatFormat.DayOf(TimeProvider.GetUtcNow());
protected override async Task OnInitializedAsync()
{
Store.Changed += OnStoreChanged;
Store.Notice += OnNotice;
_timer = TimeProvider.CreateTimer(_ => Store.PruneTyping(), null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
await Store.InitializeAsync();
var users = await Api.GetUsersAsync();
if (users.Succeeded && users.Value is { } list)
{
foreach (var user in list)
{
_people[user.Id] = user;
}
StateHasChanged();
}
}
protected override async Task OnParametersSetAsync()
{
if (ChannelId == _openedChannel)
{
return;
}
_openedChannel = ChannelId;
_draft = string.Empty;
_lastId = _firstId = null;
if (ChannelId is { } id)
{
_newConversation = true;
await Store.OpenAsync(id);
Store.IsConversationVisible = await IsTabVisibleAsync();
}
else
{
Store.Close();
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_reference = DotNetObjectReference.Create(this);
await TryJsAsync("fpChat.watchVisibility", _reference);
}
var messages = Store.Messages;
var lastId = messages.Count > 0 ? messages[^1].Id : (Guid?)null;
var firstId = messages.Count > 0 ? messages[0].Id : (Guid?)null;
if (lastId != _lastId || firstId != _firstId)
{
var appended = lastId != _lastId;
var prepended = !appended && firstId != _firstId;
(_lastId, _firstId) = (lastId, firstId);
if (appended)
{
await TryJsAsync("fpChat.scrollToEnd", ListId, _newConversation);
_newConversation = false;
}
else if (prepended)
{
await TryJsAsync("fpChat.keepPosition", ListId);
}
}
}
private async Task<bool> IsTabVisibleAsync()
{
try
{
return await JS.InvokeAsync<bool>("fpChat.isVisible");
}
catch (Exception ex) when (ex is JSException or JSDisconnectedException or InvalidOperationException)
{
return true;
}
}
private async Task TryJsAsync(string identifier, params object?[] args)
{
try
{
await JS.InvokeVoidAsync(identifier, args);
}
catch (Exception ex) when (ex is JSException or JSDisconnectedException or InvalidOperationException)
{
// Scrollen ist nur Komfort.
}
}
[JSInvokable]
public Task OnVisibilityChanged(bool visible)
{
Store.IsConversationVisible = visible && ChannelId is not null;
return Task.CompletedTask;
}
private void OnStoreChanged() => InvokeAsync(async () =>
{
await TryJsAsync("fpChat.capture", ListId); // vor dem Rendern: war der Benutzer unten?
StateHasChanged();
});
private void OnNotice(string message) => Snackbar.Add(message, Severity.Warning);
private UserDirectoryEntry? Person(Guid? id) => id is { } key && _people.TryGetValue(key, out var person) ? person : null;
private string ChannelLabel(ChatChannelDto channel) =>
channel.UnreadCount > 0 ? $"{channel.Title}, {ChatFormat.UnreadLabel(channel.UnreadCount)}" : channel.Title;
private int ReadCount(ChatMessageDto message) =>
message.SenderId != CurrentUserId ? 0 : Store.ReadStates.Count(r => r.UserId != CurrentUserId && r.LastReadAt >= message.CreatedAt);
private string TypingText(Guid channelId) =>
ChatFormat.Typing(Store.TypingUsers(channelId).Select(id => Person(id)?.DisplayName ?? "Jemand").ToList());
private async Task OnDraftChangedAsync(string value)
{
_draft = value ?? string.Empty;
if (!string.IsNullOrWhiteSpace(_draft))
{
await Store.NotifyTypingAsync();
}
}
private async Task SendAsync()
{
var text = _draft.Trim();
if (_sending || text.Length == 0)
{
return;
}
_sending = true;
var error = await Store.SendAsync(text);
_sending = false;
if (error is null)
{
_draft = string.Empty;
_newConversation = true; // eigene Nachricht: immer ans Ende scrollen
}
else
{
Snackbar.Add(error, Severity.Error);
}
}
private async Task SendImageAsync(InputFileChangeEventArgs args)
{
var file = args.File;
if (_sending || file is null)
{
return;
}
if (file.Size > ChatRules.MaxImageBytes)
{
Snackbar.Add($"Das Bild ist zu groß (höchstens {ChatRules.MaxImageBytes / 1024 / 1024} MB).", Severity.Warning);
return;
}
_sending = true;
try
{
await using var stream = file.OpenReadStream(ChatRules.MaxImageBytes);
var error = await Store.SendImageAsync(stream, file.Name, file.ContentType, _draft);
if (error is null)
{
_draft = string.Empty;
_newConversation = true;
}
else
{
Snackbar.Add(error, Severity.Error);
}
}
finally
{
_sending = false;
}
}
private async Task LoadOlderAsync()
{
await TryJsAsync("fpChat.capture", ListId);
await Store.LoadOlderAsync();
}
private async Task ReactAsync(ChatMessageDto message, string emoji)
{
if (await Store.ToggleReactionAsync(message, emoji) is { } error)
{
Snackbar.Add(error, Severity.Error);
}
}
private async Task DeleteAsync(ChatMessageDto message)
{
var confirmed = await DialogService.ShowMessageBoxAsync(
"Nachricht löschen?", "Die Nachricht wird für alle entfernt.", yesText: "Löschen", cancelText: "Abbrechen");
if (confirmed != true)
{
return;
}
if (await Store.DeleteAsync(message) is { } error)
{
Snackbar.Add(error, Severity.Error);
}
}
private async Task StartDirectAsync()
{
var existing = Store.Channels.Where(c => c.PartnerId is not null).Select(c => c.PartnerId!.Value).ToHashSet();
var candidates = _people.Values
.Where(p => p.Id != CurrentUserId && p.Role != FamilyPlanner.Domain.Roles.Guest)
.OrderBy(p => p.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.ToList();
var parameters = new DialogParameters<NewChatDialog> { { d => d.Candidates, candidates } };
var dialog = await DialogService.ShowAsync<NewChatDialog>("Neue Unterhaltung", parameters, new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true });
var result = await dialog.Result;
if (result is { Canceled: false, Data: Guid userId } && await Store.StartDirectAsync(userId) is { } channel)
{
Navigation.NavigateTo($"chat/{channel.Id}");
}
}
public async ValueTask DisposeAsync()
{
Store.Changed -= OnStoreChanged;
Store.Notice -= OnNotice;
Store.Close();
_timer?.Dispose();
await TryJsAsync("fpChat.unwatchVisibility");
_reference?.Dispose();
}
}
+13 -1
View File
@@ -226,7 +226,7 @@ else
<CardHeaderContent><MudText Typo="Typo.h6" HtmlTag="h2">Nachrichten</MudText></CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudText Typo="Typo.body2">
<MudText Typo="Typo.body2" Class="fp-dash-chat-count">
@(chat.UnreadCount == 0 ? "Keine ungelesenen Nachrichten." : DashboardFormat.Count(chat.UnreadCount, "ungelesene Nachricht", "ungelesene Nachrichten"))
</MudText>
</MudCardContent>
@@ -271,6 +271,9 @@ else
Live.TaskRemoved += OnTaskRemoved;
Live.TaskPointsChanged += OnLiveEvent;
Live.CalendarChanged += OnCalendarChanged;
Live.ChatMessageReceived += OnChatMessage;
Live.ChatMessageRemoved += OnChatRemoved;
Live.ChatReadChanged += OnChatRead;
Live.Connected += RefreshAsync;
await Live.EnsureStartedAsync();
await RefreshAsync();
@@ -337,6 +340,12 @@ else
private Task OnCalendarChanged(Guid _) => ScheduleRefreshAsync();
private Task OnChatMessage(FamilyPlanner.Shared.Chat.ChatMessageDto _) => ScheduleRefreshAsync();
private Task OnChatRemoved(Guid _, Guid __) => ScheduleRefreshAsync();
private Task OnChatRead(Guid _, Guid __, DateTimeOffset ___) => ScheduleRefreshAsync();
private Task OnQuickAddKeyAsync(KeyboardEventArgs args) => args.Key == "Enter" ? QuickAddAsync() : Task.CompletedTask;
private async Task QuickAddAsync()
@@ -379,6 +388,9 @@ else
Live.TaskRemoved -= OnTaskRemoved;
Live.TaskPointsChanged -= OnLiveEvent;
Live.CalendarChanged -= OnCalendarChanged;
Live.ChatMessageReceived -= OnChatMessage;
Live.ChatMessageRemoved -= OnChatRemoved;
Live.ChatReadChanged -= OnChatRead;
Live.Connected -= RefreshAsync;
_disposed.Cancel();
_disposed.Dispose();
+2
View File
@@ -32,11 +32,13 @@ builder.Services.AddScoped<IMealsApi, MealsApi>();
builder.Services.AddScoped<ITasksApi, TasksApi>();
builder.Services.AddScoped<ICalendarApi, CalendarApi>();
builder.Services.AddScoped<IDashboardApi, DashboardApi>();
builder.Services.AddScoped<IChatApi, ChatApi>();
builder.Services.AddScoped<IPushService, PushService>();
builder.Services.AddScoped<ILocalStore, BrowserLocalStore>();
builder.Services.AddScoped<ILiveConnection, LiveConnection>();
builder.Services.AddScoped<ShoppingStore>();
builder.Services.AddScoped<TaskStore>();
builder.Services.AddScoped<ChatStore>();
builder.Services.AddScoped<ApiAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<ApiAuthenticationStateProvider>());
builder.Services.AddCascadingAuthenticationState();
@@ -0,0 +1,74 @@
using System.Net.Http.Json;
using FamilyPlanner.Shared.Admin;
using FamilyPlanner.Shared.Chat;
namespace FamilyPlanner.Web.Client.Services;
public interface IChatApi
{
Task<ApiResult<ChatChannelListDto>> GetChannelsAsync(CancellationToken ct = default);
Task<ApiResult<ChatChannelDto>> StartDirectAsync(Guid userId, CancellationToken ct = default);
/// <summary>Die neuesten <paramref name="take"/> Nachrichten bzw. die davor liegende Seite (<paramref name="before"/>).</summary>
Task<ApiResult<ChatMessagePageDto>> GetMessagesAsync(Guid channelId, Guid? before, int take, CancellationToken ct = default);
/// <summary>Wert ist <c>null</c>, wenn die Nachricht inzwischen gelöscht wurde (204).</summary>
Task<ApiResult<ChatMessageDto>> SendAsync(Guid channelId, SendChatMessageRequest request, CancellationToken ct = default);
Task<ApiResult<ChatMessageDto>> SendImageAsync(Guid channelId, Guid messageId, Stream content, string fileName, string contentType, string? caption, CancellationToken ct = default);
Task<ApiResult<bool>> MarkReadAsync(Guid channelId, Guid? upToMessageId, CancellationToken ct = default);
Task<ApiResult<bool>> DeleteAsync(Guid messageId, CancellationToken ct = default);
Task<ApiResult<List<ChatReactionDto>>> SetReactionAsync(Guid messageId, string emoji, bool active, CancellationToken ct = default);
Task<ApiResult<List<UserDirectoryEntry>>> GetUsersAsync(CancellationToken ct = default);
}
public sealed class ChatApi(HttpClient http) : IChatApi
{
private const string Base = "api/chat";
public Task<ApiResult<ChatChannelListDto>> GetChannelsAsync(CancellationToken ct = default) =>
ApiCall.SendAsync<ChatChannelListDto>(() => http.GetAsync($"{Base}/channels", ct), ct);
public Task<ApiResult<ChatChannelDto>> StartDirectAsync(Guid userId, CancellationToken ct = default) =>
ApiCall.SendAsync<ChatChannelDto>(() => http.PostAsJsonAsync($"{Base}/direct", new StartDirectChatRequest(userId), ct), ct);
public Task<ApiResult<ChatMessagePageDto>> GetMessagesAsync(Guid channelId, Guid? before, int take, CancellationToken ct = default) =>
ApiCall.SendAsync<ChatMessagePageDto>(
() => http.GetAsync($"{Base}/channels/{channelId}/messages?take={take}{(before is { } id ? $"&before={id}" : string.Empty)}", ct), ct);
public Task<ApiResult<ChatMessageDto>> SendAsync(Guid channelId, SendChatMessageRequest request, CancellationToken ct = default) =>
ApiCall.SendAsync<ChatMessageDto>(() => http.PostAsJsonAsync($"{Base}/channels/{channelId}/messages", request, ct), ct);
public async Task<ApiResult<ChatMessageDto>> SendImageAsync(
Guid channelId, Guid messageId, Stream content, string fileName, string contentType, string? caption, CancellationToken ct = default)
{
using var form = new MultipartFormDataContent();
var file = new StreamContent(content);
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
form.Add(file, "file", fileName);
form.Add(new StringContent(messageId.ToString()), "messageId");
if (!string.IsNullOrWhiteSpace(caption))
{
form.Add(new StringContent(caption), "text");
}
return await ApiCall.SendAsync<ChatMessageDto>(() => http.PostAsync($"{Base}/channels/{channelId}/images", form, ct), ct);
}
public Task<ApiResult<bool>> MarkReadAsync(Guid channelId, Guid? upToMessageId, CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.PostAsJsonAsync($"{Base}/channels/{channelId}/read", new MarkChatReadRequest(upToMessageId), ct), ct);
public Task<ApiResult<bool>> DeleteAsync(Guid messageId, CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.DeleteAsync($"{Base}/messages/{messageId}", ct), ct);
public Task<ApiResult<List<ChatReactionDto>>> SetReactionAsync(Guid messageId, string emoji, bool active, CancellationToken ct = default) =>
ApiCall.SendAsync<List<ChatReactionDto>>(() => http.PutAsJsonAsync($"{Base}/messages/{messageId}/reactions", new SetChatReactionRequest(emoji, active), ct), ct);
public Task<ApiResult<List<UserDirectoryEntry>>> GetUsersAsync(CancellationToken ct = default) =>
ApiCall.SendAsync<List<UserDirectoryEntry>>(() => http.GetAsync("api/users", ct), ct);
}
@@ -0,0 +1,526 @@
using FamilyPlanner.Domain.Chat;
using FamilyPlanner.Shared.Chat;
using FamilyPlanner.Web.Client.Auth;
namespace FamilyPlanner.Web.Client.Services;
/// <summary>
/// Zustand des Chats für die ganze Sitzung: Kanalliste mit Ungelesen-Zählern (Navigation, Dashboard), geöffnete Unterhaltung
/// mit Paging, Live-Ereignisse, Gelesen-Stand und „tippt …“ (ADR-0019). Der Chat arbeitet nur online (keine Outbox).
/// </summary>
public sealed class ChatStore : IAsyncDisposable
{
/// <summary>So lange gilt ein „tippt …“-Signal ohne Erneuerung.</summary>
public static readonly TimeSpan TypingLifetime = TimeSpan.FromSeconds(4);
private static readonly TimeSpan TypingSendInterval = TimeSpan.FromSeconds(3);
private readonly IChatApi _api;
private readonly ILiveConnection _live;
private readonly ApiAuthenticationStateProvider _auth;
private readonly TimeProvider _time;
private readonly Dictionary<(Guid Channel, Guid User), DateTimeOffset> _typing = [];
private readonly List<ChatMessageDto> _messages = [];
private readonly List<ChatReadStateDto> _readStates = [];
private List<ChatChannelDto> _channels = [];
private DateTimeOffset _lastTypingSent = DateTimeOffset.MinValue;
private bool _initialized;
private bool _visible;
private int _refreshing;
public ChatStore(IChatApi api, ILiveConnection live, ApiAuthenticationStateProvider auth, TimeProvider time)
{
_api = api;
_live = live;
_auth = auth;
_time = time;
}
public event Action? Changed;
/// <summary>Kurzer Hinweis für Snackbar (z. B. Fehler beim Laden im Hintergrund).</summary>
public event Action<string>? Notice;
public IReadOnlyList<ChatChannelDto> Channels => _channels;
public int TotalUnread => _channels.Sum(c => c.UnreadCount);
public bool IsLoading { get; private set; }
public bool IsOffline { get; private set; }
public Guid? SelectedChannelId { get; private set; }
public ChatChannelDto? SelectedChannel => _channels.FirstOrDefault(c => c.Id == SelectedChannelId);
public IReadOnlyList<ChatMessageDto> Messages => _messages;
public IReadOnlyList<ChatReadStateDto> ReadStates => _readStates;
public bool HasMore { get; private set; }
public bool IsLoadingOlder { get; private set; }
public bool IsLoadingConversation { get; private set; }
public Guid CurrentUserId => _auth.CurrentUser?.Id ?? Guid.Empty;
/// <summary>Wird gesetzt, solange die Unterhaltung tatsächlich sichtbar ist (Seite offen, Tab im Vordergrund).</summary>
public bool IsConversationVisible
{
get => _visible;
set
{
if (_visible == value)
{
return;
}
_visible = value;
if (value)
{
_ = MarkReadAsync();
}
}
}
public async Task InitializeAsync()
{
if (_initialized)
{
return;
}
_initialized = true;
_live.ChatMessageReceived += OnMessageReceivedAsync;
_live.ChatMessageRemoved += OnMessageRemovedAsync;
_live.ChatReactionsChanged += OnReactionsChangedAsync;
_live.ChatReadChanged += OnReadChangedAsync;
_live.ChatTyping += OnTypingAsync;
_live.Connected += OnConnectedAsync;
await _live.EnsureStartedAsync();
await RefreshChannelsAsync();
}
// ---------- Kanäle ----------
public async Task RefreshChannelsAsync()
{
// Gleichzeitige Aufrufe (mehrere Ereignisse hintereinander) zu einem zusammenfassen.
if (Interlocked.Exchange(ref _refreshing, 1) == 1)
{
return;
}
try
{
IsLoading = _channels.Count == 0;
var result = await _api.GetChannelsAsync();
IsOffline = !result.Succeeded;
if (result.Succeeded && result.Value is { } list)
{
_channels = [.. list.Channels];
}
}
finally
{
IsLoading = false;
Volatile.Write(ref _refreshing, 0);
RaiseChanged();
}
}
public async Task<ChatChannelDto?> StartDirectAsync(Guid userId)
{
var result = await _api.StartDirectAsync(userId);
if (!result.Succeeded || result.Value is not { } channel)
{
Notice?.Invoke(result.Problem?.Message ?? "Die Unterhaltung konnte nicht gestartet werden.");
return null;
}
if (_channels.All(c => c.Id != channel.Id))
{
_channels = [.. _channels, channel];
RaiseChanged();
}
return channel;
}
// ---------- Unterhaltung ----------
public async Task OpenAsync(Guid channelId)
{
SelectedChannelId = channelId;
_messages.Clear();
_readStates.Clear();
HasMore = false;
IsLoadingConversation = true;
RaiseChanged();
await LoadLatestAsync(channelId);
}
public void Close()
{
SelectedChannelId = null;
_visible = false;
_messages.Clear();
_readStates.Clear();
RaiseChanged();
}
private async Task LoadLatestAsync(Guid channelId)
{
var result = await _api.GetMessagesAsync(channelId, null, ChatRules.DefaultPageSize);
if (SelectedChannelId != channelId)
{
return; // inzwischen anderer Kanal geöffnet
}
IsLoadingConversation = false;
IsOffline = !result.Succeeded;
if (result.Succeeded && result.Value is { } page)
{
// Beim erneuten Laden (Reconnect) ältere, bereits geladene Nachrichten behalten.
var older = _messages.Where(m => page.Messages.All(p => p.Id != m.Id) && (page.Messages.Count == 0 || m.CreatedAt < page.Messages[0].CreatedAt)).ToList();
var keepMore = older.Count > 0 && HasMore;
_messages.Clear();
_messages.AddRange(older);
_messages.AddRange(page.Messages);
HasMore = older.Count > 0 ? keepMore : page.HasMore;
_readStates.Clear();
_readStates.AddRange(page.ReadStates);
}
else
{
Notice?.Invoke(result.Problem?.Message ?? "Der Verlauf konnte nicht geladen werden.");
}
RaiseChanged();
if (_visible)
{
await MarkReadAsync();
}
}
public async Task LoadOlderAsync()
{
if (SelectedChannelId is not { } channelId || !HasMore || IsLoadingOlder || _messages.Count == 0)
{
return;
}
IsLoadingOlder = true;
RaiseChanged();
var result = await _api.GetMessagesAsync(channelId, _messages[0].Id, ChatRules.DefaultPageSize);
IsLoadingOlder = false;
if (SelectedChannelId == channelId && result.Succeeded && result.Value is { } page)
{
_messages.InsertRange(0, page.Messages.Where(m => _messages.All(e => e.Id != m.Id)));
HasMore = page.HasMore;
}
else if (!result.Succeeded)
{
Notice?.Invoke(result.Problem?.Message ?? "Ältere Nachrichten konnten nicht geladen werden.");
}
RaiseChanged();
}
// ---------- Senden ----------
/// <summary>Sendet Text. Liefert eine Fehlermeldung oder <c>null</c> bei Erfolg.</summary>
public async Task<string?> SendAsync(string text)
{
if (SelectedChannelId is not { } channelId)
{
return "Bitte zuerst eine Unterhaltung öffnen.";
}
var result = await _api.SendAsync(channelId, new SendChatMessageRequest(Guid.CreateVersion7(), text));
if (!result.Succeeded)
{
return result.Problem?.Message ?? "Die Nachricht konnte nicht gesendet werden.";
}
if (result.Value is { } message)
{
AddOwnMessage(message);
}
return null;
}
public async Task<string?> SendImageAsync(Stream content, string fileName, string contentType, string? caption)
{
if (SelectedChannelId is not { } channelId)
{
return "Bitte zuerst eine Unterhaltung öffnen.";
}
var result = await _api.SendImageAsync(channelId, Guid.CreateVersion7(), content, fileName, contentType, caption);
if (!result.Succeeded)
{
return result.Problem?.Message ?? "Das Bild konnte nicht gesendet werden.";
}
if (result.Value is { } message)
{
AddOwnMessage(message);
}
return null;
}
public async Task<string?> DeleteAsync(ChatMessageDto message)
{
var result = await _api.DeleteAsync(message.Id);
if (!result.Succeeded)
{
return result.Problem?.Message ?? "Die Nachricht konnte nicht gelöscht werden.";
}
RemoveMessage(message.Id);
await RefreshChannelsAsync();
return null;
}
public async Task<string?> ToggleReactionAsync(ChatMessageDto message, string emoji)
{
var active = !message.Reactions.Any(r => r.Emoji == emoji && r.UserIds.Contains(CurrentUserId));
var result = await _api.SetReactionAsync(message.Id, emoji, active);
if (!result.Succeeded)
{
return result.Problem?.Message ?? "Die Reaktion konnte nicht gespeichert werden.";
}
ApplyReactions(message.Id, result.Value ?? []);
return null;
}
// ---------- Gelesen, Tippen ----------
/// <summary>Markiert die Unterhaltung bis zur neuesten angezeigten Nachricht als gelesen (nur wenn sichtbar).</summary>
public async Task MarkReadAsync()
{
if (!_visible || SelectedChannelId is not { } channelId)
{
return;
}
var latest = _messages.LastOrDefault();
var channel = _channels.FirstOrDefault(c => c.Id == channelId);
if (latest is null || channel is null || channel.UnreadCount == 0)
{
return;
}
var result = await _api.MarkReadAsync(channelId, latest.Id);
if (result.Succeeded)
{
// Nachrichten, die erst nach dem Laden eintreffen, bleiben ungelesen (der Server markiert nur bis zu `latest`).
var newer = _messages.Count(m => m.CreatedAt > latest.CreatedAt && m.SenderId != CurrentUserId);
UpdateChannel(channelId, c => c with { UnreadCount = newer });
}
}
/// <summary>Beim Tippen aufrufen; sendet höchstens alle 3 Sekunden ein Signal.</summary>
public Task NotifyTypingAsync()
{
var now = _time.GetUtcNow();
if (SelectedChannelId is not { } channelId || now - _lastTypingSent < TypingSendInterval)
{
return Task.CompletedTask;
}
_lastTypingSent = now;
return _live.SendTypingAsync(channelId);
}
/// <summary>Personen, die gerade im Kanal tippen.</summary>
public IReadOnlyList<Guid> TypingUsers(Guid channelId)
{
var now = _time.GetUtcNow();
return _typing.Where(t => t.Key.Channel == channelId && t.Value > now).Select(t => t.Key.User).ToList();
}
/// <summary>Entfernt abgelaufene Signale; liefert <c>true</c>, wenn sich die Anzeige ändert.</summary>
public bool PruneTyping()
{
var now = _time.GetUtcNow();
var expired = _typing.Where(t => t.Value <= now).Select(t => t.Key).ToList();
foreach (var key in expired)
{
_typing.Remove(key);
}
if (expired.Count > 0)
{
RaiseChanged();
}
return expired.Count > 0;
}
// ---------- Live-Ereignisse ----------
private async Task OnMessageReceivedAsync(ChatMessageDto message)
{
_typing.Remove((message.ChannelId, message.SenderId));
if (_channels.All(c => c.Id != message.ChannelId))
{
await RefreshChannelsAsync(); // neue Direktunterhaltung
}
var isOpen = SelectedChannelId == message.ChannelId;
if (isOpen && _messages.Any(m => m.Id == message.Id))
{
return; // schon bekannt (Antwort auf eigenes Senden oder doppelt zugestellt)
}
if (isOpen)
{
AddMessage(message);
}
var mine = message.SenderId == CurrentUserId;
UpdateChannel(message.ChannelId, c => c with
{
LastMessageAt = message.CreatedAt,
LastMessage = new ChatPreviewDto(message.SenderId, message.Text, message.ImageUrl is not null, message.CreatedAt),
UnreadCount = mine || (isOpen && _visible) ? c.UnreadCount : c.UnreadCount + 1,
});
if (isOpen && _visible && !mine)
{
UpdateChannel(message.ChannelId, c => c with { UnreadCount = Math.Max(c.UnreadCount, 1) });
await MarkReadAsync();
}
}
private async Task OnMessageRemovedAsync(Guid channelId, Guid messageId)
{
RemoveMessage(messageId);
await RefreshChannelsAsync(); // Zähler und Vorschau stimmen serverseitig
}
private Task OnReactionsChangedAsync(ChatReactionsChangedDto change)
{
ApplyReactions(change.MessageId, change.Reactions);
return Task.CompletedTask;
}
private async Task OnReadChangedAsync(Guid channelId, Guid userId, DateTimeOffset at)
{
if (userId == CurrentUserId)
{
await RefreshChannelsAsync(); // auf einem anderen Gerät gelesen
return;
}
if (SelectedChannelId == channelId)
{
var index = _readStates.FindIndex(r => r.UserId == userId);
if (index < 0)
{
_readStates.Add(new ChatReadStateDto(userId, at));
}
else if (at > _readStates[index].LastReadAt)
{
_readStates[index] = new ChatReadStateDto(userId, at);
}
RaiseChanged();
}
}
private Task OnTypingAsync(Guid channelId, Guid userId)
{
if (userId != CurrentUserId)
{
_typing[(channelId, userId)] = _time.GetUtcNow() + TypingLifetime;
RaiseChanged();
}
return Task.CompletedTask;
}
private async Task OnConnectedAsync()
{
await RefreshChannelsAsync();
if (SelectedChannelId is { } channelId)
{
await LoadLatestAsync(channelId); // verpasste Nachrichten nachladen
}
}
// ---------- Hilfen ----------
private void AddOwnMessage(ChatMessageDto message)
{
AddMessage(message);
UpdateChannel(message.ChannelId, c => c with
{
LastMessageAt = message.CreatedAt,
LastMessage = new ChatPreviewDto(message.SenderId, message.Text, message.ImageUrl is not null, message.CreatedAt),
});
}
private void AddMessage(ChatMessageDto message)
{
if (SelectedChannelId != message.ChannelId || _messages.Any(m => m.Id == message.Id))
{
return;
}
var index = _messages.FindLastIndex(m => m.CreatedAt <= message.CreatedAt);
_messages.Insert(index + 1, message);
RaiseChanged();
}
private void RemoveMessage(Guid messageId)
{
if (_messages.RemoveAll(m => m.Id == messageId) > 0)
{
RaiseChanged();
}
}
private void ApplyReactions(Guid messageId, IReadOnlyList<ChatReactionDto> reactions)
{
var index = _messages.FindIndex(m => m.Id == messageId);
if (index >= 0)
{
_messages[index] = _messages[index] with { Reactions = reactions };
RaiseChanged();
}
}
private void UpdateChannel(Guid channelId, Func<ChatChannelDto, ChatChannelDto> update)
{
var index = _channels.FindIndex(c => c.Id == channelId);
if (index >= 0)
{
_channels[index] = update(_channels[index]);
RaiseChanged();
}
}
private void RaiseChanged() => Changed?.Invoke();
public ValueTask DisposeAsync()
{
if (_initialized)
{
_live.ChatMessageReceived -= OnMessageReceivedAsync;
_live.ChatMessageRemoved -= OnMessageRemovedAsync;
_live.ChatReactionsChanged -= OnReactionsChangedAsync;
_live.ChatReadChanged -= OnReadChangedAsync;
_live.ChatTyping -= OnTypingAsync;
_live.Connected -= OnConnectedAsync;
}
return ValueTask.CompletedTask;
}
}
@@ -1,4 +1,5 @@
using FamilyPlanner.Shared.Calendar;
using FamilyPlanner.Shared.Chat;
using FamilyPlanner.Shared.Meals;
using FamilyPlanner.Shared.Shopping;
using FamilyPlanner.Shared.Tasks;
@@ -45,7 +46,23 @@ public interface ILiveConnection
/// <summary>Termine/Kalender geändert (Kalender-Id, <see cref="Guid.Empty"/> = Kalenderliste/Rechte).</summary>
event Func<Guid, Task>? CalendarChanged;
event Func<ChatMessageDto, Task>? ChatMessageReceived;
/// <summary>Kanal-Id, Nachrichten-Id.</summary>
event Func<Guid, Guid, Task>? ChatMessageRemoved;
event Func<ChatReactionsChangedDto, Task>? ChatReactionsChanged;
/// <summary>Kanal-Id, Benutzer-Id, Gelesen-Stand.</summary>
event Func<Guid, Guid, DateTimeOffset, Task>? ChatReadChanged;
/// <summary>Kanal-Id, Benutzer-Id: jemand tippt.</summary>
event Func<Guid, Guid, Task>? ChatTyping;
Task EnsureStartedAsync();
/// <summary>Meldet „ich tippe“ im Kanal (best effort, Fehler werden verschluckt).</summary>
Task SendTypingAsync(Guid channelId);
}
public sealed class LiveConnection : ILiveConnection, IAsyncDisposable
@@ -71,6 +88,11 @@ public sealed class LiveConnection : ILiveConnection, IAsyncDisposable
_hub.On<Guid>(TaskEvents.TaskRemoved, id => Raise(TaskRemoved, h => h(id)));
_hub.On(TaskEvents.PointsChanged, () => Raise(TaskPointsChanged, h => h()));
_hub.On<Guid>(CalendarEvents.Changed, calendarId => Raise(CalendarChanged, h => h(calendarId)));
_hub.On<ChatMessageDto>(ChatEvents.MessageReceived, dto => Raise(ChatMessageReceived, h => h(dto)));
_hub.On<Guid, Guid>(ChatEvents.MessageRemoved, (channelId, messageId) => Raise(ChatMessageRemoved, h => h(channelId, messageId)));
_hub.On<ChatReactionsChangedDto>(ChatEvents.ReactionsChanged, dto => Raise(ChatReactionsChanged, h => h(dto)));
_hub.On<Guid, Guid, DateTimeOffset>(ChatEvents.ReadChanged, (channelId, userId, at) => Raise(ChatReadChanged, h => h(channelId, userId, at)));
_hub.On<Guid, Guid>(ChatEvents.Typing, (channelId, userId) => Raise(ChatTyping, h => h(channelId, userId)));
_hub.Reconnecting += _ => SetState(LiveState.Connecting);
_hub.Reconnected += async _ =>
@@ -115,6 +137,33 @@ public sealed class LiveConnection : ILiveConnection, IAsyncDisposable
public event Func<Guid, Task>? CalendarChanged;
public event Func<ChatMessageDto, Task>? ChatMessageReceived;
public event Func<Guid, Guid, Task>? ChatMessageRemoved;
public event Func<ChatReactionsChangedDto, Task>? ChatReactionsChanged;
public event Func<Guid, Guid, DateTimeOffset, Task>? ChatReadChanged;
public event Func<Guid, Guid, Task>? ChatTyping;
public async Task SendTypingAsync(Guid channelId)
{
if (_hub.State != HubConnectionState.Connected)
{
return;
}
try
{
await _hub.SendAsync(ChatEvents.TypingMethod, channelId);
}
catch (Exception) when (!_stopping.IsCancellationRequested)
{
// „tippt …“ ist nur ein Hinweis; eine unterbrochene Verbindung meldet sich über Reconnect.
}
}
public Task EnsureStartedAsync()
{
_startLoop ??= StartLoopAsync();
@@ -39,6 +39,7 @@
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
<script src="@Assets["js/pwa.js"]"></script>
<script src="@Assets["js/chat.js"]"></script>
<script src="@Assets["_framework/blazor.web.js"]" autostart="false"></script>
<script src="@Assets["js/boot.js"]"></script>
</body>
+312
View File
@@ -1159,3 +1159,315 @@ button.fp-meal-dish {
min-width: 128px;
font-size: 1.1rem !important;
}
/* Chat */
.fp-chat {
display: flex;
gap: 16px;
height: calc(100dvh - 130px);
min-height: 360px;
}
.fp-chat-list-pane {
flex: 0 0 320px;
overflow-y: auto;
}
.fp-chat-conv-pane {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
border: 1px solid var(--mud-palette-lines-default);
border-radius: var(--mud-default-borderradius);
background: var(--mud-palette-surface);
}
.fp-chat-placeholder {
margin: auto;
text-align: center;
color: var(--mud-palette-text-secondary);
}
.fp-chat-channels {
list-style: none;
margin: 0;
padding: 0;
}
.fp-chat-channel {
display: flex;
align-items: center;
gap: 12px;
min-height: 64px;
padding: 8px 12px;
border-radius: var(--mud-default-borderradius);
color: inherit;
text-decoration: none;
}
.fp-chat-channel:hover,
.fp-chat-channel.is-active {
background: var(--mud-palette-action-default-hover);
}
.fp-chat-channel-body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
}
.fp-chat-channel-title {
font-weight: 600;
}
.fp-chat-channel-preview {
color: var(--mud-palette-text-secondary);
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fp-chat-channel-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
font-size: 0.75rem;
color: var(--mud-palette-text-secondary);
}
.fp-chat-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--mud-palette-lines-default);
}
.fp-chat-back {
display: none;
}
.fp-chat-messages {
flex: 1 1 auto;
overflow-y: auto;
padding: 8px 12px;
overscroll-behavior: contain;
}
.fp-chat-log {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.fp-chat-day {
align-self: center;
margin: 8px 0 2px;
padding: 2px 12px;
border-radius: 12px;
font-size: 0.75rem;
color: var(--mud-palette-text-secondary);
background: var(--mud-palette-background-gray);
}
.fp-chat-msg {
display: flex;
align-items: flex-end;
gap: 8px;
max-width: 85%;
}
.fp-chat-msg.is-own {
align-self: flex-end;
flex-direction: row-reverse;
}
.fp-chat-bubble-wrap {
display: flex;
flex-direction: column;
min-width: 0;
}
.fp-chat-msg.is-own .fp-chat-bubble-wrap {
align-items: flex-end;
}
.fp-chat-sender {
font-size: 0.75rem;
font-weight: 600;
margin-left: 4px;
}
.fp-chat-bubble {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 10px;
border-radius: 12px;
background: var(--mud-palette-background-gray);
overflow-wrap: anywhere;
}
.fp-chat-msg.is-own .fp-chat-bubble {
background: var(--mud-palette-primary);
color: var(--mud-palette-primary-text);
}
.fp-chat-text {
white-space: pre-wrap;
}
.fp-chat-image {
display: block;
max-width: min(320px, 100%);
max-height: 320px;
border-radius: 8px;
}
.fp-chat-meta {
align-self: flex-end;
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 0.6875rem;
opacity: 0.85;
}
.fp-chat-meta .mud-icon-root {
font-size: 1rem;
}
.fp-chat-msg.is-own .fp-chat-read .mud-icon-root {
color: inherit;
}
.fp-chat-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
margin-top: 2px;
}
.fp-chat-reaction {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 28px;
padding: 0 8px;
border: 1px solid var(--mud-palette-lines-default);
border-radius: 14px;
background: var(--mud-palette-surface);
color: inherit;
cursor: pointer;
font: inherit;
}
.fp-chat-reaction.is-mine {
border-color: var(--mud-palette-primary);
background: var(--mud-palette-primary-hover);
}
.fp-chat-reaction-count {
font-size: 0.75rem;
}
.fp-chat-emoji-picker {
display: flex;
gap: 4px;
padding: 4px 8px;
}
.fp-chat-emoji {
min-width: 44px;
min-height: 44px;
border: 0;
border-radius: 8px;
background: transparent;
font-size: 1.5rem;
cursor: pointer;
}
.fp-chat-emoji:hover,
.fp-chat-emoji:focus-visible {
background: var(--mud-palette-action-default-hover);
}
.fp-chat-typing {
min-height: 1.5rem;
padding: 0 16px;
font-size: 0.8125rem;
color: var(--mud-palette-text-secondary);
}
.fp-chat-composer {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px calc(8px + env(safe-area-inset-bottom));
border-top: 1px solid var(--mud-palette-lines-default);
}
.fp-chat-input {
flex: 1 1 auto;
}
.fp-chat-attach {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
cursor: pointer;
}
.fp-chat-attach:hover,
.fp-chat-attach:focus-within {
background: var(--mud-palette-action-default-hover);
}
.fp-chat-attach input[type=file] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.fp-nav-unread {
margin-left: 8px;
}
/* Smartphone: Liste und Unterhaltung nacheinander statt nebeneinander */
@media (max-width: 959.98px) {
.fp-chat {
height: calc(100dvh - 64px - 72px - 32px - env(safe-area-inset-bottom));
}
.fp-chat.show-list .fp-chat-conv-pane,
.fp-chat.show-conversation .fp-chat-list-pane {
display: none;
}
.fp-chat-list-pane {
flex: 1 1 auto;
}
.fp-chat-back {
display: inline-flex;
}
.fp-chat-msg {
max-width: 92%;
}
}
+48
View File
@@ -0,0 +1,48 @@
// Hilfen für die Chat-Seite: Scrollen der Nachrichtenliste und Sichtbarkeit des Tabs.
(() => {
let captured = null;
let visibilityHandler = null;
window.fpChat = {
// Vor dem Rendern: Position merken (war der Benutzer am Ende? Höhe vorher?).
capture(id) {
const el = document.getElementById(id);
captured = el
? { height: el.scrollHeight, top: el.scrollTop, nearEnd: el.scrollHeight - el.scrollTop - el.clientHeight < 120 }
: null;
},
// Nach neuen Nachrichten am Ende: ans Ende springen, wenn der Benutzer unten war (oder erzwungen).
scrollToEnd(id, force) {
const el = document.getElementById(id);
if (el && (force || !captured || captured.nearEnd)) {
el.scrollTop = el.scrollHeight;
}
},
// Nach älteren Nachrichten oben: sichtbaren Ausschnitt beibehalten.
keepPosition(id) {
const el = document.getElementById(id);
if (el && captured) {
el.scrollTop = captured.top + (el.scrollHeight - captured.height);
}
},
isVisible() {
return document.visibilityState === 'visible';
},
watchVisibility(reference) {
this.unwatchVisibility();
visibilityHandler = () => reference.invokeMethodAsync('OnVisibilityChanged', document.visibilityState === 'visible');
document.addEventListener('visibilitychange', visibilityHandler);
},
unwatchVisibility() {
if (visibilityHandler) {
document.removeEventListener('visibilitychange', visibilityHandler);
visibilityHandler = null;
}
},
};
})();