Improve admin message seen/dismiss state. (#26223)

Fixes #26211

Admin messages now have separate "seen" and "dismissed" fields. The idea is that an admin should be able to tell whether a user pressed the "dismiss for now" button. Instead of using "seen" as "show this message to players when they join", "dismissed" is now used for this.

Existing notes in the database will automatically be marked as dismissed on migration. A note cannot be dismissed without being seen (enforced via constraint in the database too, aren't I fancy).

As part of this, it has become impossible for a player to play without dismissing the message in some form. Instead of a shitty popup window, the popup is now a fullscreen overlay that blocks clicks behind it, making the game unplayable. Also, if a user somehow has multiple messages they will be combined into one popup.

Also I had enough respect for the codebase to make it look better and clean up the code somewhat. Yippee.
This commit is contained in:
Pieter-Jan Briers
2024-03-21 16:15:46 +01:00
committed by GitHub
parent f87480dd36
commit d776c4b392
21 changed files with 3748 additions and 108 deletions

View File

@@ -2,6 +2,7 @@ using Content.Client.Eui;
using Content.Shared.Administration.Notes;
using Content.Shared.Eui;
using JetBrains.Annotations;
using Robust.Client.UserInterface.Controls;
using static Content.Shared.Administration.Notes.AdminMessageEuiMsg;
namespace Content.Client.Administration.UI.AdminRemarks;
@@ -14,9 +15,8 @@ public sealed class AdminMessageEui : BaseEui
public AdminMessageEui()
{
_popup = new AdminMessagePopupWindow();
_popup.OnAcceptPressed += () => SendMessage(new Accept());
_popup.OnDismissPressed += () => SendMessage(new Dismiss());
_popup.OnClose += () => SendMessage(new CloseEuiMessage());
_popup.OnAcceptPressed += () => SendMessage(new Dismiss(true));
_popup.OnDismissPressed += () => SendMessage(new Dismiss(false));
}
public override void HandleState(EuiStateBase state)
@@ -26,13 +26,17 @@ public sealed class AdminMessageEui : BaseEui
return;
}
_popup.SetMessage(s.Message);
_popup.SetDetails(s.AdminName, s.AddedOn);
_popup.Timer = s.Time;
_popup.SetState(s);
}
public override void Opened()
{
_popup.OpenCentered();
_popup.UserInterfaceManager.WindowRoot.AddChild(_popup);
LayoutContainer.SetAnchorPreset(_popup, LayoutContainer.LayoutPreset.Wide);
}
public override void Closed()
{
_popup.Orphan();
}
}

View File

@@ -0,0 +1,6 @@
<Control xmlns="https://spacestation14.io" Margin="0 0 0 8">
<BoxContainer Orientation="Vertical">
<RichTextLabel Name="Admin" Margin="0 0 0 4" />
<RichTextLabel Name="Message" Margin="2 0 0 0" />
</BoxContainer>
</Control>

View File

@@ -0,0 +1,23 @@
using Content.Shared.Administration.Notes;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Utility;
namespace Content.Client.Administration.UI.AdminRemarks;
[GenerateTypedNameReferences]
public sealed partial class AdminMessagePopupMessage : Control
{
public AdminMessagePopupMessage(AdminMessageEuiState.Message message)
{
RobustXamlLoader.Load(this);
Admin.SetMessage(FormattedMessage.FromMarkup(Loc.GetString(
"admin-notes-message-admin",
("admin", message.AdminName),
("date", message.AddedOn.ToLocalTime()))));
Message.SetMessage(message.Text);
}
}

View File

