Update: Mentor (#3478)

This commit is contained in:
Daniel 2025-12-23 05:36:36 +01:00 committed by GitHub
parent fab5bc3f9b
commit acec175fa3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 882 additions and 280 deletions

View file

@ -6,9 +6,12 @@
<BoxContainer Orientation="Horizontal" SetHeight="40" HorizontalExpand="True" Margin="5">
<Button Name="NewTicketButton" Access="Public" Text="{Loc 'mentor-help-new-ticket'}"
StyleClasses="ButtonBig" Margin="0 0 10 0"/>
<Button Name="AdminWhoButton" Access="Public" Text="{Loc 'admin-who-button'}" Margin="0 0 10 0" />
<Button Name="AdminWhoButton" Access="Public" Text="{Loc 'admin-who-button'}"
StyleClasses="ButtonBig" Margin="0 0 10 0"/>
<Button Name="StatisticsButton" Access="Public" Text="{Loc 'mentor-help-statistics'}"
StyleClasses="ButtonBig" Visible="False" Margin="0 0 10 0" />
<CheckBox Name="AutoOpenTickets" Access="Public" Text="{Loc 'mentor-help-auto-open-tickets'}" ToolTip="{Loc 'mentor-help-auto-open-tickets-tooltip'}" HorizontalAlignment="Right" Margin="0 0 10 0"/>
<CheckBox Name="PlaySound" Access="Public" Text="{Loc 'help-kwoink-play-sound'}" Pressed="True" HorizontalAlignment="Right"/>
<Control HorizontalExpand="True" />
<Button Name="BackToListButton" Access="Public" Text="{Loc 'mentor-help-back-to-list'}"
StyleClasses="ButtonBig" HorizontalAlignment="Right" Visible="False" />
@ -18,7 +21,6 @@
<TabContainer Name="TicketsTabContainer" Access="Public" VerticalExpand="True" HorizontalExpand="True">
<BoxContainer Orientation="Vertical" HorizontalExpand="True" VerticalExpand="True">
<BoxContainer Orientation="Horizontal" HorizontalExpand="True" Margin="5 0 15 0">
<controls:VSeparator/>
<Label Text="{Loc 'mentor-help-column-id'}" SizeFlagsStretchRatio="1" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
<Label Text="{Loc 'mentor-help-column-player'}" SizeFlagsStretchRatio="3" HorizontalExpand="True" HorizontalAlignment="Center"/>
@ -28,7 +30,6 @@
<Label Text="{Loc 'mentor-help-column-assigned'}" SizeFlagsStretchRatio="3" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
<Label Text="{Loc 'mentor-help-column-subject'}" SizeFlagsStretchRatio="5" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
</BoxContainer>
<controls:HSeparator/>
@ -39,7 +40,6 @@
<BoxContainer Orientation="Vertical" HorizontalExpand="True" VerticalExpand="True">
<BoxContainer Orientation="Horizontal" HorizontalExpand="True" Margin="5 0 15 0">
<controls:VSeparator/>
<Label Text="{Loc 'mentor-help-column-id'}" SizeFlagsStretchRatio="1" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
<Label Text="{Loc 'mentor-help-column-player'}" SizeFlagsStretchRatio="3" HorizontalExpand="True" HorizontalAlignment="Center"/>
@ -49,7 +49,6 @@
<Label Text="{Loc 'mentor-help-column-assigned'}" SizeFlagsStretchRatio="3" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
<Label Text="{Loc 'mentor-help-column-subject'}" SizeFlagsStretchRatio="5" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
</BoxContainer>
<controls:HSeparator/>
@ -79,11 +78,12 @@
</BoxContainer>
</PanelContainer>
<ScrollContainer Name="MessagesScroll" Access="Public" VerticalExpand="True" HorizontalExpand="True" Margin="5">
<ScrollContainer Name="MessagesScroll" Access="Public" VerticalExpand="True" HorizontalExpand="True" Margin="5" HScrollEnabled="False">
<BoxContainer Name="MessagesContainer" Access="Public" Orientation="Vertical" />
</ScrollContainer>
<BoxContainer Name="ReplyPanel" Access="Public" Orientation="Vertical" HorizontalExpand="True" Margin="5">
<RichTextLabel Name="TypingIndicator" Access="Public" />
<LineEdit Name="ReplyInput" Access="Public" PlaceHolder="{Loc 'mentor-help-reply-placeholder'}"
HorizontalExpand="True" />
<BoxContainer Orientation="Horizontal" HorizontalExpand="True" Margin="0 5 0 0">

View file

@ -11,6 +11,11 @@ using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Network;
using Robust.Shared.Timing;
using Robust.Shared.Configuration;
using Content.Shared._Sunrise.SunriseCCVars;
using Robust.Shared.Utility;
using Content.Client.Stylesheets;
using Robust.Shared.Localization;
namespace Content.Client._Sunrise.MentorHelp
{
@ -21,7 +26,13 @@ namespace Content.Client._Sunrise.MentorHelp
public sealed partial class MentorHelpControl : Control
{
[Dependency] private readonly IUserInterfaceManager _ui = default!;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly ILocalizationManager _loc = default!;
private const double CloseConfirmTimeoutSeconds = 2;
private const double TypingIndicatorTimeoutSeconds = 10;
private bool _isDisposed;
private MentorHelpSystem? _mentorHelpSystem;
private NetUserId _ownerUserId;
@ -29,6 +40,7 @@ namespace Content.Client._Sunrise.MentorHelp
private List<MentorHelpTicketData> _tickets = new();
private MentorHelpTicketData? _selectedTicket;
private Dictionary<int, List<MentorHelpMessageData>> _ticketMessages = new();
private readonly HashSet<int> _newMessageFromAuthorTickets = new();
private readonly Dictionary<int, TicketEntryControl> _openTicketControls = new();
private readonly Dictionary<int, TicketEntryControl> _closedTicketControls = new();
@ -41,12 +53,25 @@ namespace Content.Client._Sunrise.MentorHelp
private MentorHelpNewTicketDialog? _newTicketDialog;
private int? _pendingOpenTicketId;
private List<string> PeopleTyping { get; set; } = new();
private readonly Dictionary<string, TimeSpan> _typingTimeouts = new();
public event Action<string>? InputTextChanged;
private bool _closeConfirming;
private int? _closeConfirmTicketId;
private TimeSpan? _closeConfirmResetOn;
public MentorHelpControl()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
_cfg.OnValueChanged(SunriseCCVars.MentorHelpSoundEnabled, OnMentorHelpSoundEnabledChanged, true);
_cfg.OnValueChanged(SunriseCCVars.MentorHelpAutoOpenOnNewMessage, OnMentorHelpAutoOpenChanged, true);
PlaySound.OnToggled += OnPlaySoundToggled;
AutoOpenTickets.OnToggled += OnAutoOpenTicketsToggled;
// Wire up button events
NewTicketButton.OnPressed += _ => OpenNewTicketDialog();
StatisticsButton.OnPressed += _ => _ui.GetUIController<MentorHelpStatisticsUIController>().ToggleStatistics();
@ -59,10 +84,12 @@ namespace Content.Client._Sunrise.MentorHelp
// Handle enter key in reply input
ReplyInput.OnTextEntered += _ => SendReply();
ReplyInput.OnTextChanged += Input_OnTextChanged;
UpdateTypingIndicator();
// Setup tab container like in AdminMenuWindow
TicketsTabContainer.SetTabTitle(0, Loc.GetString("mentor-help-tab-open"));
TicketsTabContainer.SetTabTitle(1, Loc.GetString("mentor-help-tab-closed"));
TicketsTabContainer.SetTabTitle(0, _loc.GetString("mentor-help-tab-open"));
TicketsTabContainer.SetTabTitle(1, _loc.GetString("mentor-help-tab-closed"));
// Handle tab changes to load closed tickets when needed
TicketsTabContainer.OnTabChanged += OnTabChanged;
@ -75,6 +102,37 @@ namespace Content.Client._Sunrise.MentorHelp
// Set initial state
SwitchState(ViewState.TicketsList);
MessagesContainer.OnChildAdded += _ =>
{
MessagesScroll.VScrollTarget = float.MaxValue;
};
}
private void OnPlaySoundToggled(BaseButton.ButtonToggledEventArgs args)
{
if (_isDisposed)
return;
_cfg.SetCVar(SunriseCCVars.MentorHelpSoundEnabled, args.Pressed);
}
private void OnAutoOpenTicketsToggled(BaseButton.ButtonToggledEventArgs args)
{
if (_isDisposed)
return;
_cfg.SetCVar(SunriseCCVars.MentorHelpAutoOpenOnNewMessage, args.Pressed);
}
private void OnMentorHelpSoundEnabledChanged(bool enabled)
{
PlaySound.Pressed = enabled;
}
private void OnMentorHelpAutoOpenChanged(bool enabled)
{
AutoOpenTickets.Pressed = enabled;
}
/// <summary>
@ -123,6 +181,15 @@ namespace Content.Client._Sunrise.MentorHelp
// State switching like in LobbyGui
private void SwitchState(ViewState state)
{
if (state == ViewState.TicketsList && _selectedTicket != null)
{
_mentorHelpSystem?.SendInputTextUpdated(_selectedTicket.Id, false);
PeopleTyping.Clear();
_typingTimeouts.Clear();
UpdateTypingIndicator();
ResetCloseConfirm();
}
DefaultState.Visible = false;
TicketViewState.Visible = false;
BackToListButton.Visible = false;
@ -201,11 +268,13 @@ namespace Content.Client._Sunrise.MentorHelp
if (controls.TryGetValue(ticket.Id, out var existingControl))
{
existingControl.UpdateData(ticket);
existingControl.SetNewMessageFromAuthor(_newMessageFromAuthorTickets.Contains(ticket.Id));
}
else
{
var control = new TicketEntryControl();
control.UpdateData(ticket);
control.SetNewMessageFromAuthor(_newMessageFromAuthorTickets.Contains(ticket.Id));
control.OnTicketSelected += OnTicketSelected;
controls[ticket.Id] = control;
container.AddChild(control);
@ -215,7 +284,19 @@ namespace Content.Client._Sunrise.MentorHelp
private void OnTicketSelected(MentorHelpTicketData ticket)
{
if (_selectedTicket != null)
_mentorHelpSystem?.SendInputTextUpdated(_selectedTicket.Id, false);
_selectedTicket = ticket;
PeopleTyping.Clear();
_typingTimeouts.Clear();
UpdateTypingIndicator();
ReplyInput.Text = string.Empty;
ResetCloseConfirm();
_newMessageFromAuthorTickets.Remove(ticket.Id);
ticket.HasUnreadMessages = false;
UpdateTicket(ticket);
SwitchState(ViewState.TicketView);
// Clear previous messages first
@ -229,9 +310,45 @@ namespace Content.Client._Sunrise.MentorHelp
{
_ticketMessages[ticketId] = messages;
var ticketIndex = _tickets.FindIndex(t => t.Id == ticketId);
if (ticketIndex >= 0 && messages.Count > 0)
{
var ticket = _tickets[ticketIndex];
var lastMessage = messages[^1];
if (ticket.UpdatedAt < lastMessage.SentAt)
ticket.UpdatedAt = lastMessage.SentAt;
var isViewingTicket = _selectedTicket?.Id == ticketId && TicketViewState.Visible;
var isRelevant = !_hasMentorPermissions
? ticket.PlayerId == _ownerUserId
: ticket.AssignedToUserId == null || ticket.AssignedToUserId == _ownerUserId;
if (isRelevant)
{
var unread = !isViewingTicket && lastMessage.SenderUserId != _ownerUserId;
ticket.HasUnreadMessages = unread;
if (_hasMentorPermissions && unread && lastMessage.SenderUserId == ticket.PlayerId)
_newMessageFromAuthorTickets.Add(ticketId);
else
_newMessageFromAuthorTickets.Remove(ticketId);
}
else
{
ticket.HasUnreadMessages = false;
_newMessageFromAuthorTickets.Remove(ticketId);
}
_tickets[ticketIndex] = ticket;
RefreshTicketsList();
}
if (_selectedTicket?.Id == ticketId && TicketViewState.Visible)
{
DisplayTicketMessages(messages);
ScrollToBottomDeferred();
}
}
@ -282,10 +399,10 @@ namespace Content.Client._Sunrise.MentorHelp
TicketSubjectLabel.Text = _selectedTicket.Subject;
// Компактная строка: статус | назначен | создано
TicketStatus.Text = Loc.GetString("mentor-help-status-label", ("status", GetStatusText(_selectedTicket.Status)));
TicketAssigned.Text = Loc.GetString("mentor-help-assigned-label",
("assigned", _selectedTicket.AssignedToName ?? Loc.GetString("mentor-help-unassigned")));
TicketCreated.Text = Loc.GetString("mentor-help-created-label",
TicketStatus.Text = _loc.GetString("mentor-help-status-label", ("status", GetStatusText(_selectedTicket.Status)));
TicketAssigned.Text = _loc.GetString("mentor-help-assigned-label",
("assigned", _selectedTicket.AssignedToName ?? _loc.GetString("mentor-help-unassigned")));
TicketCreated.Text = _loc.GetString("mentor-help-created-label",
("created", _selectedTicket.CreatedAt.ToString("dd.MM.yyyy HH:mm")));
}
@ -300,13 +417,17 @@ namespace Content.Client._Sunrise.MentorHelp
var canReply = _selectedTicket.Status != MentorHelpTicketStatus.Closed;
var isAssignedToMe = _selectedTicket.AssignedToUserId == _ownerUserId;
var isOpen = _selectedTicket.Status != MentorHelpTicketStatus.Closed;
var isUnassigned = _selectedTicket.AssignedToUserId == null;
// Hide reply panel for closed tickets
ReplyPanel.Visible = canReply;
if (!isOpen)
ResetCloseConfirm();
if (_hasMentorPermissions)
{
ClaimButton.Visible = isOpen && !isAssignedToMe;
ClaimButton.Visible = isOpen && isUnassigned;
UnassignButton.Visible = isOpen && isAssignedToMe;
CloseTicketButton.Visible = isOpen;
}
@ -318,67 +439,83 @@ namespace Content.Client._Sunrise.MentorHelp
}
}
private void ResetCloseConfirm()
{
_closeConfirming = false;
_closeConfirmTicketId = null;
_closeConfirmResetOn = null;
CloseTicketButton.Text = _loc.GetString("mentor-help-close-ticket");
CloseTicketButton.StyleClasses.Remove(StyleNano.StyleClassButtonColorRed);
}
private string GetStatusText(MentorHelpTicketStatus status)
{
return status switch
{
MentorHelpTicketStatus.Open => Loc.GetString("mentor-help-status-open"),
MentorHelpTicketStatus.Assigned => Loc.GetString("mentor-help-status-assigned"),
MentorHelpTicketStatus.AwaitingResponse => Loc.GetString("mentor-help-status-awaiting"),
MentorHelpTicketStatus.Closed => Loc.GetString("mentor-help-status-closed"),
_ => Loc.GetString("mentor-help-status-unknown")
MentorHelpTicketStatus.Open => _loc.GetString("mentor-help-status-open"),
MentorHelpTicketStatus.Assigned => _loc.GetString("mentor-help-status-assigned"),
MentorHelpTicketStatus.AwaitingResponse => _loc.GetString("mentor-help-status-awaiting"),
MentorHelpTicketStatus.Closed => _loc.GetString("mentor-help-status-closed"),
_ => _loc.GetString("mentor-help-status-unknown")
};
}
private void DisplayTicketMessages(List<MentorHelpMessageData> messages)
{
MessagesContainer.RemoveAllChildren();
DateTime? lastDate = null;
foreach (var message in messages.OrderBy(m => m.SentAt))
{
// Ensure we show a date header when the day changes
var sentAt = message.SentAt;
var sentDate = sentAt.Date;
if (lastDate == null || lastDate.Value != sentDate)
{
lastDate = sentDate;
var dateLabel = new RichTextLabel
{
Text = $"[center=\"{sentAt:dd.MM.yyyy}\"]",
HorizontalExpand = true
HorizontalExpand = true,
Margin = new Thickness(0, 10, 0, 5)
};
dateLabel.Text = $"[center][bold]{sentAt:dd.MM.yyyy}[/bold][/center]";
MessagesContainer.AddChild(dateLabel);
}
var messageBox = new PanelContainer
{
StyleClasses = { "PanelColorMedium" },
HorizontalExpand = true,
Margin = new Thickness(0, 2)
};
var vbox = new BoxContainer
var messageBox = new BoxContainer
{
Orientation = BoxContainer.LayoutOrientation.Vertical,
HorizontalExpand = true
Margin = new Thickness(0, 4)
};
// Format: [HH:mm] Author: message
var content = new RichTextLabel
var nameLine = new RichTextLabel();
nameLine.Text = $"[bold]{sentAt:HH:mm}[/bold] {message.FormattedSender}:";
var textLine = new RichTextLabel
{
Text = $"[bold]{sentAt:HH:mm}[/bold] {message.FormattedSender}: {message.Message}",
HorizontalExpand = true
};
textLine.Text = message.Message;
messageBox.AddChild(nameLine);
messageBox.AddChild(textLine);
vbox.AddChild(content);
messageBox.AddChild(vbox);
MessagesContainer.AddChild(messageBox);
}
}
private void ScrollToBottomDeferred()
{
_ui.DeferAction(() =>
{
MessagesScroll.VScrollTarget = float.MaxValue;
});
}
private void OpenNewTicketDialog()
{
if (_newTicketDialog != null)
@ -428,7 +565,122 @@ namespace Content.Client._Sunrise.MentorHelp
if (_selectedTicket == null)
return;
if (!_closeConfirming || _closeConfirmTicketId != _selectedTicket.Id)
{
_closeConfirming = true;
_closeConfirmTicketId = _selectedTicket.Id;
_closeConfirmResetOn = _gameTiming.RealTime + TimeSpan.FromSeconds(CloseConfirmTimeoutSeconds);
CloseTicketButton.Text = _loc.GetString("mentor-help-close-confirm");
CloseTicketButton.StyleClasses.Add(StyleNano.StyleClassButtonColorRed);
return;
}
ResetCloseConfirm();
_mentorHelpSystem?.CloseTicket(_selectedTicket.Id);
}
private void UpdateTypingIndicator()
{
var msg = new FormattedMessage();
msg.PushColor(Color.LightGray);
var text = PeopleTyping.Count == 0
? string.Empty
: _loc.GetString("bwoink-system-typing-indicator",
("players", string.Join(", ", PeopleTyping)),
("count", PeopleTyping.Count));
msg.AddText(text);
msg.Pop();
TypingIndicator.SetMessage(msg);
}
public void UpdatePlayerTyping(int ticketId, string name, bool typing)
{
if (_selectedTicket?.Id != ticketId || !TicketViewState.Visible)
return;
if (typing)
{
var now = _gameTiming.RealTime;
_typingTimeouts[name] = now + TimeSpan.FromSeconds(TypingIndicatorTimeoutSeconds);
if (!PeopleTyping.Contains(name))
PeopleTyping.Add(name);
}
else
{
PeopleTyping.Remove(name);
_typingTimeouts.Remove(name);
}
UpdateTypingIndicator();
}
private void Input_OnTextChanged(LineEdit.LineEditEventArgs args)
{
InputTextChanged?.Invoke(args.Text);
if (_selectedTicket == null)
return;
_mentorHelpSystem?.SendInputTextUpdated(_selectedTicket.Id, args.Text.Length > 0);
}
protected override void FrameUpdate(FrameEventArgs args)
{
base.FrameUpdate(args);
if (Disposed)
return;
var now = _gameTiming.RealTime;
if (_closeConfirmResetOn < now)
{
if (_closeConfirming && _closeConfirmTicketId == _selectedTicket?.Id)
ResetCloseConfirm();
_closeConfirmResetOn = null;
}
if (PeopleTyping.Count == 0)
return;
var updatedTypingIndicator = false;
for (var g = PeopleTyping.Count - 1; g >= 0; g--)
{
var name = PeopleTyping[g];
if (!_typingTimeouts.TryGetValue(name, out var timeoutAt) || timeoutAt > now)
continue;
PeopleTyping.RemoveAt(g);
_typingTimeouts.Remove(name);
updatedTypingIndicator = true;
}
if (updatedTypingIndicator)
UpdateTypingIndicator();
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (!disposing)
return;
_isDisposed = true;
PlaySound.OnToggled -= OnPlaySoundToggled;
AutoOpenTickets.OnToggled -= OnAutoOpenTicketsToggled;
_cfg.UnsubValueChanged(SunriseCCVars.MentorHelpSoundEnabled, OnMentorHelpSoundEnabledChanged);
_cfg.UnsubValueChanged(SunriseCCVars.MentorHelpAutoOpenOnNewMessage, OnMentorHelpAutoOpenChanged);
}
}
}

View file

@ -1,6 +1,8 @@
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.IoC;
using Robust.Shared.Localization;
using Robust.Shared.Utility;
namespace Content.Client._Sunrise.MentorHelp
@ -11,11 +13,14 @@ namespace Content.Client._Sunrise.MentorHelp
[GenerateTypedNameReferences]
public sealed partial class MentorHelpNewTicketDialog : DefaultWindow
{
[Dependency] private readonly ILocalizationManager _loc = default!;
public event Action<string, string>? OnTicketCreated;
public MentorHelpNewTicketDialog()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
CancelButton.OnPressed += _ => Close();
CreateButton.OnPressed += _ =>
@ -25,14 +30,14 @@ namespace Content.Client._Sunrise.MentorHelp
if (string.IsNullOrEmpty(subject))
{
ErrorLabel.Text = Loc.GetString("mentor-help-new-ticket-error-subject");
ErrorLabel.Text = _loc.GetString("mentor-help-new-ticket-error-subject");
ErrorLabel.Visible = true;
return;
}
if (string.IsNullOrEmpty(message))
{
ErrorLabel.Text = Loc.GetString("mentor-help-new-ticket-error-message");
ErrorLabel.Text = _loc.GetString("mentor-help-new-ticket-error-message");
ErrorLabel.Visible = true;
return;
}

View file

@ -4,19 +4,11 @@
Title="{Loc 'mentor-help-statistics-title'}"
Resizable="False">
<BoxContainer Orientation="Vertical" SeparationOverride="10">
<Label Name="HeaderLabel" Access="Public"
Text="{Loc 'mentor-help-statistics-header'}"
StyleClasses="LabelHeading"
HorizontalAlignment="Center" />
<BoxContainer Orientation="Horizontal" HorizontalExpand="True" Margin="5 0 15 0">
<controls:VSeparator/>
<Label Text="Ментор" SizeFlagsStretchRatio="3" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
<Label Text="Взятых тикетов" SizeFlagsStretchRatio="2" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
<Label Text="Сообщений" SizeFlagsStretchRatio="2" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
<Label Text="{Loc 'mentor-help-statistics-column-mentor'}" SizeFlagsStretchRatio="3" HorizontalExpand="True" HorizontalAlignment="Center" StyleClasses="LabelSubText"/>
<Label Text="{Loc 'mentor-help-statistics-column-tickets'}" SizeFlagsStretchRatio="2" HorizontalExpand="True" HorizontalAlignment="Center" StyleClasses="LabelSubText"/>
<Label Text="{Loc 'mentor-help-statistics-column-messages'}" SizeFlagsStretchRatio="2" HorizontalExpand="True" HorizontalAlignment="Center" StyleClasses="LabelSubText"/>
</BoxContainer>
<controls:HSeparator/>

View file

@ -1,5 +1,6 @@
using Content.Shared._Sunrise.MentorHelp;
using JetBrains.Annotations;
using Robust.Shared.Timing;
namespace Content.Client._Sunrise.MentorHelp
{
@ -9,11 +10,17 @@ namespace Content.Client._Sunrise.MentorHelp
[UsedImplicitly]
public sealed class MentorHelpSystem : SharedMentorHelpSystem
{
[Dependency] private readonly IGameTiming _gameTiming = default!;
public event EventHandler<MentorHelpTicketUpdateMessage>? OnTicketUpdated;
public event EventHandler<MentorHelpTicketsListMessage>? OnTicketsListReceived;
public event EventHandler<MentorHelpTicketMessagesMessage>? OnTicketMessagesReceived;
public event EventHandler<MentorHelpStatisticsMessage>? OnStatisticsReceived;
public event EventHandler<MentorHelpOpenTicketMessage>? OnOpenTicketReceived;
public event EventHandler<MentorHelpPlayerTypingUpdated>? OnPlayerTypingUpdated;
private const double TypingUpdateResendIntervalSeconds = 1;
private (TimeSpan Timestamp, bool Typing) _lastTypingUpdateSent;
protected override void OnCreateTicketMessage(MentorHelpCreateTicketMessage message, EntitySessionEventArgs eventArgs)
{
@ -59,6 +66,7 @@ namespace Content.Client._Sunrise.MentorHelp
SubscribeNetworkEvent<MentorHelpTicketMessagesMessage>(OnTicketMessages);
SubscribeNetworkEvent<MentorHelpStatisticsMessage>(OnStatistics);
SubscribeNetworkEvent<MentorHelpOpenTicketMessage>(OnOpenTicket);
SubscribeNetworkEvent<MentorHelpPlayerTypingUpdated>(OnTypingUpdated);
}
private void OnOpenTicket(MentorHelpOpenTicketMessage message, EntitySessionEventArgs eventArgs)
@ -66,6 +74,11 @@ namespace Content.Client._Sunrise.MentorHelp
OnOpenTicketReceived?.Invoke(this, message);
}
private void OnTypingUpdated(MentorHelpPlayerTypingUpdated message, EntitySessionEventArgs eventArgs)
{
OnPlayerTypingUpdated?.Invoke(this, message);
}
private void OnTicketUpdate(MentorHelpTicketUpdateMessage message, EntitySessionEventArgs eventArgs)
{
OnTicketUpdated?.Invoke(this, message);
@ -150,5 +163,17 @@ namespace Content.Client._Sunrise.MentorHelp
{
RaiseNetworkEvent(new MentorHelpRequestStatisticsMessage());
}
public void SendInputTextUpdated(int ticketId, bool typing)
{
if (_lastTypingUpdateSent.Typing == typing &&
_lastTypingUpdateSent.Timestamp + TimeSpan.FromSeconds(TypingUpdateResendIntervalSeconds) > _gameTiming.RealTime)
{
return;
}
_lastTypingUpdateSent = (_gameTiming.RealTime, typing);
RaiseNetworkEvent(new MentorHelpClientTypingUpdated(ticketId, typing));
}
}
}

View file

@ -17,8 +17,10 @@ using Robust.Client.UserInterface.Controllers;
using Robust.Client.UserInterface.Controls;
using Robust.Shared.Configuration;
using Robust.Shared.Input.Binding;
using Robust.Shared.Localization;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Audio;
namespace Content.Client._Sunrise.MentorHelp;
@ -30,17 +32,21 @@ public sealed class MentorHelpUIController : UIController, IOnSystemChanged<Ment
{
[Dependency] private readonly IClientAdminManager _adminManager = default!;
[Dependency] private readonly IConfigurationManager _config = default!;
[Dependency] private readonly ILocalizationManager _loc = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IClyde _clyde = default!;
[Dependency] private readonly IUserInterfaceManager _uiManager = default!;
[UISystemDependency] private readonly AudioSystem _audio = default!;
private MentorHelpSystem? _mentorHelpSystem;
public IMentorHelpUIHandler? UIHelper;
private bool _hasMentorPermissions;
private bool _hasUnreadTickets;
private readonly HashSet<int> _unreadTicketIds = new();
private readonly Dictionary<int, int> _lastMessageIdByTicket = new();
// Последнее состояние тикетов. Нужны для фильтрации "моих" тикетов и авто-открытия.
private readonly Dictionary<int, MentorHelpTicketData> _ticketDataById = new();
private bool _mentorHelpSoundEnabled;
private string? _mentorHelpSound;
private static readonly SoundSpecifier? MentorHelpSound =
new SoundPathSpecifier("/Audio/_Sunrise/Effects/adminticketopen.ogg", AudioParams.Default.WithVolume(-3f));
private Button? LobbyMHelpButton => (UIManager.ActiveScreen as LobbyGui)?.MHelpButton;
private MenuButton? GameMHelpButton => UIManager.GetActiveUIWidgetOrNull<GameTopMenuBar>()?.MHelpButton;
@ -52,7 +58,6 @@ public sealed class MentorHelpUIController : UIController, IOnSystemChanged<Ment
base.Initialize();
_adminManager.AdminStatusUpdated += OnAdminStatusUpdated;
_config.OnValueChanged(SunriseCCVars.MentorHelpSound, v => _mentorHelpSound = v, true); // Reuse ahelp sound for now
_config.OnValueChanged(SunriseCCVars.MentorHelpSoundEnabled, v => _mentorHelpSoundEnabled = v, true);
}
@ -63,6 +68,7 @@ public sealed class MentorHelpUIController : UIController, IOnSystemChanged<Ment
_mentorHelpSystem.OnTicketsListReceived += OnTicketsListReceived;
_mentorHelpSystem.OnTicketMessagesReceived += OnTicketMessagesReceived;
_mentorHelpSystem.OnOpenTicketReceived += OnOpenTicketReceived;
_mentorHelpSystem.OnPlayerTypingUpdated += OnPlayerTypingUpdated;
CommandBinds.Builder
.Bind(ContentKeyFunctions.OpenMentorHelp,
@ -74,9 +80,23 @@ public sealed class MentorHelpUIController : UIController, IOnSystemChanged<Ment
{
EnsureUIHelper();
// Open the window and instruct UI to open the specific ticket
Open();
UIHelper?.OpenTicket(message.TicketId);
if (UIHelper == null)
return;
if (!UIHelper.IsOpen)
{
UIHelper.OpenWindow();
UIHelper.OpenTicket(message.TicketId);
return;
}
if (UIHelper.CurrentTicketId == message.TicketId)
{
_mentorHelpSystem?.RequestTicketMessages(message.TicketId);
return;
}
UIHelper.OpenTicket(message.TicketId);
}
public void OnSystemUnloaded(MentorHelpSystem system)
@ -88,6 +108,8 @@ public sealed class MentorHelpUIController : UIController, IOnSystemChanged<Ment
_mentorHelpSystem.OnTicketUpdated -= OnTicketUpdated;
_mentorHelpSystem.OnTicketsListReceived -= OnTicketsListReceived;
_mentorHelpSystem.OnTicketMessagesReceived -= OnTicketMessagesReceived;
_mentorHelpSystem.OnOpenTicketReceived -= OnOpenTicketReceived;
_mentorHelpSystem.OnPlayerTypingUpdated -= OnPlayerTypingUpdated;
_mentorHelpSystem = null;
}
@ -100,33 +122,39 @@ public sealed class MentorHelpUIController : UIController, IOnSystemChanged<Ment
public void OnStateEntered(GameplayState state)
{
EnsureUIHelper();
SubscribeToButtons();
if (GameMHelpButton != null)
{
// Защита от повторной подписки, OnStateEntered может вызываться несколько раз
// Аналогично в Content.Client/UserInterface/Systems/Bwoink/AHelpUIController.cs (метод OnStateEntered)
GameMHelpButton.OnPressed -= MHelpButtonPressed;
GameMHelpButton.OnPressed += MHelpButtonPressed;
GameMHelpButton.Pressed = UIHelper?.IsOpen ?? false;
UpdateButtonStyling();
}
}
public void OnStateExited(GameplayState state)
{
// Keep UI helper for potential return to game
if (GameMHelpButton != null)
GameMHelpButton.OnPressed -= MHelpButtonPressed;
}
public void OnStateEntered(LobbyState state)
{
EnsureUIHelper();
SubscribeToButtons();
if (LobbyMHelpButton != null)
{
// То же самое для лобби, см. Content.Client/UserInterface/Systems/Bwoink/AHelpUIController.cs (метод OnStateEntered)
LobbyMHelpButton.OnPressed -= MHelpButtonPressed;
LobbyMHelpButton.OnPressed += MHelpButtonPressed;
LobbyMHelpButton.Pressed = UIHelper?.IsOpen ?? false;
UpdateButtonStyling();
}
}
public void OnStateExited(LobbyState state)
{
// Keep UI helper for potential return to lobby
}
private void SubscribeToButtons()
{
if (GameMHelpButton != null)
GameMHelpButton.OnPressed += MHelpButtonPressed;
if (LobbyMHelpButton != null)
LobbyMHelpButton.OnPressed += MHelpButtonPressed;
LobbyMHelpButton.OnPressed -= MHelpButtonPressed;
}
private void MHelpButtonPressed(BaseButton.ButtonEventArgs obj)
@ -136,9 +164,7 @@ public sealed class MentorHelpUIController : UIController, IOnSystemChanged<Ment
private void OnAdminStatusUpdated()
{
_hasMentorPermissions = _adminManager.HasFlag(AdminFlags.Mentor);
if (UIHelper is not { IsOpen: true })
if (UIHelper == null || !UIHelper.IsOpen)
return;
EnsureUIHelper();
@ -146,32 +172,125 @@ public sealed class MentorHelpUIController : UIController, IOnSystemChanged<Ment
private void OnTicketUpdated(object? sender, MentorHelpTicketUpdateMessage message)
{
if (_mentorHelpSound != null && _mentorHelpSoundEnabled)
{
_audio.PlayGlobal(_mentorHelpSound, Filter.Local(), false);
_clyde.RequestWindowAttention();
}
// Новый тикет
var isNewTicket = !_ticketDataById.ContainsKey(message.Ticket.Id);
_ticketDataById[message.Ticket.Id] = message.Ticket;
EnsureUIHelper();
if (!UIHelper!.IsOpen)
if (UIHelper == null)
return;
// Звук для менторов только при появлении нового тикета
if (isNewTicket && _mentorHelpSoundEnabled && _adminManager.HasFlag(AdminFlags.Mentor) && IsRelevantTicket(message.Ticket.Id))
{
UnreadTicketReceived();
_audio.PlayGlobal(MentorHelpSound, Filter.Local(), false);
if (!UIHelper.IsOpen)
_clyde.RequestWindowAttention();
}
UIHelper!.TicketUpdated(message.Ticket);
if (!IsRelevantTicket(message.Ticket.Id))
_unreadTicketIds.Remove(message.Ticket.Id);
UpdateButtonStyling();
UIHelper.TicketUpdated(message.Ticket);
if (UIHelper.IsOpen && UIHelper.CurrentTicketId == message.Ticket.Id)
{
_mentorHelpSystem?.RequestTicketMessages(message.Ticket.Id);
}
}
private void OnTicketsListReceived(object? sender, MentorHelpTicketsListMessage message)
{
EnsureUIHelper();
UIHelper!.TicketsListReceived(message.Tickets);
foreach (var ticket in message.Tickets)
{
_ticketDataById[ticket.Id] = ticket;
if (!IsRelevantTicket(ticket.Id))
_unreadTicketIds.Remove(ticket.Id);
}
UpdateButtonStyling();
if (UIHelper == null)
return;
UIHelper.TicketsListReceived(message.Tickets);
}
private void OnTicketMessagesReceived(object? sender, MentorHelpTicketMessagesMessage message)
{
EnsureUIHelper();
UIHelper!.TicketMessagesReceived(message.TicketId, message.Messages);
UpdateUnreadTickets(message.TicketId, message.Messages);
// Звук только на новые входящие сообщения
TryPlaySoundForNewMessage(message.TicketId, message.Messages);
var autoOpen = _config.GetCVar(SunriseCCVars.MentorHelpAutoOpenOnNewMessage);
var shouldAutoOpen = autoOpen && ShouldAutoOpenTicket(message.TicketId, message.Messages);
// Автооткрытие работает только когда окно закрыто, ибо раздражает постоянными переключениями
if (shouldAutoOpen && UIHelper != null && !UIHelper.IsOpen)
UIHelper.OpenTicket(message.TicketId);
if (UIHelper == null)
return;
UIHelper.TicketMessagesReceived(message.TicketId, message.Messages);
}
/// <summary>
/// Проигрывает звук только на новое входящее сообщение по тикету
/// </summary>
private void TryPlaySoundForNewMessage(int ticketId, List<MentorHelpMessageData> messages)
{
if (!_mentorHelpSoundEnabled || messages.Count == 0)
return;
var lastMessage = messages[^1];
if (!_lastMessageIdByTicket.TryGetValue(ticketId, out var previousMessageId))
{
_lastMessageIdByTicket[ticketId] = lastMessage.Id;
return;
}
// Не новое сообщение
if (previousMessageId == lastMessage.Id)
return;
_lastMessageIdByTicket[ticketId] = lastMessage.Id;
var localUser = _playerManager.LocalUser;
// Без звука свое сообщение
if (localUser == null || lastMessage.SenderUserId == localUser.Value)
return;
// Тикет не релевантентен
if (!IsRelevantTicket(ticketId))
return;
var isViewingTicket = UIHelper != null && UIHelper.IsOpen && UIHelper.CurrentTicketId == ticketId;
var isWindowClosed = UIHelper != null && !UIHelper.IsOpen;
// Окно открыто, но мы не в этом тикете
if (!isViewingTicket && !isWindowClosed)
return;
// Новое входящее сообщение
_audio.PlayGlobal(MentorHelpSound, Filter.Local(), false);
if (isWindowClosed)
_clyde.RequestWindowAttention();
}
private void OnPlayerTypingUpdated(object? sender, MentorHelpPlayerTypingUpdated message)
{
UIHelper?.PlayerTypingUpdated(message.TicketId, message.PlayerName, message.Typing);
}
public void EnsureUIHelper()
@ -182,7 +301,11 @@ public sealed class MentorHelpUIController : UIController, IOnSystemChanged<Ment
return;
UIHelper?.Dispose();
var ownerUserId = _playerManager.LocalUser!.Value;
var localUser = _playerManager.LocalUser;
if (localUser == null)
return;
var ownerUserId = localUser.Value;
UIHelper = hasMentorPerms
? new MentorMentorHelpUIHandler(ownerUserId, _mentorHelpSystem)
@ -196,8 +319,15 @@ public sealed class MentorHelpUIController : UIController, IOnSystemChanged<Ment
/// </summary>
public void Open()
{
if (_playerManager.LocalUser == null)
return;
EnsureUIHelper();
UIHelper!.OpenWindow();
if (UIHelper == null)
return;
UIHelper.OpenWindow();
SetMentorHelpPressed(true);
}
@ -213,7 +343,7 @@ public sealed class MentorHelpUIController : UIController, IOnSystemChanged<Ment
private void SetMentorHelpPressed(bool pressed)
{
UIManager.ClickSound();
UnreadTicketRead();
UnreadTicketsRead();
if (GameMHelpButton != null)
{
@ -226,21 +356,88 @@ public sealed class MentorHelpUIController : UIController, IOnSystemChanged<Ment
}
}
private void UnreadTicketReceived()
private void UnreadTicketsRead()
{
_hasUnreadTickets = true;
_unreadTicketIds.Clear();
UpdateButtonStyling();
}
private void UnreadTicketRead()
private void UpdateUnreadTickets(int ticketId, List<MentorHelpMessageData> messages)
{
_hasUnreadTickets = false;
var localUser = _playerManager.LocalUser;
if (localUser == null)
return;
if (!IsRelevantTicket(ticketId))
{
_unreadTicketIds.Remove(ticketId);
UpdateButtonStyling();
return;
}
if (messages.Count == 0)
{
_unreadTicketIds.Remove(ticketId);
UpdateButtonStyling();
return;
}
var lastMessage = messages[^1];
var isIncoming = lastMessage.SenderUserId != localUser.Value;
var isViewingTicket = UIHelper != null && UIHelper.IsOpen && UIHelper.CurrentTicketId == ticketId;
var unread = isIncoming && !isViewingTicket;
if (unread)
_unreadTicketIds.Add(ticketId);
else
_unreadTicketIds.Remove(ticketId);
UpdateButtonStyling();
}
private bool IsRelevantTicket(int ticketId)
{
var localUser = _playerManager.LocalUser;
if (localUser == null || !_ticketDataById.TryGetValue(ticketId, out var ticket))
return false;
var localId = localUser.Value;
var isMentor = _adminManager.HasFlag(AdminFlags.Mentor);
if (isMentor)
return ticket.AssignedToUserId == null || ticket.AssignedToUserId == localId;
return ticket.PlayerId == localId;
}
private bool ShouldAutoOpenTicket(int ticketId, List<MentorHelpMessageData> messages)
{
var localUser = _playerManager.LocalUser;
if (localUser == null || !_ticketDataById.TryGetValue(ticketId, out var ticket))
return false;
var localId = localUser.Value;
var isOwner = ticket.PlayerId == localId;
var isAssignedToMe = ticket.AssignedToUserId == localId;
// Авто-открытие только у автора тикета и назначенного ментора
if (!isOwner && !isAssignedToMe)
return false;
if (messages.Count == 0)
return false;
var lastMessage = messages[^1];
return lastMessage.SenderUserId != localId;
}
private void UpdateButtonStyling()
{
if (_hasUnreadTickets)
var unreadCount = _unreadTicketIds.Count;
var hasUnread = unreadCount > 0;
var displayCount = unreadCount;
if (hasUnread)
{
GameMHelpButton?.StyleClasses.Add(MenuButton.StyleClassRedTopButton);
LobbyMHelpButton?.StyleClasses.Add("ButtonColorRed");
@ -250,6 +447,18 @@ public sealed class MentorHelpUIController : UIController, IOnSystemChanged<Ment
GameMHelpButton?.StyleClasses.Remove(MenuButton.StyleClassRedTopButton);
LobbyMHelpButton?.StyleClasses.Remove("ButtonColorRed");
}
if (LobbyMHelpButton != null)
{
var baseText = _loc.GetString("ui-lobby-mhelp-button");
LobbyMHelpButton.Text = displayCount > 0 ? $"{baseText} ({displayCount})" : baseText;
}
if (GameMHelpButton != null)
{
var baseTooltip = _loc.GetString("ui-options-function-open-mentor-help");
GameMHelpButton.ToolTip = displayCount > 0 ? $"{baseTooltip} ({displayCount})" : baseTooltip;
}
}
}
@ -260,6 +469,7 @@ public interface IMentorHelpUIHandler : IDisposable
{
bool IsOpen { get; }
bool HasMentorPermissions { get; }
int? CurrentTicketId { get; }
event Action? OnClose;
void OpenWindow();
@ -268,6 +478,7 @@ public interface IMentorHelpUIHandler : IDisposable
void TicketUpdated(MentorHelpTicketData ticket);
void TicketsListReceived(List<MentorHelpTicketData> tickets);
void TicketMessagesReceived(int ticketId, List<MentorHelpMessageData> messages);
void PlayerTypingUpdated(int ticketId, string playerName, bool typing);
}
public sealed class PlayerMentorHelpUIHandler : IMentorHelpUIHandler
@ -275,6 +486,7 @@ public sealed class PlayerMentorHelpUIHandler : IMentorHelpUIHandler
public bool IsOpen { get; private set; }
public bool HasMentorPermissions => false;
public event Action? OnClose;
public int? CurrentTicketId { get; private set; }
private readonly NetUserId _ownerUserId;
private readonly MentorHelpSystem? _mentorHelpSystem;
@ -300,6 +512,7 @@ public sealed class PlayerMentorHelpUIHandler : IMentorHelpUIHandler
_window.OnClose += () =>
{
IsOpen = false;
CurrentTicketId = null;
OnClose?.Invoke();
_window = null;
};
@ -312,6 +525,7 @@ public sealed class PlayerMentorHelpUIHandler : IMentorHelpUIHandler
public void OpenTicket(int ticketId)
{
CurrentTicketId = ticketId;
// Ensure window is open
OpenWindow();
// Ask control to focus the ticket if possible
@ -322,9 +536,14 @@ public sealed class PlayerMentorHelpUIHandler : IMentorHelpUIHandler
public void CloseWindow()
{
_window?.Close();
IsOpen = false;
OnClose?.Invoke();
CurrentTicketId = null;
if (_window == null)
{
IsOpen = false;
return;
}
_window.Close();
}
public void TicketUpdated(MentorHelpTicketData ticket)
@ -342,6 +561,12 @@ public sealed class PlayerMentorHelpUIHandler : IMentorHelpUIHandler
_window?.MentorHelp.UpdateTicketMessages(ticketId, messages);
}
// Обновление статуса печати, все еще печатает ли игрок
public void PlayerTypingUpdated(int ticketId, string playerName, bool typing)
{
_window?.MentorHelp.UpdatePlayerTyping(ticketId, playerName, typing);
}
public void Dispose()
{
CloseWindow();
@ -353,6 +578,7 @@ public sealed class PlayerMentorHelpUIHandler : IMentorHelpUIHandler
/// </summary>
public sealed class MentorMentorHelpUIHandler : IMentorHelpUIHandler
{
public int? CurrentTicketId { get; private set; }
public bool IsOpen { get; private set; }
public bool HasMentorPermissions => true;
public event Action? OnClose;
@ -381,6 +607,7 @@ public sealed class MentorMentorHelpUIHandler : IMentorHelpUIHandler
_window.OnClose += () =>
{
IsOpen = false;
CurrentTicketId = null;
OnClose?.Invoke();
_window = null;
};
@ -393,6 +620,7 @@ public sealed class MentorMentorHelpUIHandler : IMentorHelpUIHandler
public void OpenTicket(int ticketId)
{
CurrentTicketId = ticketId;
OpenWindow();
_window?.MentorHelp.TryOpenTicket(ticketId);
_mentorHelpSystem?.RequestTicketMessages(ticketId);
@ -400,9 +628,14 @@ public sealed class MentorMentorHelpUIHandler : IMentorHelpUIHandler
public void CloseWindow()
{
_window?.Close();
IsOpen = false;
OnClose?.Invoke();
CurrentTicketId = null;
if (_window == null)
{
IsOpen = false;
return;
}
_window.Close();
}
public void TicketUpdated(MentorHelpTicketData ticket)
@ -420,6 +653,12 @@ public sealed class MentorMentorHelpUIHandler : IMentorHelpUIHandler
_window?.MentorHelp.UpdateTicketMessages(ticketId, messages);
}
// Обновление статуса печати, все еще печатает ли ментор
public void PlayerTypingUpdated(int ticketId, string playerName, bool typing)
{
_window?.MentorHelp.UpdatePlayerTyping(ticketId, playerName, typing);
}
public void Dispose()
{
CloseWindow();

View file

@ -1,6 +1,7 @@
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
using System.Numerics;
namespace Content.Client._Sunrise.MentorHelp
{
@ -13,6 +14,8 @@ namespace Content.Client._Sunrise.MentorHelp
public MentorHelpWindow()
{
RobustXamlLoader.Load(this);
MinSize = new Vector2(650, 450);
}
}
}

View file

@ -3,31 +3,31 @@
Name="BackgroundColorPanel"
MouseFilter="Stop">
<BoxContainer Orientation="Vertical">
<customControls:HSeparator/>
<BoxContainer Orientation="Horizontal"
HorizontalExpand="True"
Margin="5 2">
<customControls:VSeparator/>
<Label Name="MentorNameLabel" Access="Public"
SizeFlagsStretchRatio="3"
HorizontalExpand="True"
StyleClasses="LabelSubText"
ClipText="True" />
<customControls:VSeparator/>
<Label Name="TicketsClaimedLabel" Access="Public"
SizeFlagsStretchRatio="2"
HorizontalExpand="True"
StyleClasses="LabelSubText"
Align="Center"
ClipText="True" />
<customControls:VSeparator/>
<Label Name="MessagesCountLabel" Access="Public"
SizeFlagsStretchRatio="2"
HorizontalExpand="True"
StyleClasses="LabelSubText"
Align="Center"
ClipText="True" />
<customControls:VSeparator/>
</BoxContainer>
<customControls:HSeparator/>
</BoxContainer>

View file

@ -7,38 +7,32 @@
<BoxContainer Orientation="Horizontal"
HorizontalExpand="True"
Margin="5 2">
<customControls:VSeparator/>
<Label Name="IdLabel" Access="Public"
SizeFlagsStretchRatio="1"
HorizontalExpand="True"
StyleClasses="LabelSubText"
ClipText="True" />
<customControls:VSeparator/>
<Label Name="PlayerLabel" Access="Public"
SizeFlagsStretchRatio="3"
HorizontalExpand="True"
StyleClasses="LabelSubText"
ClipText="True" />
<customControls:VSeparator/>
<Label Name="StatusLabel" Access="Public"
SizeFlagsStretchRatio="2"
HorizontalExpand="True"
StyleClasses="LabelSubText"
Align="Center"
ClipText="True" />
<customControls:VSeparator/>
<Label Name="AssignedLabel" Access="Public"
SizeFlagsStretchRatio="3"
HorizontalExpand="True"
StyleClasses="LabelSubText"
ClipText="True" />
<customControls:VSeparator/>
<Label Name="SubjectLabel" Access="Public"
SizeFlagsStretchRatio="5"
HorizontalExpand="True"
StyleClasses="LabelSubText"
ClipText="True" />
<customControls:VSeparator/>
</BoxContainer>
<customControls:HSeparator/>
</BoxContainer>

View file

@ -4,23 +4,37 @@ using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.IoC;
using Robust.Shared.Input;
using Robust.Shared.Localization;
namespace Content.Client._Sunrise.MentorHelp
{
[GenerateTypedNameReferences]
public sealed partial class TicketEntryControl : Control
{
[Dependency] private readonly ILocalizationManager _loc = default!;
private static readonly Color NormalColor = Color.FromHex("#202023");
private static readonly Color HoverColor = Color.FromHex("#2F2F33");
private static readonly Color UnreadColor = Color.FromHex("#4A1F1F");
private static readonly Color AuthorUnreadColor = Color.FromHex("#17395C");
private static readonly Color UnassignedColor = Color.FromHex("#1E3A1E");
private static readonly Color ClosedColor = Color.FromHex("#3B0F0F");
private static readonly Color AwaitingColor = Color.FromHex("#3A2F12");
private static readonly Color UnassignedAssigneeLabelColor = Color.FromHex("#7CFF7C");
private const float HoverColorLerp = 0.06f;
private MentorHelpTicketData? _ticketData;
private bool _newMessageFromAuthor;
private bool _isHovering;
public event Action<MentorHelpTicketData>? OnTicketSelected;
public TicketEntryControl()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
TooltipDelay = 0.5f;
BackgroundColorPanel.PanelOverride = new StyleBoxFlat
@ -30,14 +44,14 @@ namespace Content.Client._Sunrise.MentorHelp
BackgroundColorPanel.OnMouseEntered += args =>
{
var panel = (StyleBoxFlat)BackgroundColorPanel.PanelOverride!;
panel.BackgroundColor = HoverColor;
_isHovering = true;
UpdateBackgroundColor();
};
BackgroundColorPanel.OnMouseExited += args =>
{
var panel = (StyleBoxFlat)BackgroundColorPanel.PanelOverride!;
panel.BackgroundColor = NormalColor;
_isHovering = false;
UpdateBackgroundColor();
};
BackgroundColorPanel.OnKeyBindDown += args =>
@ -57,26 +71,88 @@ namespace Content.Client._Sunrise.MentorHelp
IdLabel.Text = $"#{ticketData.Id}";
PlayerLabel.Text = ticketData.PlayerName;
var statusText = GetStatusText(ticketData.Status);
AssignedLabel.Text = ticketData.AssignedToName ?? _loc.GetString("mentor-help-unassigned");
AssignedLabel.FontColorOverride = ticketData.AssignedToUserId == null
&& ticketData.Status != MentorHelpTicketStatus.Closed
? UnassignedAssigneeLabelColor
: null;
SubjectLabel.Text = ticketData.Subject;
UpdateStatusLabel();
UpdateBackgroundColor();
}
public void SetNewMessageFromAuthor(bool newMessageFromAuthor)
{
if (_newMessageFromAuthor == newMessageFromAuthor)
return;
_newMessageFromAuthor = newMessageFromAuthor;
UpdateStatusLabel();
UpdateBackgroundColor();
}
private void UpdateStatusLabel()
{
if (_ticketData == null)
return;
var statusText = GetStatusText(_ticketData.Status);
StatusLabel.Text = statusText;
}
AssignedLabel.Text = ticketData.AssignedToName ?? Loc.GetString("mentor-help-unassigned");
private void UpdateBackgroundColor()
{
if (BackgroundColorPanel.PanelOverride is not StyleBoxFlat panel || _ticketData == null)
return;
var isUnread = _ticketData.HasUnreadMessages;
var isAuthorUnread = isUnread && _newMessageFromAuthor;
var isUnassigned = !isUnread && _ticketData.AssignedToUserId == null;
var isClosed = _ticketData.Status == MentorHelpTicketStatus.Closed;
var isAwaiting = _ticketData.Status == MentorHelpTicketStatus.AwaitingResponse;
var normal = GetNormalBackgroundColor(isClosed, isAwaiting, isAuthorUnread, isUnread, isUnassigned);
var hover = Color.InterpolateBetween(normal, Color.White, HoverColorLerp);
panel.BackgroundColor = _isHovering ? hover : normal;
}
private static Color GetNormalBackgroundColor(
bool isClosed,
bool isAwaiting,
bool isAuthorUnread,
bool isUnread,
bool isUnassigned)
{
if (isClosed)
return ClosedColor;
if (isAwaiting)
return AwaitingColor;
if (isAuthorUnread)
return AuthorUnreadColor;
if (isUnread)
return UnreadColor;
if (isUnassigned)
return UnassignedColor;
return NormalColor;
var subjectText = ticketData.Subject;
if (ticketData.HasUnreadMessages)
subjectText = "* " + subjectText;
SubjectLabel.Text = subjectText;
}
private string GetStatusText(MentorHelpTicketStatus status)
{
return status switch
{
MentorHelpTicketStatus.Open => Loc.GetString("mentor-help-status-open"),
MentorHelpTicketStatus.Assigned => Loc.GetString("mentor-help-status-assigned"),
MentorHelpTicketStatus.AwaitingResponse => Loc.GetString("mentor-help-status-awaiting"),
MentorHelpTicketStatus.Closed => Loc.GetString("mentor-help-status-closed"),
_ => Loc.GetString("mentor-help-status-unknown")
MentorHelpTicketStatus.Open => _loc.GetString("mentor-help-status-open"),
MentorHelpTicketStatus.Assigned => _loc.GetString("mentor-help-status-assigned"),
MentorHelpTicketStatus.AwaitingResponse => _loc.GetString("mentor-help-status-awaiting"),
MentorHelpTicketStatus.Closed => _loc.GetString("mentor-help-status-closed"),
_ => _loc.GetString("mentor-help-status-unknown")
};
}
}

View file

@ -36,8 +36,6 @@ namespace Content.Server._Sunrise.MentorHelp
[Dependency] private readonly PlayerRateLimitManager _rateLimit = default!;
private ISharedSponsorsManager? _sponsorsManager; // Sunrise-Sponsors
private ISawmill _sawmill = default!;
private List<MentorHelpStatisticsData>? _mentorStatsCache;
private DateTimeOffset? _mentorStatsCacheTime;
private readonly float _mentorCacheInterval = 10;
@ -46,8 +44,6 @@ namespace Content.Server._Sunrise.MentorHelp
{
base.Initialize();
_sawmill = IoCManager.Resolve<ILogManager>().GetSawmill("MHELP");
_rateLimit.Register(
RateLimitKey,
new RateLimitRegistration(SunriseCCVars.MentorHelpRateLimitPeriod, // Reuse ahelp rate limit config
@ -55,13 +51,15 @@ namespace Content.Server._Sunrise.MentorHelp
PlayerRateLimitedAction)
);
SubscribeNetworkEvent<MentorHelpClientTypingUpdated>(OnClientTypingUpdated);
_playerManager.PlayerStatusChanged += OnPlayerStatusChanged;
IoCManager.Instance!.TryResolveType(out _sponsorsManager); // Sunrise-Sponsors
}
private void PlayerRateLimitedAction(ICommonSession session)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) was rate limited for mentor help");
Log.Warning($"Player {session.Name} ({session.UserId}) was rate limited for mentor help");
}
private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e)
@ -81,13 +79,13 @@ namespace Content.Server._Sunrise.MentorHelp
// Validate input
if (string.IsNullOrWhiteSpace(message.Subject) || string.IsNullOrWhiteSpace(message.Message))
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to create mentor help ticket with empty subject or message");
Log.Warning($"Player {session.Name} ({session.UserId}) tried to create mentor help ticket with empty subject or message");
return;
}
if (message.Subject.Length > 256 || message.Message.Length > 4096)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to create mentor help ticket with too long subject or message");
Log.Warning($"Player {session.Name} ({session.UserId}) tried to create mentor help ticket with too long subject or message");
return;
}
@ -96,7 +94,7 @@ namespace Content.Server._Sunrise.MentorHelp
var now = DateTimeOffset.UtcNow;
var ticket = new MentorHelpTicket
{
PlayerId = session.UserId.UserId,
PlayerId = session.UserId,
Subject = message.Subject.Trim(),
Status = MentorHelpTicketStatus.Open,
CreatedAt = now,
@ -117,20 +115,19 @@ namespace Content.Server._Sunrise.MentorHelp
};
await _dbManager.AddMentorHelpMessageAsync(ticketMessage);
_sawmill.Info($"Player {session.Name} ({session.UserId}) created mentor help ticket #{ticket.Id}: {ticket.Subject}");
Log.Info($"Player {session.Name} ({session.UserId}) created mentor help ticket #{ticket.Id}: {ticket.Subject}");
// Notify player
var ticketData = await ConvertToTicketDataAsync(ticket);
RaiseNetworkEvent(new MentorHelpTicketUpdateMessage(ticketData), session.Channel);
await NotifyTicketUpdate(ticketData);
// Instruct the player's client to open the newly created ticket
RaiseNetworkEvent(new MentorHelpOpenTicketMessage(ticket.Id), session.Channel);
// Notify mentors/admins
await NotifyMentorsOfNewTicket(ticketData);
var messageData = await ConvertToMessageDataAsync(ticketMessage);
await NotifyTicketMessage(ticketData, messageData);
}
catch (Exception ex)
{
_sawmill.Error($"Error creating mentor help ticket for {session.Name} ({session.UserId}): {ex}");
Log.Error($"Error creating mentor help ticket for {session.Name} ({session.UserId}): {ex}");
}
}
@ -141,7 +138,7 @@ namespace Content.Server._Sunrise.MentorHelp
// Check permissions
if (!HasMentorPermissions(session))
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to claim mentor help ticket without permissions");
Log.Warning($"Player {session.Name} ({session.UserId}) tried to claim mentor help ticket without permissions");
return;
}
@ -150,13 +147,18 @@ namespace Content.Server._Sunrise.MentorHelp
var ticket = await _dbManager.GetMentorHelpTicketAsync(message.TicketId);
if (ticket == null)
{
_sawmill.Warning($"Mentor {session.Name} ({session.UserId}) tried to claim non-existent ticket #{message.TicketId}");
Log.Warning($"Mentor {session.Name} ({session.UserId}) tried to claim non-existent ticket #{message.TicketId}");
return;
}
if (ticket.Status == MentorHelpTicketStatus.Closed)
{
_sawmill.Warning($"Mentor {session.Name} ({session.UserId}) tried to claim closed ticket #{message.TicketId}");
Log.Warning($"Mentor {session.Name} ({session.UserId}) tried to claim closed ticket #{message.TicketId}");
return;
}
if (ticket.AssignedToUserId.HasValue && ticket.AssignedToUserId.Value != session.UserId.UserId)
{
return;
}
@ -167,7 +169,7 @@ namespace Content.Server._Sunrise.MentorHelp
await _dbManager.UpdateMentorHelpTicketAsync(ticket);
_sawmill.Info($"Mentor {session.Name} ({session.UserId}) claimed ticket #{ticket.Id}");
Log.Info($"Mentor {session.Name} ({session.UserId}) claimed ticket #{ticket.Id}");
// Notify all relevant parties
var ticketData = await ConvertToTicketDataAsync(ticket);
@ -175,7 +177,7 @@ namespace Content.Server._Sunrise.MentorHelp
}
catch (Exception ex)
{
_sawmill.Error($"Error claiming mentor help ticket #{message.TicketId} by {session.Name} ({session.UserId}): {ex}");
Log.Error($"Error claiming mentor help ticket #{message.TicketId} by {session.Name} ({session.UserId}): {ex}");
}
}
@ -192,13 +194,13 @@ namespace Content.Server._Sunrise.MentorHelp
var ticket = await _dbManager.GetMentorHelpTicketAsync(message.TicketId);
if (ticket == null)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to reply to non-existent ticket #{message.TicketId}");
Log.Warning($"Player {session.Name} ({session.UserId}) tried to reply to non-existent ticket #{message.TicketId}");
return;
}
if (ticket.Status == MentorHelpTicketStatus.Closed)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to reply to closed ticket #{message.TicketId}");
Log.Warning($"Player {session.Name} ({session.UserId}) tried to reply to closed ticket #{message.TicketId}");
return;
}
@ -208,21 +210,21 @@ namespace Content.Server._Sunrise.MentorHelp
if (!isTicketOwner && !hasMentorPerms)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to reply to ticket #{message.TicketId} without permissions");
Log.Warning($"Player {session.Name} ({session.UserId}) tried to reply to ticket #{message.TicketId} without permissions");
return;
}
// Staff-only messages can only be sent by mentors/admins
if (message.IsStaffOnly && !hasMentorPerms)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to send staff-only message without permissions");
Log.Warning($"Player {session.Name} ({session.UserId}) tried to send staff-only message without permissions");
return;
}
// Validate message
if (string.IsNullOrWhiteSpace(message.Message) || message.Message.Length > 4096)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to send invalid message to ticket #{message.TicketId}");
Log.Warning($"Player {session.Name} ({session.UserId}) tried to send invalid message to ticket #{message.TicketId}");
return;
}
@ -259,16 +261,27 @@ namespace Content.Server._Sunrise.MentorHelp
ticket.UpdatedAt = DateTimeOffset.UtcNow;
await _dbManager.UpdateMentorHelpTicketAsync(ticket);
_sawmill.Info($"Player {session.Name} ({session.UserId}) replied to ticket #{message.TicketId}");
Log.Info($"Player {session.Name} ({session.UserId}) replied to ticket #{message.TicketId}");
// Notify relevant parties
var ticketData = await ConvertToTicketDataAsync(ticket);
var messageData = await ConvertToMessageDataAsync(ticketMessage);
await NotifyTicketUpdate(ticketData);
await NotifyTicketMessage(ticketData, messageData);
if (hasMentorPerms)
{
var userId = new NetUserId(ticket.PlayerId);
if (_playerManager.TryGetSessionById(userId, out var authorSession))
{
RaiseNetworkEvent(new MentorHelpOpenTicketMessage(ticket.Id), authorSession.Channel);
}
}
}
catch (Exception ex)
{
_sawmill.Error($"Error adding reply to mentor help ticket #{message.TicketId} by {session.Name} ({session.UserId}): {ex}");
Log.Error($"Error adding reply to mentor help ticket #{message.TicketId} by {session.Name} ({session.UserId}): {ex}");
}
}
@ -281,13 +294,13 @@ namespace Content.Server._Sunrise.MentorHelp
var ticket = await _dbManager.GetMentorHelpTicketAsync(message.TicketId);
if (ticket == null)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to close non-existent ticket #{message.TicketId}");
Log.Warning($"Player {session.Name} ({session.UserId}) tried to close non-existent ticket #{message.TicketId}");
return;
}
if (ticket.Status == MentorHelpTicketStatus.Closed)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to close already closed ticket #{message.TicketId}");
Log.Warning($"Player {session.Name} ({session.UserId}) tried to close already closed ticket #{message.TicketId}");
return;
}
@ -297,7 +310,7 @@ namespace Content.Server._Sunrise.MentorHelp
if (!isTicketOwner && !hasMentorPerms)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to close ticket #{message.TicketId} without permissions");
Log.Warning($"Player {session.Name} ({session.UserId}) tried to close ticket #{message.TicketId} without permissions");
return;
}
@ -309,7 +322,7 @@ namespace Content.Server._Sunrise.MentorHelp
await _dbManager.UpdateMentorHelpTicketAsync(ticket);
_sawmill.Info($"Player {session.Name} ({session.UserId}) closed ticket #{ticket.Id}");
Log.Info($"Player {session.Name} ({session.UserId}) closed ticket #{ticket.Id}");
// Notify relevant parties
var ticketData = await ConvertToTicketDataAsync(ticket);
@ -317,7 +330,7 @@ namespace Content.Server._Sunrise.MentorHelp
}
catch (Exception ex)
{
_sawmill.Error($"Error closing mentor help ticket #{message.TicketId} by {session.Name} ({session.UserId}): {ex}");
Log.Error($"Error closing mentor help ticket #{message.TicketId} by {session.Name} ({session.UserId}): {ex}");
}
}
@ -339,7 +352,7 @@ namespace Content.Server._Sunrise.MentorHelp
// Mentor/admin requesting all tickets (both open and closed)
if (!HasMentorPermissions(session))
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to request all mentor help tickets without permissions");
Log.Warning($"Player {session.Name} ({session.UserId}) tried to request all mentor help tickets without permissions");
return;
}
@ -359,7 +372,7 @@ namespace Content.Server._Sunrise.MentorHelp
}
catch (Exception ex)
{
_sawmill.Error($"Error requesting mentor help tickets for {session.Name} ({session.UserId}): {ex}");
Log.Error($"Error requesting mentor help tickets for {session.Name} ({session.UserId}): {ex}");
}
}
@ -369,7 +382,7 @@ namespace Content.Server._Sunrise.MentorHelp
if (!HasMentorPermissions(session))
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to unassign mentor help ticket without permissions");
Log.Warning($"Player {session.Name} ({session.UserId}) tried to unassign mentor help ticket without permissions");
return;
}
@ -378,13 +391,13 @@ namespace Content.Server._Sunrise.MentorHelp
var ticket = await _dbManager.GetMentorHelpTicketAsync(message.TicketId);
if (ticket == null)
{
_sawmill.Warning($"Mentor {session.Name} ({session.UserId}) tried to unassign non-existent ticket #{message.TicketId}");
Log.Warning($"Mentor {session.Name} ({session.UserId}) tried to unassign non-existent ticket #{message.TicketId}");
return;
}
if (ticket.Status == MentorHelpTicketStatus.Closed)
{
_sawmill.Warning($"Mentor {session.Name} ({session.UserId}) tried to unassign closed ticket #{message.TicketId}");
Log.Warning($"Mentor {session.Name} ({session.UserId}) tried to unassign closed ticket #{message.TicketId}");
return;
}
@ -394,14 +407,14 @@ namespace Content.Server._Sunrise.MentorHelp
await _dbManager.UpdateMentorHelpTicketAsync(ticket);
_sawmill.Info($"Mentor {session.Name} ({session.UserId}) unassigned ticket #{ticket.Id}");
Log.Info($"Mentor {session.Name} ({session.UserId}) unassigned ticket #{ticket.Id}");
var ticketData = await ConvertToTicketDataAsync(ticket);
await NotifyTicketUpdate(ticketData);
}
catch (Exception ex)
{
_sawmill.Error($"Error unassigning mentor help ticket #{message.TicketId} by {session.Name} ({session.UserId}): {ex}");
Log.Error($"Error unassigning mentor help ticket #{message.TicketId} by {session.Name} ({session.UserId}): {ex}");
}
}
@ -423,7 +436,7 @@ namespace Content.Server._Sunrise.MentorHelp
if (!HasMentorPermissions(session))
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to request mentor help statistics without permissions");
Log.Warning($"Player {session.Name} ({session.UserId}) tried to request mentor help statistics without permissions");
return;
}
@ -466,7 +479,7 @@ namespace Content.Server._Sunrise.MentorHelp
}
catch (Exception ex)
{
_sawmill.Error($"Error requesting mentor help statistics for {session.Name} ({session.UserId}): {ex}");
Log.Error($"Error requesting mentor help statistics for {session.Name} ({session.UserId}): {ex}");
}
}
@ -474,25 +487,44 @@ namespace Content.Server._Sunrise.MentorHelp
{
var session = eventArgs.SenderSession;
_sawmill.Info("Received RequestTicketMessages for ticket #{0} from {1} ({2})", message.TicketId, session.Name, session.UserId);
Log.Info("Received RequestTicketMessages for ticket #{0} from {1} ({2})", message.TicketId, session.Name, session.UserId);
try
{
var ticket = await _dbManager.GetMentorHelpTicketAsync(message.TicketId);
if (ticket == null)
{
Log.Warning($"Player {session.Name} ({session.UserId}) tried to request messages for non-existent ticket #{message.TicketId}");
return;
}
var hasMentorPerms = HasMentorPermissions(session);
var isTicketOwner = ticket.PlayerId == session.UserId.UserId;
if (!hasMentorPerms && !isTicketOwner)
{
Log.Warning($"Player {session.Name} ({session.UserId}) tried to request messages for ticket #{message.TicketId} without permissions");
return;
}
var allMessages = await _dbManager.GetMentorHelpMessagesByTicketAsync(message.TicketId);
var messageDatas = new List<MentorHelpMessageData>();
foreach (var msg in allMessages.OrderBy(m => m.SentAt))
{
if (!hasMentorPerms && msg.IsStaffOnly)
continue;
messageDatas.Add(await ConvertToMessageDataAsync(msg));
}
RaiseNetworkEvent(new MentorHelpTicketMessagesMessage(message.TicketId, messageDatas), session.Channel);
_sawmill.Info("Sent {0} messages for ticket #{1} to {2} ({3})", messageDatas.Count, message.TicketId, session.Name, session.UserId);
}
catch (Exception ex)
{
_sawmill.Error($"Error requesting mentor help messages for ticket #{message.TicketId} by {session.Name} ({session.UserId}): {ex}");
}
}
Log.Info("Sent {0} messages for ticket #{1} to {2} ({3})", messageDatas.Count, message.TicketId, session.Name, session.UserId);
}
catch (Exception ex)
{
Log.Error($"Error requesting mentor help messages for ticket #{message.TicketId} by {session.Name} ({session.UserId}): {ex}");
}
}
private async Task<MentorHelpTicketData> ConvertToTicketDataAsync(MentorHelpTicket ticket)
@ -587,21 +619,12 @@ namespace Content.Server._Sunrise.MentorHelp
var name = playerData?.LastSeenUserName;
if (string.IsNullOrWhiteSpace(name))
{
Logger.Warning($"GetPlayerNameAsync: No name found for userId {userId}, returning 'Unknown'.");
Log.Warning($"GetPlayerNameAsync: No name found for userId {userId}, returning 'Unknown'.");
return "Unknown";
}
return name;
}
private async Task NotifyMentorsOfNewTicket(MentorHelpTicketData ticketData)
{
var mentors = GetTargetMentors();
foreach (var mentor in mentors)
{
RaiseNetworkEvent(new MentorHelpTicketUpdateMessage(ticketData), mentor);
}
}
private async Task NotifyTicketUpdate(MentorHelpTicketData ticketData)
{
// Notify the player
@ -648,6 +671,32 @@ namespace Content.Server._Sunrise.MentorHelp
.Select(p => p.Channel)
.ToList();
}
private async void OnClientTypingUpdated(MentorHelpClientTypingUpdated msg, EntitySessionEventArgs args)
{
var session = args.SenderSession;
var ticket = await _dbManager.GetMentorHelpTicketAsync(msg.TicketId);
if (ticket == null)
return;
var update = new MentorHelpPlayerTypingUpdated(msg.TicketId, session.UserId, session.Name, msg.Typing);
var recipients = new HashSet<INetChannel>();
if (_playerManager.TryGetSessionById(new NetUserId(ticket.PlayerId), out var authorSession))
{
if (!authorSession.UserId.Equals(session.UserId))
recipients.Add(authorSession.Channel);
}
if (ticket.AssignedToUserId.HasValue &&
_playerManager.TryGetSessionById(new NetUserId(ticket.AssignedToUserId.Value), out var mentorSession))
{
if (!mentorSession.UserId.Equals(session.UserId))
recipients.Add(mentorSession.Channel);
}
foreach (var recipient in recipients)
RaiseNetworkEvent(update, recipient);
}
}
}

