diff --git a/docs/plans/2026-05-04-player-list-kick-waitlist.md b/docs/plans/2026-05-04-player-list-kick-waitlist.md new file mode 100644 index 0000000..bb69ef6 --- /dev/null +++ b/docs/plans/2026-05-04-player-list-kick-waitlist.md @@ -0,0 +1,438 @@ +# Player List + Kick + Waitlist Promotion Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Add a player list (with names) to the Web UI session views, allow GM to kick a specific player, and auto-promote the next waitlisted player. + +**Architecture:** Extend `ISessionStore` with participant queries and a remove method. Update `GroupDetails.razor` to show expandable participant lists. Reuse existing `PromoteWaitlistedPlayerAsync` logic after removal. + +**Tech Stack:** C# 14, Blazor SSR, Dapper, PostgreSQL + +--- + +## Task 1: Add domain model for WebParticipant + +**Objective:** Create a DTO to represent a session participant in the web layer. + +**Files:** +- Modify: `src/GmRelay.Web/Services/SessionService.cs` + +**Step 1: Add record** + +```csharp +public sealed record WebParticipant( + Guid Id, + long TelegramId, + string DisplayName, + string? TelegramUsername, + string RsvpStatus, + string RegistrationStatus, + bool IsGm, + DateTime? RespondedAt); +``` + +**Step 2: Commit** + +```bash +git add src/GmRelay.Web/Services/SessionService.cs +git commit -m "feat: add WebParticipant record" +``` + +--- + +## Task 2: Add GetSessionParticipantsAsync to ISessionStore + +**Objective:** Retrieve all participants for a session with full player info. + +**Files:** +- Modify: `src/GmRelay.Web/Services/ISessionStore.cs` +- Modify: `src/GmRelay.Web/Services/SessionService.cs` +- Modify: `src/GmRelay.Web/Services/AuthorizedSessionService.cs` + +**Step 1: Add to interface** + +In `ISessionStore.cs`, add: +```csharp +Task> GetSessionParticipantsAsync(Guid sessionId); +``` + +**Step 2: Implement in SessionService** + +In `SessionService.cs`, add: +```csharp +public async Task> GetSessionParticipantsAsync(Guid sessionId) +{ + await using var conn = await dataSource.OpenConnectionAsync(); + return (await conn.QueryAsync( + """ + SELECT sp.id AS Id, + p.telegram_id AS TelegramId, + p.display_name AS DisplayName, + p.telegram_username AS TelegramUsername, + sp.rsvp_status AS RsvpStatus, + sp.registration_status AS RegistrationStatus, + sp.is_gm AS IsGm, + sp.responded_at AS RespondedAt + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId + ORDER BY sp.is_gm DESC, + CASE sp.registration_status WHEN 'Active' THEN 0 ELSE 1 END, + sp.created_at + """, + new { SessionId = sessionId })).ToList(); +} +``` + +**Step 3: Add authorized wrapper** + +In `AuthorizedSessionService.cs`, add: +```csharp +public async Task?> GetSessionParticipantsForGmAsync(Guid sessionId, long gmId) +{ + var session = await GetSessionForGmAsync(sessionId, gmId); + if (session is null) + { + return null; + } + + return await sessionStore.GetSessionParticipantsAsync(sessionId); +} +``` + +**Step 4: Commit** + +```bash +git add src/GmRelay.Web/Services/ISessionStore.cs + +git add src/GmRelay.Web/Services/SessionService.cs + +git add src/GmRelay.Web/Services/AuthorizedSessionService.cs + +git commit -m "feat: add GetSessionParticipantsAsync" +``` + +--- + +## Task 3: Add RemovePlayerFromSessionAsync with waitlist promotion + +**Objective:** Allow GM to remove a specific player; auto-promote next waitlisted player if conditions met. + +**Files:** +- Modify: `src/GmRelay.Web/Services/ISessionStore.cs` +- Modify: `src/GmRelay.Web/Services/SessionService.cs` +- Modify: `src/GmRelay.Web/Services/AuthorizedSessionService.cs` + +**Step 1: Add to interface** + +In `ISessionStore.cs`, add: +```csharp +Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId); +``` + +**Step 2: Implement in SessionService** + +In `SessionService.cs`, add: +```csharp +public async Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId) +{ + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + var session = await conn.QuerySingleOrDefaultAsync( + @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, + s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, + g.telegram_chat_id AS TelegramChatId, + s.max_players AS MaxPlayers, + 0 AS ActivePlayerCount, + 0 AS WaitlistedPlayerCount, + s.notification_mode AS NotificationMode + FROM sessions s + JOIN game_groups g ON g.id = s.group_id + WHERE s.id = @SessionId AND s.group_id = @GroupId + FOR UPDATE", + new { SessionId = sessionId, GroupId = groupId }, + transaction); + + if (session is null) + { + throw new SessionAccessDeniedException(sessionId, 0); + } + + // Verify participant exists in this session + var participant = await conn.QuerySingleOrDefaultAsync( + """ + SELECT sp.id AS Id, + p.telegram_id AS TelegramId, + p.display_name AS DisplayName, + p.telegram_username AS TelegramUsername, + sp.rsvp_status AS RsvpStatus, + sp.registration_status AS RegistrationStatus, + sp.is_gm AS IsGm, + sp.responded_at AS RespondedAt + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.id = @ParticipantId AND sp.session_id = @SessionId + """, + new { ParticipantId = participantId, SessionId = sessionId }, + transaction); + + if (participant is null) + { + throw new InvalidOperationException("Участник не найден в этой сессии."); + } + + bool wasActive = participant.RegistrationStatus == ParticipantRegistrationStatus.Active; + + await conn.ExecuteAsync( + "DELETE FROM session_participants WHERE id = @ParticipantId", + new { ParticipantId = participantId }, + transaction); + + WebPromotedParticipantDto? promoted = null; + + if (wasActive) + { + promoted = await conn.QuerySingleOrDefaultAsync( + """ + SELECT sp.id AS ParticipantRowId, + p.display_name AS DisplayName + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId + AND sp.is_gm = false + AND sp.registration_status = @Waitlisted + ORDER BY sp.created_at ASC, sp.id ASC + LIMIT 1 + FOR UPDATE OF sp + """, + new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted }, + transaction); + + if (promoted is not null) + { + await conn.ExecuteAsync( + """ + UPDATE session_participants + SET registration_status = @Active, + rsvp_status = @Pending, + responded_at = NULL + WHERE id = @ParticipantRowId + """, + new + { + promoted.ParticipantRowId, + Active = ParticipantRegistrationStatus.Active, + Pending = RsvpStatus.Pending + }, + transaction); + } + } + + await transaction.CommitAsync(); + + // Notifications + await bot.SendMessage( + session.TelegramChatId, + $"🚪 {System.Net.WebUtility.HtmlEncode(participant.DisplayName)} удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».", + parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); + + if (promoted is not null) + { + await bot.SendMessage( + session.TelegramChatId, + $"⬆️ {System.Net.WebUtility.HtmlEncode(promoted.DisplayName)} переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».", + parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); + } + + if (session.BatchMessageId.HasValue) + { + await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title); + } +} +``` + +**Step 3: Add authorized wrapper** + +In `AuthorizedSessionService.cs`, add: +```csharp +public async Task RemovePlayerFromSessionForGmAsync(Guid sessionId, long gmId, Guid participantId) +{ + var session = await GetSessionForGmAsync(sessionId, gmId); + if (session is null) + { + throw new SessionAccessDeniedException(sessionId, gmId); + } + + await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId); +} +``` + +**Step 4: Commit** + +```bash +git add src/GmRelay.Web/Services/ISessionStore.cs + +git add src/GmRelay.Web/Services/SessionService.cs + +git add src/GmRelay.Web/Services/AuthorizedSessionService.cs + +git commit -m "feat: add RemovePlayerFromSessionAsync with waitlist promotion" +``` + +--- + +## Task 4: Modify GroupDetails.razor to show participant list + +**Objective:** Add expandable player lists to each session row with kick buttons. + +**Files:** +- Modify: `src/GmRelay.Web/Components/Pages/GroupDetails.razor` + +**Step 1:** Add `participants` dictionary and `kickingParticipantId` state variables. + +**Step 2:** Add `LoadParticipants(Guid sessionId)` and `KickParticipant(Guid sessionId, Guid participantId)` methods. + +**Step 3:** In desktop table, add a new column or expand row with participant list. + +**Step 4:** In mobile cards, add expandable participant section. + +**Step 5:** Add styles to `app.css` if needed (badge styles are already present). + +**Step 6:** Commit + +```bash +git add src/GmRelay.Web/Components/Pages/GroupDetails.razor + +git add src/GmRelay.Web/wwwroot/app.css + +git commit -m "feat: show player list and kick button in GroupDetails" +``` + +--- + +## Task 5: Modify EditSession.razor to show participant list + +**Objective:** Show participant list on the edit page with kick capability. + +**Files:** +- Modify: `src/GmRelay.Web/Components/Pages/EditSession.razor` + +**Step 1:** Load participants in `OnInitializedAsync`. + +**Step 2:** Render participant list below the edit form. + +**Step 3:** Add kick button for each non-GM participant. + +**Step 4:** Commit + +```bash +git add src/GmRelay.Web/Components/Pages/EditSession.razor + +git commit -m "feat: show player list and kick button in EditSession" +``` + +--- + +## Task 6: Add backend tests + +**Objective:** Cover new GetSessionParticipants and RemovePlayerFromSession logic. + +**Files:** +- Create: `tests/GmRelay.Bot.Tests/Web/SessionParticipantTests.cs` + +**Step 1:** Write tests for `GetSessionParticipantsForGmAsync`. + +**Step 2:** Write tests for `RemovePlayerFromSessionForGmAsync` including waitlist promotion. + +**Step 3:** Run tests + +```bash +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj -v n +``` + +**Step 4:** Commit + +```bash +git add tests/GmRelay.Bot.Tests/Web/SessionParticipantTests.cs + +git commit -m "test: add SessionParticipant service tests" +``` + +--- + +## Task 7: Update README + +**Objective:** Bump version and document new features. + +**Files:** +- Modify: `README.md` + +**Step 1:** Change version from `v1.9.6` to `v1.9.7`. + +**Step 2:** Add bullet under Web Dashboard: player list with kick and auto-promote. + +**Step 3:** Commit + +```bash +git add README.md + +git commit -m "docs: bump README to v1.9.7, document player list kick" +``` + +--- + +## Task 8: Update Wiki + +**Objective:** Update `Руководство ГМа` page with player management instructions. + +**Files:** +- Modify: Wiki page `Руководство ГМа` + +**Step 1:** Read current wiki content via MCP. + +**Step 2:** Add section about viewing player list and removing players. + +**Step 3:** Update via MCP. + +--- + +## Task 9: Push branch and run CI + +**Objective:** Push branch, monitor workflow, fix issues. + +**Step 1:** Push + +```bash +git push -u origin feat/player-list-kick-waitlist +``` + +**Step 2:** Check workflow run via MCP gitea actions. + +**Step 3:** Fix any issues. + +--- + +## Task 10: Merge and create release + +**Objective:** Merge PR (or fast-forward), tag, create release. + +**Step 1:** Merge to main + +```bash +git checkout main + +git merge --no-ff feat/player-list-kick-waitlist -m "feat: player list, kick, and waitlist promotion (#X)" +``` + +**Step 2:** Tag v1.9.7 + +```bash +git tag v1.9.7 + +git push origin main --tags +``` + +**Step 3:** Create release via MCP gitea_create_release. + +--- diff --git a/src/GmRelay.Web/Components/Pages/GroupDetails.razor b/src/GmRelay.Web/Components/Pages/GroupDetails.razor index b14b5e1..c88fbe2 100644 --- a/src/GmRelay.Web/Components/Pages/GroupDetails.razor +++ b/src/GmRelay.Web/Components/Pages/GroupDetails.razor @@ -229,8 +229,13 @@ @foreach (var session in sessions) { + var isExpanded = expandedSessions.Contains(session.Id); - @session.Title + + + @session.ScheduledAt.FormatMoscow() @FormatSeats(session) @@ -255,6 +260,49 @@ + @if (isExpanded) + { + + +
+ @if (loadingParticipantsSessionId == session.Id) + { +
+ } + else if (participantsCache.TryGetValue(session.Id, out var participants)) + { + @if (participants.Count == 0) + { +
+
Нет участников
+
+ } + else + { +
+ @foreach (var p in participants) + { +
+
+ @p.DisplayName + @FormatParticipantUsername(p) + @TranslateParticipantStatus(p) +
+ @if (!p.IsGm) + { + + } +
+ } +
+ } + } +
+ + + } } @@ -264,9 +312,12 @@
@foreach (var session in sessions) { + var isExpanded = expandedSessions.Contains(session.Id);
- @session.Title + @TranslateStatus(session.Status)
@@ -294,6 +345,45 @@ }
+ @if (isExpanded) + { +
+ @if (loadingParticipantsSessionId == session.Id) + { +
+ } + else if (participantsCache.TryGetValue(session.Id, out var participants)) + { + @if (participants.Count == 0) + { +
+
Нет участников
+
+ } + else + { +
+ @foreach (var p in participants) + { +
+
+ @p.DisplayName + @FormatParticipantUsername(p) + @TranslateParticipantStatus(p) +
+ @if (!p.IsGm) + { + + } +
+ } +
+ } + } +
+ }
}
@@ -316,6 +406,10 @@ private string? errorMessage; private string? successMessage; private CoGmEditModel coGmModel = new(); + private Dictionary> participantsCache = new(); + private HashSet expandedSessions = new(); + private Guid? kickingParticipantId; + private Guid? loadingParticipantsSessionId; protected override async Task OnInitializedAsync() { @@ -447,6 +541,105 @@ } } + private async Task ToggleParticipants(Guid sessionId) + { + if (expandedSessions.Contains(sessionId)) + { + expandedSessions.Remove(sessionId); + return; + } + + expandedSessions.Add(sessionId); + + if (!participantsCache.ContainsKey(sessionId)) + { + loadingParticipantsSessionId = sessionId; + try + { + var participants = await SessionService.GetSessionParticipantsForGmAsync(sessionId, telegramId); + participantsCache[sessionId] = participants ?? []; + } + catch (Exception ex) + { + errorMessage = "Не удалось загрузить участников: " + ex.Message; + expandedSessions.Remove(sessionId); + } + finally + { + loadingParticipantsSessionId = null; + } + } + } + + private async Task KickParticipant(Guid sessionId, Guid participantId) + { + errorMessage = null; + successMessage = null; + kickingParticipantId = participantId; + + try + { + await SessionService.RemovePlayerFromSessionForGmAsync(sessionId, telegramId, participantId); + participantsCache.Remove(sessionId); + successMessage = "Игрок исключён."; + await LoadSessions(); + if (expandedSessions.Contains(sessionId)) + { + await ToggleParticipants(sessionId); + } + } + catch (SessionAccessDeniedException) + { + Navigation.NavigateTo("/access-denied"); + } + catch (Exception ex) + { + errorMessage = ex.Message; + } + finally + { + kickingParticipantId = null; + } + } + + private static string FormatParticipantUsername(WebParticipant p) + { + var username = string.IsNullOrWhiteSpace(p.TelegramUsername) + ? p.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture) + : "@" + p.TelegramUsername; + return $"{username} · {FormatParticipantRsvp(p.RsvpStatus)}"; + } + + private static string FormatParticipantRsvp(string rsvp) => rsvp switch + { + RsvpStatus.Pending => "⏳ не ответил", + RsvpStatus.Confirmed => "✅ подтвердил", + RsvpStatus.Declined => "❌ отказался", + _ => rsvp + }; + + private static string GetParticipantStatusClass(WebParticipant p) + { + if (p.IsGm) return "status-success"; + return p.RegistrationStatus switch + { + "Active" => "status-info", + "Waitlisted" => "status-warning", + _ => "status-neutral" + }; + } + + private static string TranslateParticipantStatus(WebParticipant p) + { + if (p.IsGm) return "ГМ"; + return p.RegistrationStatus switch + { + "Active" => "Основной состав", + "Waitlisted" => "Ожидание", + _ => p.RegistrationStatus + }; + } + private async Task UpdateBatchDetails(BatchBulkEditModel batch) { errorMessage = null; diff --git a/src/GmRelay.Web/Services/AuthorizedSessionService.cs b/src/GmRelay.Web/Services/AuthorizedSessionService.cs index 906c05a..2c7112b 100644 --- a/src/GmRelay.Web/Services/AuthorizedSessionService.cs +++ b/src/GmRelay.Web/Services/AuthorizedSessionService.cs @@ -219,6 +219,28 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore) await sessionStore.RemoveGroupCoGmAsync(groupId, coGmTelegramId); } + public async Task?> GetSessionParticipantsForGmAsync(Guid sessionId, long gmId) + { + var session = await GetSessionForGmAsync(sessionId, gmId); + if (session is null) + { + return null; + } + + return await sessionStore.GetSessionParticipantsAsync(sessionId); + } + + public async Task RemovePlayerFromSessionForGmAsync(Guid sessionId, long gmId, Guid participantId) + { + var session = await GetSessionForGmAsync(sessionId, gmId); + if (session is null) + { + throw new SessionAccessDeniedException(sessionId, gmId); + } + + await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId); + } + private async Task GroupBelongsToGmAsync(Guid groupId, long gmId) { return await sessionStore.IsGroupManagerAsync(groupId, gmId); diff --git a/src/GmRelay.Web/Services/ISessionStore.cs b/src/GmRelay.Web/Services/ISessionStore.cs index 99f8e04..d36000a 100644 --- a/src/GmRelay.Web/Services/ISessionStore.cs +++ b/src/GmRelay.Web/Services/ISessionStore.cs @@ -25,4 +25,6 @@ public interface ISessionStore Task CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt); Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername); Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId); + Task> GetSessionParticipantsAsync(Guid sessionId); + Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId); } diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index b60ed5a..e9ba54d 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -39,6 +39,16 @@ public sealed record WebSession( int WaitlistedPlayerCount, string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue); +public sealed record WebParticipant( + Guid Id, + long TelegramId, + string DisplayName, + string? TelegramUsername, + string RsvpStatus, + string RegistrationStatus, + bool IsGm, + DateTime? RespondedAt); + internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName); internal sealed record WebDirectNotificationRecipient(long TelegramId, string DisplayName); internal sealed record WebBatchInfo( @@ -507,6 +517,148 @@ public sealed class SessionService( } } + public async Task> GetSessionParticipantsAsync(Guid sessionId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return (await conn.QueryAsync( + """ + SELECT sp.id AS Id, + p.telegram_id AS TelegramId, + p.display_name AS DisplayName, + p.telegram_username AS TelegramUsername, + sp.rsvp_status AS RsvpStatus, + sp.registration_status AS RegistrationStatus, + sp.is_gm AS IsGm, + sp.responded_at AS RespondedAt + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId + ORDER BY sp.is_gm DESC, + CASE sp.registration_status WHEN 'Active' THEN 0 ELSE 1 END, + sp.created_at + """, + new { SessionId = sessionId })).ToList(); + } + + public async Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + var session = await conn.QuerySingleOrDefaultAsync( + @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, + s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, + g.telegram_chat_id AS TelegramChatId, + s.max_players AS MaxPlayers, + 0 AS ActivePlayerCount, + 0 AS WaitlistedPlayerCount, + s.notification_mode AS NotificationMode + FROM sessions s + JOIN game_groups g ON g.id = s.group_id + WHERE s.id = @SessionId AND s.group_id = @GroupId + FOR UPDATE", + new { SessionId = sessionId, GroupId = groupId }, + transaction); + + if (session is null) + { + throw new SessionAccessDeniedException(sessionId, 0); + } + + var participant = await conn.QuerySingleOrDefaultAsync( + """ + SELECT sp.id AS Id, + p.telegram_id AS TelegramId, + p.display_name AS DisplayName, + p.telegram_username AS TelegramUsername, + sp.rsvp_status AS RsvpStatus, + sp.registration_status AS RegistrationStatus, + sp.is_gm AS IsGm, + sp.responded_at AS RespondedAt + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.id = @ParticipantId AND sp.session_id = @SessionId + """, + new { ParticipantId = participantId, SessionId = sessionId }, + transaction); + + if (participant is null) + { + throw new InvalidOperationException("Участник не найден в этой сессии."); + } + + bool wasActive = participant.RegistrationStatus == ParticipantRegistrationStatus.Active; + + await conn.ExecuteAsync( + "DELETE FROM session_participants WHERE id = @ParticipantId", + new { ParticipantId = participantId }, + transaction); + + WebPromotedParticipantDto? promoted = null; + + if (wasActive) + { + promoted = await conn.QuerySingleOrDefaultAsync( + """ + SELECT sp.id AS ParticipantRowId, + p.display_name AS DisplayName + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId + AND sp.is_gm = false + AND sp.registration_status = @Waitlisted + ORDER BY sp.created_at ASC, sp.id ASC + LIMIT 1 + FOR UPDATE OF sp + """, + new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted }, + transaction); + + if (promoted is not null) + { + await conn.ExecuteAsync( + """ + UPDATE session_participants + SET registration_status = @Active, + rsvp_status = @Pending, + responded_at = NULL + WHERE id = @ParticipantRowId + """, + new + { + promoted.ParticipantRowId, + Active = ParticipantRegistrationStatus.Active, + Pending = RsvpStatus.Pending + }, + transaction); + } + } + + await transaction.CommitAsync(); + + await bot.SendMessage( + session.TelegramChatId, + $"🚪 {System.Net.WebUtility.HtmlEncode(participant.DisplayName)} удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».", + parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); + + if (promoted is not null) + { + await bot.SendMessage( + session.TelegramChatId, + $"⬆️ {System.Net.WebUtility.HtmlEncode(promoted.DisplayName)} переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».", + parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); + + if (session.BatchMessageId.HasValue) + { + await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title); + } + } + else if (session.BatchMessageId.HasValue) + { + await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title); + } + } + public async Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink) { await using var conn = await dataSource.OpenConnectionAsync(); diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index 0ba6c59..2d43eae 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -1115,3 +1115,56 @@ body.telegram-mini-app .session-card-mobile { padding: 1rem; } } + +/* === Participant list === */ +.participant-panel { + background: rgba(0, 0, 0, 0.2); + border-radius: 0.75rem; + padding: 0.75rem; + margin: 0.5rem 0; +} + +.participant-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.participant-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + background: rgba(255, 255, 255, 0.03); + border-radius: 0.5rem; + gap: 0.5rem; +} + +.participant-info { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + flex: 1; +} + +.participant-name { + font-weight: 500; + color: var(--text-primary); +} + +.participant-username { + font-size: 0.75rem; + color: var(--text-muted); +} + +.btn-gm-link { + background: transparent; + border: none; + color: var(--text-primary); + font-weight: 500; + cursor: pointer; + font-size: inherit; + font-family: inherit; + padding: 0; +}