@@ -1,22 +1,36 @@
<ui:FancyWindow xmlns="https://spacestation14.io"
xmlns:ui="clr-namespace:Content.Client.UserInterface.Controls"
VerticalExpand="True" HorizontalExpand="True"
Title="{Loc admin-notes-message-window-title}"
MinSize="600 170">
<PanelContainer VerticalExpand="True" HorizontalExpand="True" StyleClasses="BackgroundDark">
<ScrollContainer HScrollEnabled="False" VerticalExpand="True" HorizontalExpand="True" Margin="4">
<BoxContainer Orientation="Vertical" SeparationOverride="10" VerticalAlignment="Bottom">
<Label Name="AdminLabel" Text="Loading..." />
<RichTextLabel Name="MessageLabel" />
<Control xmlns="https://spacestation14.io"
xmlns:gfx="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client">
<PanelContainer MouseFilter="Stop">
<PanelContainer.PanelOverride>
<!-- semi-transparent background -->
<gfx:StyleBoxFlat BackgroundColor="#000000AA" />
</PanelContainer.PanelOverride>
<Control HorizontalAlignment="Center" VerticalAlignment="Center" MaxWidth="600">
<PanelContainer StyleClasses="AngleRect" />
<BoxContainer Orientation="Vertical" Margin="4">
<RichTextLabel Name="Description" />
<!-- Contains actual messages -->
<ScrollContainer HScrollEnabled="False" Margin="4" VerticalExpand="True" ReturnMeasure="True" MaxHeight="400">
<BoxContainer Orientation="Vertical" Name="MessageContainer" Margin="0 2 0 0" />
</ScrollContainer>
<Label Name="WaitLabel" />
<BoxContainer Orientation="Horizontal">
<Button Name="DismissButton"
Text="{Loc 'admin-notes-message-dismiss'}" />
Text="{Loc 'admin-notes-message-dismiss'}"
Disabled="True"
HorizontalExpand="True"
StyleClasses="OpenRight" />
<Button Name="AcceptButton"
Text="{Loc 'admin-notes-message-accept'}"
Disabled="True" />
Disabled="True"
HorizontalExpand="True"
StyleClasses="OpenLeft" />
</BoxContainer>
</BoxContainer>
</ScrollContainer>
</Control>
</PanelContainer>
</ui:FancyWindow>
</Control>

View File

@@ -1,56 +1,65 @@
using Content.Client.UserInterface.Controls;
using Content.Client.Stylesheets;
using Content.Shared.Administration.Notes;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Client.Administration.UI.AdminRemarks;
[GenerateTypedNameReferences]
public sealed partial class AdminMessagePopupWindow : FancyWindow
public sealed partial class AdminMessagePopupWindow : Control
{
private float _timer = float.MaxValue;
public float Timer
{
get => _timer;
set
{
WaitLabel.Text = Loc.GetString("admin-notes-message-wait", ("time", MathF.Floor(value)));
_timer = value;
}
}
public event Action? OnDismissPressed;
public event Action? OnAcceptPressed;
public AdminMessagePopupWindow()
{
RobustXamlLoader.Load(this);
Stylesheet = IoCManager.Resolve<IStylesheetManager>().SheetSpace;
AcceptButton.OnPressed += OnAcceptButtonPressed;
DismissButton.OnPressed += OnDismissButtonPressed;
}
public void SetMessage(string message)
public float Timer
{
MessageLabel.SetMessage(message);
get => _timer;
private set
{
WaitLabel.Text = Loc.GetString("admin-notes-message-wait", ("time", MathF.Floor(value)));
_timer = value;
}
}
public void SetDetails(string adminName, DateTime addedOn)
public void SetState(AdminMessageEuiState state)
{
AdminLabel.Text = Loc.GetString("admin-notes-message-admin", ("admin", adminName), ("date", addedOn));
Timer = (float) state.Time.TotalSeconds;
MessageContainer.RemoveAllChildren();
foreach (var message in state.Messages)
{
MessageContainer.AddChild(new AdminMessagePopupMessage(message));
}
Description.SetMessage(FormattedMessage.FromMarkup(Loc.GetString("admin-notes-message-desc", ("count", state.Messages.Length))));
}
private void OnDismissButtonPressed(BaseButton.ButtonEventArgs obj)
{
OnDismissPressed?.Invoke();
Close();
}
private void OnAcceptButtonPressed(BaseButton.ButtonEventArgs obj)
{
OnAcceptPressed?.Invoke();
Close();
}
protected override void FrameUpdate(FrameEventArgs args)
@@ -70,6 +79,7 @@ public sealed partial class AdminMessagePopupWindow : FancyWindow
else
{
AcceptButton.Disabled = false;
DismissButton.Disabled = false;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Content.Server.Database.Migrations.Postgres
{
/// <inheritdoc />
public partial class AdminMessageDismiss : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "dismissed",
table: "admin_messages",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.Sql("UPDATE admin_messages SET dismissed = seen");
migrationBuilder.AddCheckConstraint(
name: "NotDismissedAndSeen",
table: "admin_messages",
sql: "NOT dismissed OR seen");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropCheckConstraint(
name: "NotDismissedAndSeen",
table: "admin_messages");
migrationBuilder.DropColumn(
name: "dismissed",
table: "admin_messages");
}
}
}

View File