View file

@ -44,134 +44,85 @@ namespace Content.Shared._Sunrise.MentorHelp
/// Message to create a new mentor help ticket
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpCreateTicketMessage : EntityEventArgs
public sealed class MentorHelpCreateTicketMessage(string subject, string message) : EntityEventArgs
{
public string Subject { get; }
public string Message { get; }
public MentorHelpCreateTicketMessage(string subject, string message)
{
Subject = subject;
Message = message;
}
public readonly string Subject = subject;
public readonly string Message = message;
}
/// <summary>
/// Message to claim a mentor help ticket
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpClaimTicketMessage : EntityEventArgs
public sealed class MentorHelpClaimTicketMessage(int ticketId) : EntityEventArgs
{
public int TicketId { get; }
public MentorHelpClaimTicketMessage(int ticketId)
{
TicketId = ticketId;
}
public readonly int TicketId = ticketId;
}
/// <summary>
/// Message to reply to a mentor help ticket
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpReplyMessage : EntityEventArgs
public sealed class MentorHelpReplyMessage(int ticketId, string message, bool isStaffOnly = false) : EntityEventArgs
{
public int TicketId { get; }
public string Message { get; }
public bool IsStaffOnly { get; }
public MentorHelpReplyMessage(int ticketId, string message, bool isStaffOnly = false)
{
TicketId = ticketId;
Message = message;
IsStaffOnly = isStaffOnly;
}
public readonly int TicketId = ticketId;
public readonly string Message = message;
public readonly bool IsStaffOnly = isStaffOnly;
}
/// <summary>
/// Message to unassign a mentor help ticket
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpUnassignTicketMessage : EntityEventArgs
public sealed class MentorHelpUnassignTicketMessage(int ticketId) : EntityEventArgs
{
public int TicketId { get; }
public MentorHelpUnassignTicketMessage(int ticketId)
{
TicketId = ticketId;
}
public readonly int TicketId = ticketId;
}
/// <summary>
/// Message to close a mentor help ticket
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpCloseTicketMessage : EntityEventArgs
public sealed class MentorHelpCloseTicketMessage(int ticketId) : EntityEventArgs
{
public int TicketId { get; }
public MentorHelpCloseTicketMessage(int ticketId)
{
TicketId = ticketId;
}
public readonly int TicketId = ticketId;
}
/// <summary>
/// Message to request tickets (from client)
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpRequestTicketsMessage : EntityEventArgs
public sealed class MentorHelpRequestTicketsMessage(bool onlyMine = false) : EntityEventArgs
{
public bool OnlyMine { get; }
public MentorHelpRequestTicketsMessage(bool onlyMine = false)
{
OnlyMine = onlyMine;
}
public readonly bool OnlyMine = onlyMine;
}
/// <summary>
/// Message with ticket update (to client)
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpTicketUpdateMessage : EntityEventArgs
public sealed class MentorHelpTicketUpdateMessage(MentorHelpTicketData ticket) : EntityEventArgs
{
public MentorHelpTicketData Ticket { get; }
public MentorHelpTicketUpdateMessage(MentorHelpTicketData ticket)
{
Ticket = ticket;
}
public readonly MentorHelpTicketData Ticket = ticket;
}
/// <summary>
/// Message with tickets list (to client)
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpTicketsListMessage : EntityEventArgs
public sealed class MentorHelpTicketsListMessage(List<MentorHelpTicketData> tickets) : EntityEventArgs
{
public List<MentorHelpTicketData> Tickets { get; }
public MentorHelpTicketsListMessage(List<MentorHelpTicketData> tickets)
{
Tickets = tickets;
}
public readonly List<MentorHelpTicketData> Tickets = tickets;
}
/// <summary>
/// Message with ticket messages (to client)
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpTicketMessagesMessage : EntityEventArgs
public sealed class MentorHelpTicketMessagesMessage(int ticketId, List<MentorHelpMessageData> messages) : EntityEventArgs
{
public int TicketId { get; }
public List<MentorHelpMessageData> Messages { get; }
public MentorHelpTicketMessagesMessage(int ticketId, List<MentorHelpMessageData> messages)
{
TicketId = ticketId;
Messages = messages;
}
public readonly int TicketId = ticketId;
public readonly List<MentorHelpMessageData> Messages = messages;
}
/// <summary>
@ -236,27 +187,18 @@ namespace Content.Shared._Sunrise.MentorHelp
/// Сообщение с результатами статистики по менторам
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpStatisticsMessage : EntityEventArgs
public sealed class MentorHelpStatisticsMessage(List<MentorHelpStatisticsData> statistics) : EntityEventArgs
{
public List<MentorHelpStatisticsData> Statistics { get; }
public MentorHelpStatisticsMessage(List<MentorHelpStatisticsData> statistics)
{
Statistics = statistics;
}
public readonly List<MentorHelpStatisticsData> Statistics = statistics;
}
/// <summary>
/// Message to request messages for a specific ticket (from client)
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpRequestTicketMessagesMessage : EntityEventArgs
public sealed class MentorHelpRequestTicketMessagesMessage(int ticketId) : EntityEventArgs
{
public int TicketId { get; }
public MentorHelpRequestTicketMessagesMessage(int ticketId)
{
TicketId = ticketId;
}
public readonly int TicketId = ticketId;
}
/// <summary>
@ -264,13 +206,24 @@ namespace Content.Shared._Sunrise.MentorHelp
/// This is used immediately after creating a ticket so the creating player sees their new ticket.
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpOpenTicketMessage : EntityEventArgs
public sealed class MentorHelpOpenTicketMessage(int ticketId) : EntityEventArgs
{
public int TicketId { get; }
public readonly int TicketId = ticketId;
}
public MentorHelpOpenTicketMessage(int ticketId)
{
TicketId = ticketId;
}
[Serializable, NetSerializable]
public sealed class MentorHelpClientTypingUpdated(int ticketId, bool typing) : EntityEventArgs
{
public readonly int TicketId = ticketId;
public readonly bool Typing = typing;
}
[Serializable, NetSerializable]
public sealed class MentorHelpPlayerTypingUpdated(int ticketId, NetUserId userId, string playerName, bool typing) : EntityEventArgs
{
public readonly int TicketId = ticketId;
public readonly NetUserId UserId = userId;
public readonly string PlayerName = playerName;
public readonly bool Typing = typing;
}
}

