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:
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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
})();
|
||||
Reference in new issue
Block a user