@@ -183,6 +183,10 @@ namespace Content.Server.Database.Migrations.Postgres
.HasColumnType("uuid")
.HasColumnName("deleted_by_id");
b.Property<bool>("Dismissed")
.HasColumnType("boolean")
.HasColumnName("dismissed");
b.Property<DateTime?>("ExpirationTime")
.HasColumnType("timestamp with time zone")
.HasColumnName("expiration_time");
@@ -232,7 +236,10 @@ namespace Content.Server.Database.Migrations.Postgres
b.HasIndex("RoundId")
.HasDatabaseName("IX_admin_messages_round_id");
b.ToTable("admin_messages", (string)null);
b.ToTable("admin_messages", null, t =>
{
t.HasCheckConstraint("NotDismissedAndSeen", "NOT dismissed OR seen");
});
});
modelBuilder.Entity("Content.Server.Database.AdminNote", b =>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Content.Server.Database.Migrations.Sqlite
{
/// <inheritdoc />
public partial class AdminMessageDismiss : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "dismissed",
table: "admin_messages",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.Sql("UPDATE admin_messages SET dismissed = seen");
migrationBuilder.AddCheckConstraint(
name: "NotDismissedAndSeen",
table: "admin_messages",
sql: "NOT dismissed OR seen");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropCheckConstraint(
name: "NotDismissedAndSeen",
table: "admin_messages");
migrationBuilder.DropColumn(
name: "dismissed",
table: "admin_messages");
}
}
}

View File

@@ -166,6 +166,10 @@ namespace Content.Server.Database.Migrations.Sqlite
.HasColumnType("TEXT")
.HasColumnName("deleted_by_id");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER")
.HasColumnName("dismissed");
b.Property<DateTime?>("ExpirationTime")
.HasColumnType("TEXT")
.HasColumnName("expiration_time");
@@ -215,7 +219,10 @@ namespace Content.Server.Database.Migrations.Sqlite
b.HasIndex("RoundId")
.HasDatabaseName("IX_admin_messages_round_id");
b.ToTable("admin_messages", (string)null);
b.ToTable("admin_messages", null, t =>
{
t.HasCheckConstraint("NotDismissedAndSeen", "NOT dismissed OR seen");
});
});
modelBuilder.Entity("Content.Server.Database.AdminNote", b =>

View File

@@ -268,6 +268,11 @@ namespace Content.Server.Database
.HasPrincipalKey(author => author.UserId)
.OnDelete(DeleteBehavior.SetNull);
// A message cannot be "dismissed" without also being "seen".
modelBuilder.Entity<AdminMessage>().ToTable(t =>
t.HasCheckConstraint("NotDismissedAndSeen",
"NOT dismissed OR seen"));
modelBuilder.Entity<ServerBan>()
.HasOne(ban => ban.CreatedBy)
.WithMany(author => author.AdminServerBansCreated)
@@ -969,6 +974,15 @@ namespace Content.Server.Database
[ForeignKey("DeletedBy")] public Guid? DeletedById { get; set; }
public Player? DeletedBy { get; set; }
public DateTime? DeletedAt { get; set; }
/// <summary>
/// Whether the message has been seen at least once by the player.
/// </summary>
public bool Seen { get; set; }
/// <summary>
/// Whether the message has been dismissed permanently by the player.
/// </summary>
public bool Dismissed { get; set; }
}
}

View File