View file

@ -581,9 +581,12 @@ public sealed partial class SunriseCCVars : CVars
public static readonly CVarDef<int> MentorHelpRateLimitCount =
CVarDef.Create("mentor_help.rate_limit_count", 10, CVar.SERVERONLY);
public static readonly CVarDef<string> MentorHelpSound =
CVarDef.Create("mentor_help.mentor_help_sound", "/Audio/_Sunrise/Effects/adminticketopen.ogg", CVar.ARCHIVE | CVar.CLIENTONLY);
public static readonly CVarDef<bool> MentorHelpSoundEnabled =
CVarDef.Create("mentor_help.mentor_help_sound_enabled", true, CVar.ARCHIVE | CVar.CLIENTONLY);
/// <summary>
/// Авто-открывать тикет при получении нового сообщения (только для автора и назначенного ментора).
/// </summary>
public static readonly CVarDef<bool> MentorHelpAutoOpenOnNewMessage =
CVarDef.Create("mentor_help.auto_open_on_new_message", false, CVar.ARCHIVE | CVar.CLIENTONLY);
}

View file

@ -1,5 +1,5 @@
ui-lobby-mhelp-button = MHelp
ui-options-function-open-mentor-help = Открыть ментор помощь
ui-options-function-open-mentor-help = Открыть Ментор помощь
ui-options-function-open-help-choice = Открыть выбор помощи
# Mentor Help Window
@ -12,13 +12,18 @@ mentor-help-tab-closed = Закрытые
# Buttons
mentor-help-statistics = Статистика
mentor-help-new-ticket = Новый тикет
mentor-help-back-to-list = Назад к списку
mentor-help-back-to-list = Назад
mentor-help-send-reply = Отправить
mentor-help-claim = Взять
mentor-help-unassign = Освободить
mentor-help-close-ticket = Закрыть
mentor-help-close-confirm = Вы уверены?
mentor-help-cancel = Отмена
mentor-help-close = Закрыть
mentor-help-auto-open-tickets = Авто-открытие
mentor-help-auto-open-tickets-tooltip = Автоматически открывать тикет при новых сообщениях
mentor-help-ticket-activity-unread = Новое сообщение
mentor-help-ticket-activity-author = Игрок написал
# Table columns
mentor-help-column-id = ID
@ -70,3 +75,9 @@ help-choice-ahelp-button = Админ-помощь
help-choice-mhelp-button = Ментор-помощь
help-choice-ahelp-desc-label = [color=#CCCCCC][font size=12]• Админ-помощь - для жалоб на игроков, сообщений о багах и нарушениях правил[/font][/color]
help-choice-mhelp-desc-label = [color=#CCCCCC][font size=12]• Ментор-помощь - для вопросов о механиках игры и помощи новичкам[/font][/color]
help-kwoink-play-sound = Звук сообщений
mentor-help-statistics-column-mentor = Ментор
mentor-help-statistics-column-tickets = Взятых тикетов
mentor-help-statistics-column-messages = Сообщений