@@ -1,9 +1,11 @@
using System.Linq;
using Content.Server.Database;
using Content.Server.EUI;
using Content.Shared.Administration.Notes;
using Content.Shared.CCVar;
using Content.Shared.Eui;
using Robust.Shared.Configuration;
using Robust.Shared.Timing;
using static Content.Shared.Administration.Notes.AdminMessageEuiMsg;
namespace Content.Server.Administration.Notes;
@@ -12,32 +14,33 @@ public sealed class AdminMessageEui : BaseEui
{
[Dependency] private readonly IAdminNotesManager _notesMan = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
private readonly float _closeWait;
private AdminMessageRecord? _message;
private DateTime _startTime;
[Dependency] private readonly IGameTiming _gameTiming = default!;
public AdminMessageEui()
private readonly TimeSpan _closeWait;
private readonly TimeSpan _endTime;
private readonly AdminMessageRecord[] _messages;
public AdminMessageEui(AdminMessageRecord[] messages)
{
IoCManager.InjectDependencies(this);
_closeWait = _cfg.GetCVar(CCVars.MessageWaitTime);
_closeWait = TimeSpan.FromSeconds(_cfg.GetCVar(CCVars.MessageWaitTime));
_endTime = _gameTiming.RealTime + _closeWait;
_messages = messages;
}
public void SetMessage(AdminMessageRecord message)
public override void Opened()
{
_message = message;
_startTime = DateTime.UtcNow;
StateDirty();
}
public override EuiStateBase GetNewState()
{
if (_message == null)
return new AdminMessageEuiState(float.MaxValue, "An error has occurred.", string.Empty, DateTime.MinValue);
return new AdminMessageEuiState(
_closeWait,
_message.Message,
_message.CreatedBy?.LastSeenUserName ?? "[System]",
_message.CreatedAt.UtcDateTime
_messages.Select(x => new AdminMessageEuiState.Message(
x.Message,
x.CreatedBy?.LastSeenUserName ?? Loc.GetString("admin-notes-fallback-admin-name"),
x.CreatedAt.UtcDateTime)).ToArray()
);
}
@@ -47,15 +50,14 @@ public sealed class AdminMessageEui : BaseEui
switch (msg)
{
case Accept:
if (_message == null)
break;
// No escape
if (DateTime.UtcNow - _startTime >= TimeSpan.FromSeconds(_closeWait))
await _notesMan.MarkMessageAsSeen(_message.Id);
Close();
break;
case Dismiss:
case Dismiss dismiss:
if (_gameTiming.RealTime < _endTime)
return;
foreach (var message in _messages)
{
await _notesMan.MarkMessageAsSeen(message.Id, dismiss.Permanent);
}
Close();
break;
}

View File

@@ -331,9 +331,9 @@ public sealed class AdminNotesManager : IAdminNotesManager, IPostInjectInit
return await _db.GetMessages(player);
}
public async Task MarkMessageAsSeen(int id)
public async Task MarkMessageAsSeen(int id, bool dismissedToo)
{
await _db.MarkMessageAsSeen(id);
await _db.MarkMessageAsSeen(id, dismissedToo);
}
public void PostInject()

View File

@@ -1,3 +1,4 @@
using System.Linq;
using Content.Server.Administration.Commands;
using Content.Server.Chat.Managers;
using Content.Server.EUI;
@@ -52,7 +53,7 @@ public sealed class AdminNotesSystem : EntitySystem
private async void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e)
{
if (e.NewStatus != SessionStatus.Connected)
if (e.NewStatus != SessionStatus.InGame)
return;
var messages = await _notes.GetNewMessages(e.Session.UserId);
@@ -69,19 +70,11 @@ public sealed class AdminNotesSystem : EntitySystem
_chat.SendAdminAlert(Loc.GetString("admin-notes-watchlist", ("player", username), ("message", watchlist.Message)));
}
foreach (var message in messages)
{
var messageString = Loc.GetString("admin-notes-new-message", ("admin", message.CreatedBy?.LastSeenUserName ?? "[System]"), ("message", message.Message));
// Only open the popup if the user hasn't seen it yet
if (!message.Seen)
{
var ui = new AdminMessageEui();
_euis.OpenEui(ui, e.Session);
ui.SetMessage(message);
var messagesToShow = messages.OrderBy(x => x.CreatedAt).Where(x => !x.Dismissed).ToArray();
if (messagesToShow.Length == 0)
return;
// Only send the message if they haven't seen it yet
_chat.DispatchServerMessage(e.Session, messageString);
}
}
var ui = new AdminMessageEui(messagesToShow);
_euis.OpenEui(ui, e.Session);
}
}

View File

@@ -45,5 +45,13 @@ public interface IAdminNotesManager
/// <param name="player">Desired player's <see cref="Guid"/></param>
/// <returns>All unread messages</returns>
Task<List<AdminMessageRecord>> GetNewMessages(Guid player);
Task MarkMessageAsSeen(int id);
/// <summary>
/// Mark an admin message as being seen by the target player.
/// </summary>
/// <param name="id">The database ID of the admin message.</param>
/// <param name="dismissedToo">
/// If true, the message is "permanently dismissed" and will not be shown to the player again when they join.
/// </param>
Task MarkMessageAsSeen(int id, bool dismissedToo);
}

View File

@@ -111,7 +111,8 @@ public sealed record AdminMessageRecord(
bool Deleted,
PlayerRecord? DeletedBy,
DateTimeOffset? DeletedAt,
bool Seen) : IAdminRemarksRecord;
bool Seen,
bool Dismissed) : IAdminRemarksRecord;
public sealed record PlayerRecord(

View File

@@ -1143,7 +1143,8 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id}
entity.Deleted,
MakePlayerRecord(entity.DeletedBy),
NormalizeDatabaseTime(entity.DeletedAt),
entity.Seen);
entity.Seen,
entity.Dismissed);
}
public async Task<ServerBanNoteRecord?> GetServerBanAsNoteAsync(int id)
@@ -1422,11 +1423,13 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id}
return entities.Select(MakeAdminMessageRecord).ToList();
}
public async Task MarkMessageAsSeen(int id)
public async Task MarkMessageAsSeen(int id, bool dismissedToo)
{
await using var db = await GetDb();
var message = await db.DbContext.AdminMessages.SingleAsync(m => m.Id == id);
message.Seen = true;
if (dismissedToo)
message.Dismissed = true;
await db.DbContext.SaveChangesAsync();
}

View File

@@ -274,7 +274,15 @@ namespace Content.Server.Database
Task DeleteAdminMessage(int id, Guid deletedBy, DateTimeOffset deletedAt);
Task HideServerBanFromNotes(int id, Guid deletedBy, DateTimeOffset deletedAt);
Task HideServerRoleBanFromNotes(int id, Guid deletedBy, DateTimeOffset deletedAt);
Task MarkMessageAsSeen(int id);
/// <summary>
/// Mark an admin message as being seen by the target player.
/// </summary>
/// <param name="id">The database ID of the admin message.</param>
/// <param name="dismissedToo">
/// If true, the message is "permanently dismissed" and will not be shown to the player again when they join.
/// </param>
Task MarkMessageAsSeen(int id, bool dismissedToo);
#endregion
}
@@ -847,10 +855,10 @@ namespace Content.Server.Database
return RunDbCommand(() => _db.HideServerRoleBanFromNotes(id, deletedBy, deletedAt));
}
public Task MarkMessageAsSeen(int id)
public Task MarkMessageAsSeen(int id, bool dismissedToo)
{
DbWriteOpsMetric.Inc();
return RunDbCommand(() => _db.MarkMessageAsSeen(id));
return RunDbCommand(() => _db.MarkMessageAsSeen(id, dismissedToo));
}
// Wrapper functions to run DB commands from the thread pool.

View File

@@ -1,39 +1,28 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Content.Shared.Eui;
using Robust.Shared.Serialization;
namespace Content.Shared.Administration.Notes;
[Serializable, NetSerializable]
public sealed class AdminMessageEuiState : EuiStateBase
public sealed class AdminMessageEuiState(TimeSpan time, AdminMessageEuiState.Message[] messages) : EuiStateBase
{
public float Time { get; set; }
public string Message { get; set; }
public string AdminName { get; set; }
public DateTime AddedOn { get; set; }
public TimeSpan Time { get; } = time;
public Message[] Messages { get; } = messages;
public AdminMessageEuiState(float time, string message, string adminName, DateTime addedOn)
[Serializable]
public sealed class Message(string text, string adminName, DateTime addedOn)
{
Message = message;
Time = time;
AdminName = adminName;
AddedOn = addedOn;
public string Text = text;
public string AdminName = adminName;
public DateTime AddedOn = addedOn;
}
}
public static class AdminMessageEuiMsg
{
[Serializable, NetSerializable]
public sealed class Accept : EuiMessageBase
{
}
[Serializable, NetSerializable]
public sealed class Dismiss : EuiMessageBase
public sealed class Dismiss(bool permanent) : EuiMessageBase
{
public bool Permanent { get; } = permanent;
}
}

View File

@@ -23,8 +23,11 @@ admin-notes-hide = Hide
admin-notes-delete-confirm = Confirm delete
admin-notes-edited = Last edit by {$author} on {$date}
admin-notes-unbanned = Unbanned by {$admin} on {$date}
admin-notes-message-window-title = Alert!
admin-notes-message-admin = New message from {$admin}, added on {$date}
admin-notes-message-desc = [color=white]You have received { $count ->
[1] an administrative message
*[other] administrative messages
} since the last time you played on this server.[/color]
admin-notes-message-admin = From [bold]{ $admin }[/bold], written on { TOSTRING($date, "f") }:
admin-notes-message-wait = The accept button will be enabled after {$time} seconds.
admin-notes-message-accept = Dismiss permanently
admin-notes-message-dismiss = Dismiss for now
@@ -68,6 +71,7 @@ admin-notes-verb-text = Open Admin Notes
# Watchlist and message login
admin-notes-watchlist = Watchlist for {$player}: {$message}
admin-notes-new-message = You've received an admin message from {$admin}: {$message}
admin-notes-fallback-admin-name = [System]
# Admin remarks
admin-remarks-command-description = Opens the admin remarks page