Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1cc812cd3 | ||
|
|
e273a1fa3a | ||
|
|
e297168eef | ||
|
|
984a5384a5 | ||
|
|
b37476785c | ||
|
|
997d5b691f | ||
|
|
63e37a76bf | ||
|
|
66b8a7f74b | ||
|
|
35b07aeacd | ||
|
|
fe2a30456f | ||
|
|
e23d6f3ba7 | ||
|
|
f167435cfc | ||
|
|
3c93bfc943 | ||
|
|
9e617db031 | ||
|
|
258dd35754 | ||
|
|
126ee2ac35 | ||
|
|
188e895d6a | ||
|
|
08ecb92fdc | ||
|
|
683b9db3d9 | ||
|
|
880c1bed31 | ||
|
|
aba4972866 | ||
|
|
2758b202fa | ||
|
|
f797d0bf8d | ||
|
|
5602dc5089 | ||
|
|
19722eeec7 | ||
|
|
fc558e41ea | ||
|
|
daf070f876 | ||
|
|
2cd02f1ea8 | ||
|
|
e1ce26896e | ||
|
|
bd2245eee3 | ||
|
|
0b03791672 | ||
|
|
7250214259 | ||
|
|
46c3b5079d | ||
|
|
3e618923ea |
@@ -59,6 +59,9 @@ public abstract class CartridgeLoaderBoundUserInterface : BoundUserInterface
|
||||
SendCartridgeUiReadyEvent(_activeProgram.Value);
|
||||
}
|
||||
|
||||
if (_activeCartridgeUI is IDisposable disposableUi) // Funky: Handle cleanup for popups and such.
|
||||
disposableUi.Dispose();
|
||||
|
||||
_activeCartridgeUI = ui;
|
||||
_activeUiFragment?.Dispose();
|
||||
_activeUiFragment = control;
|
||||
@@ -121,8 +124,13 @@ public abstract class CartridgeLoaderBoundUserInterface : BoundUserInterface
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (disposing)
|
||||
if (disposing) // Funky: Handle cleanup for popups and such.
|
||||
{
|
||||
if (_activeCartridgeUI is IDisposable disposableUi)
|
||||
disposableUi.Dispose();
|
||||
|
||||
_activeUiFragment?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
protected CartridgeComponent? RetrieveCartridgeComponent(EntityUid? cartridgeUid)
|
||||
|
||||
@@ -26,6 +26,16 @@
|
||||
</PanelContainer.PanelOverride>
|
||||
</PanelContainer>
|
||||
|
||||
<!-- Group chat icon -->
|
||||
<TextureRect Name="GroupIcon"
|
||||
TexturePath="/Textures/_Funkystation/Interface/NanoChat/members.png"
|
||||
MinSize="16 16"
|
||||
MaxSize="16 16"
|
||||
Stretch="KeepAspectCentered"
|
||||
VerticalAlignment="Center"
|
||||
Margin="4 0 6 0"
|
||||
Visible="False" />
|
||||
|
||||
<!-- Text container -->
|
||||
<BoxContainer Orientation="Vertical"
|
||||
HorizontalExpand="True"
|
||||
|
||||
@@ -44,6 +44,7 @@ public sealed partial class NanoChatEntry : BoxContainer
|
||||
JobLabel.Text = FormattedMessage.EscapeText(SharedNanoChatSystem.Truncate(recipient.JobTitle ?? string.Empty, _maxIdJobLength));
|
||||
JobLabel.Visible = !string.IsNullOrWhiteSpace(JobLabel.Text);
|
||||
UnreadIndicator.Visible = recipient.HasUnread;
|
||||
GroupIcon.Visible = recipient.IsGroup;
|
||||
|
||||
ChatButton.ModulateSelfOverride = isSelected ? NanoChatMessageBubble.OwnMessageColor : null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using Content.Shared._DV.CartridgeLoader.Cartridges;
|
||||
using Content.Shared.Roles;
|
||||
using Robust.Client.AutoGenerated;
|
||||
using Robust.Client.UserInterface;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
using Robust.Client.UserInterface.XAML;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Content.Client._DV.CartridgeLoader.Cartridges;
|
||||
|
||||
@@ -26,12 +29,67 @@ public sealed partial class NanoChatLookupView : PanelContainer
|
||||
return;
|
||||
}
|
||||
|
||||
for (var idx = 0; idx < contacts.Count; idx++)
|
||||
var prototypeManager = IoCManager.Resolve<IPrototypeManager>();
|
||||
var departmentGroups = new Dictionary<DepartmentPrototype, List<NanoChatRecipient>>();
|
||||
var noDepartmentContacts = new List<NanoChatRecipient>();
|
||||
|
||||
foreach (var contact in contacts)
|
||||
{
|
||||
var contact = contacts[idx];
|
||||
var isEvenRow = idx % 2 == 0;
|
||||
var contactControl = new ContactContainer(contact, state, isEvenRow, OnStartChat);
|
||||
ContactsList.AddChild(contactControl);
|
||||
if (!string.IsNullOrWhiteSpace(contact.Department) &&
|
||||
prototypeManager.TryIndex<DepartmentPrototype>(contact.Department, out var dept))
|
||||
{
|
||||
departmentGroups.GetOrNew(dept).Add(contact);
|
||||
}
|
||||
else
|
||||
{
|
||||
noDepartmentContacts.Add(contact);
|
||||
}
|
||||
}
|
||||
|
||||
var rowIndex = 0;
|
||||
foreach (var (department, departmentContacts) in departmentGroups.OrderBy(entry => entry.Key, DepartmentUIComparer.Instance))
|
||||
{
|
||||
departmentContacts.Sort();
|
||||
|
||||
// Add department header
|
||||
ContactsList.AddChild(new Label
|
||||
{
|
||||
Text = Loc.GetString(department.Name),
|
||||
StyleClasses = { "LabelBig" },
|
||||
Margin = new Thickness(0, rowIndex == 0 ? 0 : 8, 0, 2)
|
||||
});
|
||||
|
||||
foreach (var contact in departmentContacts)
|
||||
{
|
||||
var isEvenRow = rowIndex % 2 == 0;
|
||||
var contactControl = new ContactContainer(contact, state, isEvenRow, OnStartChat);
|
||||
ContactsList.AddChild(contactControl);
|
||||
rowIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// Add contacts without departments at the end
|
||||
if (noDepartmentContacts.Count > 0)
|
||||
{
|
||||
noDepartmentContacts.Sort();
|
||||
|
||||
if (departmentGroups.Count > 0)
|
||||
{
|
||||
ContactsList.AddChild(new Label
|
||||
{
|
||||
Text = Loc.GetString("nano-chat-other-department"),
|
||||
StyleClasses = { "LabelBig" },
|
||||
Margin = new Thickness(0, 8, 0, 2)
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var contact in noDepartmentContacts)
|
||||
{
|
||||
var isEvenRow = rowIndex % 2 == 0;
|
||||
var contactControl = new ContactContainer(contact, state, isEvenRow, OnStartChat);
|
||||
ContactsList.AddChild(contactControl);
|
||||
rowIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +100,13 @@ public sealed partial class NanoChatLookupView : PanelContainer
|
||||
HorizontalExpand = true;
|
||||
StyleClasses.Add(isEvenRow ? "PanelBackgroundBaseDark" : "PanelBackgroundLight");
|
||||
|
||||
var displayName = contact.Name;
|
||||
if (!string.IsNullOrWhiteSpace(contact.JobTitle))
|
||||
displayName = $"{contact.Name} ({contact.JobTitle})";
|
||||
|
||||
var nameLabel = new Label()
|
||||
{
|
||||
Text = contact.Name,
|
||||
Text = displayName,
|
||||
HorizontalAlignment = HAlignment.Left,
|
||||
HorizontalExpand = true
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<cartridges:NanoChatMessageBubble
|
||||
xmlns="https://spacestation14.io"
|
||||
xmlns:nanochatui="clr-namespace:Content.Client._DV.NanoChat.UI"
|
||||
xmlns:cartridges="clr-namespace:Content.Client._DV.CartridgeLoader.Cartridges"
|
||||
xmlns:graphics="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client"
|
||||
HorizontalExpand="True">
|
||||
@@ -7,31 +8,34 @@
|
||||
<BoxContainer Name="MessageContainer"
|
||||
Orientation="Horizontal"
|
||||
HorizontalExpand="True">
|
||||
<!-- Left spacer for other's messages -->
|
||||
<Control Name="LeftSpacer"
|
||||
MinSize="12 0" />
|
||||
<!-- Left spacer for other's messages -->
|
||||
<Control MinSize="12 0" Name="LeftSpacer" />
|
||||
|
||||
<!-- Message panel -->
|
||||
<BoxContainer Name="MessageBox"
|
||||
Orientation="Vertical"
|
||||
MaxWidth="320"
|
||||
HorizontalExpand="True">
|
||||
|
||||
<!-- Sender name -->
|
||||
<Label HorizontalAlignment="Left"
|
||||
Margin="10,0,10,2"
|
||||
Name="SenderNameLabel"
|
||||
StyleClasses="LabelSmall"
|
||||
Visible="False" />
|
||||
|
||||
<PanelContainer Name="MessagePanel"
|
||||
MaxWidth="320"
|
||||
HorizontalExpand="True">
|
||||
<PanelContainer.PanelOverride>
|
||||
<graphics:StyleBoxFlat
|
||||
ContentMarginLeftOverride="10"
|
||||
ContentMarginRightOverride="10"
|
||||
ContentMarginTopOverride="6"
|
||||
ContentMarginBottomOverride="6"
|
||||
BorderThickness="1">
|
||||
<graphics:StyleBoxFlat BorderThickness="1">
|
||||
<!-- Colors set in code based on message sender -->
|
||||
</graphics:StyleBoxFlat>
|
||||
</PanelContainer.PanelOverride>
|
||||
|
||||
<RichTextLabel Name="MessageText"
|
||||
HorizontalExpand="True" />
|
||||
HorizontalExpand="True"
|
||||
Margin="10,6,10,6" />
|
||||
</PanelContainer>
|
||||
|
||||
<!-- Delivery failed text -->
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Content.Shared._DV.CartridgeLoader.Cartridges;
|
||||
using Robust.Client.AutoGenerated;
|
||||
using Robust.Client.Graphics;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
using Robust.Client.UserInterface.XAML;
|
||||
using Robust.Shared.Utility;
|
||||
using Content.Client._Funkystation.NanoChat;
|
||||
|
||||
namespace Content.Client._DV.CartridgeLoader.Cartridges;
|
||||
|
||||
[GenerateTypedNameReferences]
|
||||
public sealed partial class NanoChatMessageBubble : BoxContainer
|
||||
{
|
||||
private static readonly Regex EmojiRegex = new(@":(\w+):", RegexOptions.Compiled);
|
||||
|
||||
public static readonly Color OwnMessageColor = Color.FromHex("#173717d9"); // Dark green
|
||||
public static readonly Color OtherMessageColor = Color.FromHex("#252525d9"); // Dark gray
|
||||
public static readonly Color BorderColor = Color.FromHex("#40404066"); // Subtle border
|
||||
@@ -21,7 +25,7 @@ public sealed partial class NanoChatMessageBubble : BoxContainer
|
||||
RobustXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public void SetMessage(NanoChatMessage message, bool isOwnMessage)
|
||||
public void SetMessage(NanoChatMessage message, bool isOwnMessage, string? senderName = null, bool showSenderName = false)
|
||||
{
|
||||
if (MessagePanel.PanelOverride is not StyleBoxFlat)
|
||||
return;
|
||||
@@ -31,8 +35,20 @@ public sealed partial class NanoChatMessageBubble : BoxContainer
|
||||
style.BackgroundColor = isOwnMessage ? OwnMessageColor : OtherMessageColor;
|
||||
style.BorderColor = BorderColor;
|
||||
|
||||
// Set message content
|
||||
MessageText.Text = FormattedMessage.EscapeText(message.Content);
|
||||
var contentWithEmoji = EmojiRegex.Replace(FormattedMessage.EscapeText(message.Content), "[emoji=\"$1\"]");
|
||||
|
||||
MessageText.SetMessage(
|
||||
FormattedMessage.FromMarkupPermissive(contentWithEmoji),
|
||||
new[] { typeof(EmojiTag) }
|
||||
);
|
||||
|
||||
SenderNameLabel.Visible = showSenderName && !string.IsNullOrEmpty(senderName);
|
||||
if (SenderNameLabel.Visible)
|
||||
{
|
||||
SenderNameLabel.Text = senderName;
|
||||
SenderNameLabel.Modulate = TextColor;
|
||||
}
|
||||
|
||||
MessageText.Modulate = TextColor;
|
||||
|
||||
// Show delivery failed text if needed (only for own messages)
|
||||
|
||||
@@ -5,7 +5,7 @@ using Robust.Client.UserInterface;
|
||||
|
||||
namespace Content.Client._DV.CartridgeLoader.Cartridges;
|
||||
|
||||
public sealed partial class NanoChatUi : UIFragment
|
||||
public sealed partial class NanoChatUi : UIFragment, IDisposable
|
||||
{
|
||||
private NanoChatUiFragment? _fragment;
|
||||
|
||||
@@ -30,6 +30,12 @@ public sealed partial class NanoChatUi : UIFragment
|
||||
_fragment?.UpdateState(cast);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Close all popups when switching apps or closing the PDA
|
||||
_fragment?.CloseAllPopups();
|
||||
}
|
||||
|
||||
private static void SendNanoChatUiMessage(NanoChatUiMessageType type,
|
||||
uint? number,
|
||||
string? content,
|
||||
|
||||
@@ -60,6 +60,17 @@
|
||||
Margin="0 0 4 0"
|
||||
StyleClasses="OpenBoth"
|
||||
ToolTip="{Loc nano-chat-new-chat}" />
|
||||
<Button Name="CreateGroupChatButton"
|
||||
MinSize="32 32"
|
||||
MaxSize="32 32"
|
||||
Margin="0 0 4 0"
|
||||
StyleClasses="OpenBoth"
|
||||
ToolTip="{Loc nano-chat-create-group}">
|
||||
<TextureRect StyleClasses="ButtonSquare"
|
||||
TexturePath="/Textures/_Funkystation/Interface/NanoChat/members.png"
|
||||
Stretch="KeepAspectCentered"
|
||||
MinSize="18 18" />
|
||||
</Button>
|
||||
<Button Name="LookupButton"
|
||||
MaxSize="32 32"
|
||||
StyleClasses="OpenBoth"
|
||||
@@ -84,12 +95,20 @@
|
||||
</BoxContainer>
|
||||
</controls:StripeBack>
|
||||
|
||||
<Label Name="NoIdLabel"
|
||||
Text="{Loc 'nano-chat-no-id-card'}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
VerticalExpand="True"
|
||||
HorizontalExpand="True"
|
||||
Visible="True" />
|
||||
<!-- Main content split -->
|
||||
<BoxContainer Name="ChatView"
|
||||
Orientation="Horizontal"
|
||||
VerticalExpand="True"
|
||||
HorizontalExpand="True"
|
||||
Margin="0 5 0 0">
|
||||
Margin="0 5 0 0"
|
||||
Visible="False">
|
||||
<!-- Left panel: Chat list -->
|
||||
<PanelContainer StyleClasses="AngleRect"
|
||||
VerticalExpand="True"
|
||||
@@ -167,6 +186,18 @@
|
||||
Stretch="KeepAspectCentered"
|
||||
MinSize="18 18" />
|
||||
</Button>
|
||||
<!-- View Members should take place of edit button in group chats, to avoid layout shift when moving between normal and group -->
|
||||
<Button Name="ViewMembersButton"
|
||||
MaxSize="32 32"
|
||||
Visible="False"
|
||||
StyleClasses="OpenBoth"
|
||||
Margin="0 0 4 0"
|
||||
ToolTip="{Loc nano-chat-view-members}">
|
||||
<TextureRect StyleClasses="ButtonSquare"
|
||||
TexturePath="/Textures/_Funkystation/Interface/NanoChat/members.png"
|
||||
Stretch="KeepAspectCentered"
|
||||
MinSize="18 18" />
|
||||
</Button>
|
||||
<Button Name="DeleteChatButton"
|
||||
MaxSize="32 32"
|
||||
Visible="False"
|
||||
@@ -197,11 +228,34 @@
|
||||
MinSize="18 18" />
|
||||
</Control>
|
||||
</Button>
|
||||
<Button Name="InviteToGroupButton"
|
||||
MaxSize="32 32"
|
||||
Visible="False"
|
||||
StyleClasses="OpenBoth"
|
||||
Margin="0 0 4 0"
|
||||
ToolTip="{Loc nano-chat-invite-to-group}">
|
||||
<TextureRect StyleClasses="ButtonSquare"
|
||||
TexturePath="/Textures/_Funkystation/Interface/NanoChat/invite.png"
|
||||
Stretch="KeepAspectCentered"
|
||||
MinSize="18 18" />
|
||||
</Button>
|
||||
<!-- Input row -->
|
||||
<LineEdit Name="MessageInput"
|
||||
PlaceHolder="{Loc nano-chat-message-placeholder}"
|
||||
HorizontalExpand="True"
|
||||
StyleClasses="OpenRight" />
|
||||
<!-- Emoji Picker -->
|
||||
<Button Name="EmojiButton"
|
||||
MinSize="32 32"
|
||||
MaxSize="32 32"
|
||||
StyleClasses="OpenBoth"
|
||||
Margin="4 0 0 0"
|
||||
ToolTip="{Loc nano-chat-emoji-picker}">
|
||||
<TextureRect StyleClasses="ButtonSquare"
|
||||
TexturePath="/Textures/Interface/Emotes/laugh.png"
|
||||
Stretch="KeepAspectCentered"
|
||||
MinSize="24 24" />
|
||||
</Button>
|
||||
<Button Name="SendButton"
|
||||
MinSize="32 32"
|
||||
Disabled="True"
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
// SPDX-FileCopyrightText: 2024 Skubman <ba.fallaria@gmail.com>
|
||||
// SPDX-FileCopyrightText: 2024 Tadeo <td12233a@gmail.com>
|
||||
// SPDX-FileCopyrightText: 2025 Evaisa <evagiacosa1@gmail.com>
|
||||
// SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
|
||||
// SPDX-FileCopyrightText: 2025 Icepick <122653407+Icepicked@users.noreply.github.com>
|
||||
// SPDX-FileCopyrightText: 2025 corresp0nd <46357632+corresp0nd@users.noreply.github.com>
|
||||
// SPDX-FileCopyrightText: 2025 taydeo <td12233a@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using Content.Shared._DV.CartridgeLoader.Cartridges;
|
||||
using Content.Shared.CCVar;
|
||||
using Content.Client._Funkystation.NanoChat;
|
||||
using Robust.Client.AutoGenerated;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
using Robust.Client.UserInterface.XAML;
|
||||
@@ -18,10 +29,14 @@ public sealed partial class NanoChatUiFragment : BoxContainer
|
||||
[Dependency] private readonly IGameTiming _timing = default!;
|
||||
[Dependency] private readonly IConfigurationManager _cfg = default!;
|
||||
|
||||
private int _maxNameLength;
|
||||
private int _maxIdJobLength;
|
||||
private readonly int _maxNameLength;
|
||||
private readonly int _maxIdJobLength;
|
||||
private readonly NewChatPopup _newChatPopup;
|
||||
private readonly EditChatPopup _editChatPopup;
|
||||
private readonly CreateGroupChatPopup _createGroupChatPopup;
|
||||
private readonly InviteToGroupPopup _inviteToGroupPopup;
|
||||
private readonly GroupMembersPopup _groupMembersPopup;
|
||||
private readonly EmojiPickerPopup _emojiPickerPopup;
|
||||
private uint? _currentChat;
|
||||
private uint? _pendingChat;
|
||||
private uint _ownNumber;
|
||||
@@ -30,6 +45,7 @@ public sealed partial class NanoChatUiFragment : BoxContainer
|
||||
private Dictionary<uint, NanoChatRecipient> _recipients = [];
|
||||
private Dictionary<uint, List<NanoChatMessage>> _messages = [];
|
||||
private HashSet<uint> _mutedChats = [];
|
||||
private List<NanoChatRecipient>? _contacts; // Used in order to show names for people not in recipients
|
||||
|
||||
public event Action<NanoChatUiMessageType, uint?, string?, string?>? OnMessageSent;
|
||||
|
||||
@@ -43,6 +59,10 @@ public sealed partial class NanoChatUiFragment : BoxContainer
|
||||
|
||||
_newChatPopup = new(_maxNameLength, _maxIdJobLength);
|
||||
_editChatPopup = new(_maxNameLength, _maxIdJobLength);
|
||||
_createGroupChatPopup = new(_maxNameLength);
|
||||
_inviteToGroupPopup = new();
|
||||
_groupMembersPopup = new();
|
||||
_emojiPickerPopup = new();
|
||||
SetupEventHandlers();
|
||||
}
|
||||
|
||||
@@ -58,12 +78,57 @@ public sealed partial class NanoChatUiFragment : BoxContainer
|
||||
OnMessageSent?.Invoke(NanoChatUiMessageType.EditChat, number, name, job);
|
||||
};
|
||||
|
||||
_createGroupChatPopup.OnGroupCreated += (name) =>
|
||||
{
|
||||
OnMessageSent?.Invoke(NanoChatUiMessageType.CreateGroupChat, null, name, null);
|
||||
};
|
||||
|
||||
_inviteToGroupPopup.OnInvite += (number) =>
|
||||
{
|
||||
if (_currentChat is not uint currentChat)
|
||||
return;
|
||||
|
||||
OnMessageSent?.Invoke(NanoChatUiMessageType.InviteToGroup, currentChat, number.ToString(), null);
|
||||
};
|
||||
|
||||
_groupMembersPopup.OnKick += (number) =>
|
||||
{
|
||||
if (_currentChat is not uint currentChat)
|
||||
return;
|
||||
|
||||
OnMessageSent?.Invoke(NanoChatUiMessageType.KickFromGroup, currentChat, number.ToString(), null);
|
||||
};
|
||||
|
||||
_groupMembersPopup.OnAdmin += (number) =>
|
||||
{
|
||||
if (_currentChat is not uint currentChat)
|
||||
return;
|
||||
|
||||
OnMessageSent?.Invoke(NanoChatUiMessageType.AdminUser, currentChat, number.ToString(), null);
|
||||
};
|
||||
|
||||
_groupMembersPopup.OnDeadmin += (number) =>
|
||||
{
|
||||
if (_currentChat is not uint currentChat)
|
||||
return;
|
||||
|
||||
OnMessageSent?.Invoke(NanoChatUiMessageType.DeadminUser, currentChat, number.ToString(), null);
|
||||
};
|
||||
|
||||
_emojiPickerPopup.OnEmojiSelected += InsertEmoji;
|
||||
|
||||
NewChatButton.OnPressed += _ =>
|
||||
{
|
||||
_newChatPopup.ClearInputs();
|
||||
_newChatPopup.OpenCentered();
|
||||
};
|
||||
|
||||
CreateGroupChatButton.OnPressed += _ =>
|
||||
{
|
||||
_createGroupChatPopup.ClearInputs();
|
||||
_createGroupChatPopup.OpenCentered();
|
||||
};
|
||||
|
||||
MuteChatButton.OnPressed += _ =>
|
||||
{
|
||||
if (_currentChat is not uint currentChat)
|
||||
@@ -134,8 +199,39 @@ public sealed partial class NanoChatUiFragment : BoxContainer
|
||||
|
||||
MessageInput.OnTextEntered += _ => SendMessage();
|
||||
SendButton.OnPressed += _ => SendMessage();
|
||||
EmojiButton.OnPressed += _ => OpenEmojiPicker();
|
||||
EditChatButton.OnPressed += _ => BeginEditChat();
|
||||
DeleteChatButton.OnPressed += _ => DeleteCurrentChat();
|
||||
InviteToGroupButton.OnPressed += _ => OpenInvitePopup();
|
||||
ViewMembersButton.OnPressed += _ => OpenMembersPopup();
|
||||
}
|
||||
|
||||
private void OpenEmojiPicker()
|
||||
{
|
||||
// Close any existing emoji picker to prevent duplicates (shouldn't be happening anyway but it sure did)
|
||||
if (_emojiPickerPopup.IsOpen)
|
||||
_emojiPickerPopup.Close();
|
||||
|
||||
_emojiPickerPopup.OpenCentered();
|
||||
}
|
||||
|
||||
private void InsertEmoji(string emoji)
|
||||
{
|
||||
MessageInput.InsertAtCursor(emoji);
|
||||
MessageInput.GrabKeyboardFocus();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes all open popups. Called when switching apps or closing the PDA.
|
||||
/// </summary>
|
||||
public void CloseAllPopups()
|
||||
{
|
||||
_emojiPickerPopup.Close();
|
||||
_inviteToGroupPopup.Close();
|
||||
_groupMembersPopup.Close();
|
||||
_newChatPopup.Close();
|
||||
_editChatPopup.Close();
|
||||
_createGroupChatPopup.Close();
|
||||
}
|
||||
|
||||
private void ToggleView()
|
||||
@@ -156,7 +252,7 @@ public sealed partial class NanoChatUiFragment : BoxContainer
|
||||
if (_recipients.Count == 0)
|
||||
return;
|
||||
|
||||
var orderedRecipients = _recipients.OrderBy(r => r.Value.Name).Select(r => r.Key).ToArray();
|
||||
var orderedRecipients = _recipients.OrderBy(r => r.Value).Select(r => r.Key).ToArray();
|
||||
var currentChatIndex = (direction, _currentChat) switch
|
||||
{
|
||||
(CycleDirection.Up, null) => _recipients.Count,
|
||||
@@ -252,6 +348,89 @@ public sealed partial class NanoChatUiFragment : BoxContainer
|
||||
OnMessageSent?.Invoke(NanoChatUiMessageType.DeleteChat, activeChat, null, null);
|
||||
}
|
||||
|
||||
private void OpenInvitePopup()
|
||||
{
|
||||
if (_currentChat is not uint currentChat ||
|
||||
!_recipients.TryGetValue(currentChat, out var recipient) ||
|
||||
!recipient.IsGroup)
|
||||
return;
|
||||
|
||||
var availableContacts = _contacts ?? new List<NanoChatRecipient>();
|
||||
var members = recipient.Members ?? new HashSet<uint>();
|
||||
_inviteToGroupPopup.SetContacts(availableContacts, members, _ownNumber);
|
||||
_inviteToGroupPopup.ClearSearch();
|
||||
_inviteToGroupPopup.OpenCentered();
|
||||
}
|
||||
|
||||
private void OpenMembersPopup()
|
||||
{
|
||||
if (_currentChat is not uint currentChat ||
|
||||
!_recipients.TryGetValue(currentChat, out var recipient) ||
|
||||
!recipient.IsGroup)
|
||||
return;
|
||||
|
||||
var availableContacts = _contacts ?? new List<NanoChatRecipient>();
|
||||
var members = recipient.Members ?? new HashSet<uint>();
|
||||
var admins = recipient.Admins ?? new HashSet<uint>();
|
||||
_groupMembersPopup.SetMembers(availableContacts, members, _ownNumber, recipient.CreatorId, admins);
|
||||
_groupMembersPopup.OpenCentered();
|
||||
}
|
||||
|
||||
private void RefreshOpenPopups()
|
||||
{
|
||||
// If popup is open, refresh it or close if no longer a member
|
||||
if (_inviteToGroupPopup.IsOpen)
|
||||
{
|
||||
if (_currentChat is uint currentChat &&
|
||||
_recipients.TryGetValue(currentChat, out var recipient) &&
|
||||
recipient.IsGroup)
|
||||
{
|
||||
var members = recipient.Members ?? new HashSet<uint>();
|
||||
if (!members.Contains(_ownNumber))
|
||||
{
|
||||
_inviteToGroupPopup.Close();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use the contacts list from the server (same as Look Up Numbers)
|
||||
// Don't fall back to recipients as they contain outdated cached information
|
||||
var availableContacts = _contacts ?? new List<NanoChatRecipient>();
|
||||
_inviteToGroupPopup.SetContacts(availableContacts, members, _ownNumber);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_inviteToGroupPopup.Close();
|
||||
}
|
||||
}
|
||||
|
||||
if (_groupMembersPopup.IsOpen)
|
||||
{
|
||||
if (_currentChat is uint currentChat &&
|
||||
_recipients.TryGetValue(currentChat, out var recipient) &&
|
||||
recipient.IsGroup)
|
||||
{
|
||||
var members = recipient.Members ?? new HashSet<uint>();
|
||||
if (!members.Contains(_ownNumber))
|
||||
{
|
||||
_groupMembersPopup.Close();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use the contacts list from the server (same as Look Up Numbers)
|
||||
// Don't fall back to recipients as they contain outdated cached information
|
||||
var availableContacts = _contacts ?? new List<NanoChatRecipient>();
|
||||
var admins = recipient.Admins ?? new HashSet<uint>();
|
||||
_groupMembersPopup.SetMembers(availableContacts, members, _ownNumber, recipient.CreatorId, admins);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_groupMembersPopup.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void BeginEditChat()
|
||||
{
|
||||
if (_currentChat is not uint currentChat)
|
||||
@@ -294,17 +473,40 @@ public sealed partial class NanoChatUiFragment : BoxContainer
|
||||
var activeChat = _pendingChat ?? _currentChat;
|
||||
var hasActiveChat = activeChat != null;
|
||||
|
||||
var isGroupChat = false;
|
||||
var canManageGroup = false;
|
||||
NanoChatRecipient recipient = default;
|
||||
var hasRecipient = false;
|
||||
|
||||
if (hasActiveChat && activeChat != null && _recipients.TryGetValue(activeChat.Value, out recipient))
|
||||
{
|
||||
hasRecipient = true;
|
||||
isGroupChat = recipient.IsGroup;
|
||||
canManageGroup = isGroupChat && (recipient.CreatorId == _ownNumber || recipient.Admins?.Contains(_ownNumber) == true);
|
||||
}
|
||||
|
||||
// Update UI state
|
||||
MessagesScroll.Visible = hasActiveChat;
|
||||
CurrentChatName.Visible = !hasActiveChat;
|
||||
MessageInputContainer.Visible = hasActiveChat;
|
||||
DeleteChatButton.Visible = hasActiveChat;
|
||||
EditChatButton.Visible = hasActiveChat;
|
||||
EditChatButton.Visible = hasActiveChat && !isGroupChat;
|
||||
DeleteChatButton.Disabled = !hasActiveChat;
|
||||
|
||||
if (activeChat != null && _recipients.TryGetValue(activeChat.Value, out var recipient))
|
||||
|
||||
InviteToGroupButton.Visible = canManageGroup;
|
||||
ViewMembersButton.Visible = isGroupChat;
|
||||
|
||||
if (hasRecipient)
|
||||
{
|
||||
CurrentChatName.Text = recipient.Name + (string.IsNullOrEmpty(recipient.JobTitle) ? "" : $" ({recipient.JobTitle})");
|
||||
if (isGroupChat)
|
||||
{
|
||||
CurrentChatName.Text = recipient.Name;
|
||||
}
|
||||
else
|
||||
{
|
||||
CurrentChatName.Text = recipient.Name + (string.IsNullOrEmpty(recipient.JobTitle) ? "" : $" ({recipient.JobTitle})");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -321,14 +523,44 @@ public sealed partial class NanoChatUiFragment : BoxContainer
|
||||
if (activeChat == null || !messages.TryGetValue(activeChat.Value, out var chatMessages))
|
||||
return;
|
||||
|
||||
var isGroupChat = _recipients.TryGetValue(activeChat.Value, out var recipient) && recipient.IsGroup;
|
||||
foreach (var message in chatMessages)
|
||||
{
|
||||
var messageBubble = new NanoChatMessageBubble();
|
||||
messageBubble.SetMessage(message, message.SenderId == _ownNumber);
|
||||
MessageList.AddChild(messageBubble);
|
||||
var isOwnMessage = message.SenderId == _ownNumber;
|
||||
|
||||
// For group chats, show sender name for other people's messages
|
||||
string? senderName = null;
|
||||
if (isGroupChat && !isOwnMessage)
|
||||
{
|
||||
// Try to get sender name from contacts first, then recipients
|
||||
if (_recipients.TryGetValue(message.SenderId, out var senderRecipient))
|
||||
{
|
||||
senderName = senderRecipient.Name;
|
||||
}
|
||||
|
||||
// If not in recipients, try contacts list (station-wide directory)
|
||||
if (senderName == null && _contacts != null)
|
||||
{
|
||||
var contact = _contacts.FirstOrDefault(c => c.Number == message.SenderId);
|
||||
if (contact.Number != 0)
|
||||
senderName = contact.Name;
|
||||
}
|
||||
|
||||
// Fallback to "Unknown" with number if name not found
|
||||
if (senderName == null)
|
||||
{
|
||||
senderName = $"{Loc.GetString("nano-chat-unknown-sender")}#{message.SenderId:D4}";
|
||||
}
|
||||
}
|
||||
|
||||
messageBubble.SetMessage(message, isOwnMessage, senderName, isGroupChat && !isOwnMessage);
|
||||
|
||||
// Add spacing between messages
|
||||
MessageList.AddChild(new Control { MinSize = new Vector2(0, 4) });
|
||||
if (MessageList.ChildCount > 0)
|
||||
MessageList.AddChild(new Control { Name = "MessagePadding", MinSize = new Vector2(0, 4) });
|
||||
|
||||
MessageList.AddChild(messageBubble);
|
||||
}
|
||||
|
||||
MessageList.InvalidateMeasure();
|
||||
@@ -357,13 +589,41 @@ public sealed partial class NanoChatUiFragment : BoxContainer
|
||||
ListNumberButton.Pressed = _listNumber;
|
||||
}
|
||||
|
||||
private void CheckValidNumber()
|
||||
{
|
||||
if (_ownNumber == 0)
|
||||
{
|
||||
NoIdLabel.Visible = true;
|
||||
ChatView.Visible = false;
|
||||
LookupView.Visible = false;
|
||||
|
||||
MuteButton.Disabled = true;
|
||||
NewChatButton.Disabled = true;
|
||||
CreateGroupChatButton.Disabled = true;
|
||||
LookupButton.Disabled = true;
|
||||
ListNumberButton.Disabled = true;
|
||||
}
|
||||
else if (NoIdLabel.Visible)
|
||||
{
|
||||
NoIdLabel.Visible = false;
|
||||
ToggleView();
|
||||
|
||||
MuteButton.Disabled = false;
|
||||
NewChatButton.Disabled = false;
|
||||
CreateGroupChatButton.Disabled = false;
|
||||
LookupButton.Disabled = false;
|
||||
ListNumberButton.Disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateState(NanoChatUiState state)
|
||||
{
|
||||
_ownNumber = state.OwnNumber;
|
||||
_notificationsMuted = state.NotificationsMuted;
|
||||
_listNumber = state.ListNumber;
|
||||
_mutedChats = state.MutedChats;
|
||||
OwnNumberLabel.Text = $"#{state.OwnNumber:D4}";
|
||||
_contacts = state.Contacts;
|
||||
OwnNumberLabel.Text = $"#{_ownNumber:D4}";
|
||||
UpdateMuteButton();
|
||||
UpdateListNumber();
|
||||
|
||||
@@ -387,10 +647,15 @@ public sealed partial class NanoChatUiFragment : BoxContainer
|
||||
if (_pendingChat == null)
|
||||
_currentChat = state.CurrentChat;
|
||||
|
||||
UpdateCurrentChat();
|
||||
UpdateMuteChatButton();
|
||||
// Update recipients first, then update UI that depends on it
|
||||
UpdateChatList(state.Recipients);
|
||||
UpdateMessages(state.Messages);
|
||||
UpdateCurrentChat();
|
||||
UpdateMuteChatButton();
|
||||
LookupView.UpdateContactList(state);
|
||||
|
||||
CheckValidNumber();
|
||||
|
||||
RefreshOpenPopups();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Numerics;
|
||||
using Robust.Client.Graphics;
|
||||
using Robust.Client.ResourceManagement;
|
||||
using Robust.Client.UserInterface;
|
||||
|
||||
namespace Content.Client._DV.NanoChat.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Extremely specialized version of <see cref="Robust.Client.UserInterface.Controls.TextureRect" /> designed for drawing Textures at fixed size with an additional offset
|
||||
/// Circumvents the problem of not having "negative mergins" on TextureRect for manual alignment
|
||||
/// This is fucking evil, please do not use this :3
|
||||
/// </summary>
|
||||
public sealed class EmojiControl : Control
|
||||
{
|
||||
public const string StylePropertyTexture = "texture";
|
||||
public const string StylePropertyOffset = "offset";
|
||||
|
||||
private Texture? _texture;
|
||||
private Vector2? _offset;
|
||||
|
||||
private string? _texturePath;
|
||||
|
||||
public string TexturePath
|
||||
{
|
||||
set
|
||||
{
|
||||
Texture = IoCManager.Resolve<IResourceCache>().GetResource<TextureResource>(value);
|
||||
_texturePath = value;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected override void OnThemeUpdated()
|
||||
{
|
||||
if (_texturePath != null) Texture = Theme.ResolveTexture(_texturePath);
|
||||
base.OnThemeUpdated();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The texture to draw.
|
||||
/// </summary>
|
||||
public Texture? Texture
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_texture is null)
|
||||
{
|
||||
if (TryGetStyleProperty(StylePropertyTexture, out Texture? texture))
|
||||
{
|
||||
return texture;
|
||||
}
|
||||
}
|
||||
|
||||
return _texture;
|
||||
}
|
||||
set
|
||||
{
|
||||
var oldSize = _texture?.Size;
|
||||
_texture = value;
|
||||
|
||||
if (value?.Size != oldSize)
|
||||
{
|
||||
InvalidateMeasure();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vertical offset of the texture
|
||||
/// </summary>
|
||||
public Vector2 Offset
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_offset is null)
|
||||
{
|
||||
if (TryGetStyleProperty(StylePropertyOffset, out Vector2 offset))
|
||||
return offset;
|
||||
}
|
||||
|
||||
return _offset ?? Vector2.Zero;
|
||||
}
|
||||
set
|
||||
{
|
||||
var oldOffset = _offset;
|
||||
_offset = value;
|
||||
|
||||
if (oldOffset != value)
|
||||
{
|
||||
InvalidateArrange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(UIBox2 finalRect)
|
||||
{
|
||||
finalRect.Left += Offset.X;
|
||||
finalRect.Right += Offset.X;
|
||||
finalRect.Top += Offset.Y;
|
||||
finalRect.Bottom += Offset.Y;
|
||||
base.ArrangeCore(finalRect);
|
||||
}
|
||||
|
||||
protected override void Draw(DrawingHandleScreen handle)
|
||||
{
|
||||
var texture = Texture;
|
||||
|
||||
if (texture is null)
|
||||
return;
|
||||
|
||||
handle.DrawTextureRect(texture, GetDrawDimensions());
|
||||
}
|
||||
|
||||
private UIBox2 GetDrawDimensions()
|
||||
{
|
||||
var (texWidth, texHeight) = Texture?.Size ?? Vector2i.Zero;
|
||||
var width = texWidth * (PixelSize.Y / texHeight);
|
||||
var height = (float)PixelSize.Y;
|
||||
if (width > PixelSize.X)
|
||||
{
|
||||
width = PixelSize.X;
|
||||
height = texHeight * (PixelSize.X / texWidth);
|
||||
}
|
||||
|
||||
var size = new Vector2(width, height);
|
||||
var position = (PixelSize - size) / 2 + Offset;
|
||||
|
||||
return UIBox2.FromDimensions(position, size);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
-->
|
||||
|
||||
<DefaultWindow xmlns="https://spacestation14.io"
|
||||
Title="{Loc nano-chat-create-group-title}"
|
||||
MinSize="300 150">
|
||||
<PanelContainer StyleClasses="AngleRect">
|
||||
<BoxContainer Orientation="Vertical" Margin="4">
|
||||
<!-- Group Name input -->
|
||||
<BoxContainer Orientation="Vertical" Margin="0 4">
|
||||
<Label Text="{Loc nano-chat-group-name-label}"
|
||||
StyleClasses="LabelHeading" />
|
||||
<PanelContainer StyleClasses="ButtonSquare">
|
||||
<LineEdit Name="GroupNameInput"
|
||||
PlaceHolder="{Loc nano-chat-group-name-placeholder}" />
|
||||
</PanelContainer>
|
||||
</BoxContainer>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<BoxContainer Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Margin="0 8 0 0">
|
||||
<Button Name="CancelButton"
|
||||
Text="{Loc nano-chat-cancel}"
|
||||
StyleClasses="OpenRight"
|
||||
MinSize="80 0" />
|
||||
<Button Name="CreateButton"
|
||||
Text="{Loc nano-chat-create}"
|
||||
StyleClasses="OpenLeft"
|
||||
MinSize="80 0"
|
||||
Disabled="True" />
|
||||
</BoxContainer>
|
||||
</BoxContainer>
|
||||
</PanelContainer>
|
||||
</DefaultWindow>
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-FileCopyrightText: 2025 Evaisa <evagiacosa1@gmail.com>
|
||||
// SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
|
||||
using Robust.Client.AutoGenerated;
|
||||
using Robust.Client.UserInterface.CustomControls;
|
||||
using Robust.Client.UserInterface.XAML;
|
||||
|
||||
namespace Content.Client._Funkystation.NanoChat;
|
||||
|
||||
[GenerateTypedNameReferences]
|
||||
public sealed partial class CreateGroupChatPopup : DefaultWindow
|
||||
{
|
||||
public event Action<string>? OnGroupCreated;
|
||||
|
||||
public CreateGroupChatPopup(int maxFullNameLength)
|
||||
{
|
||||
RobustXamlLoader.Load(this);
|
||||
|
||||
ContentsContainer.Margin = new Thickness(3);
|
||||
|
||||
// Button handlers
|
||||
CancelButton.OnPressed += _ => Close();
|
||||
CreateButton.OnPressed += _ => CreateGroup();
|
||||
|
||||
// Input validation
|
||||
GroupNameInput.OnTextEntered += _ => CreateGroup();
|
||||
|
||||
GroupNameInput.OnTextChanged += args =>
|
||||
{
|
||||
if (args.Text.Length > maxFullNameLength)
|
||||
GroupNameInput.Text = args.Text[..maxFullNameLength];
|
||||
ValidateInputs();
|
||||
};
|
||||
}
|
||||
|
||||
private void ValidateInputs()
|
||||
{
|
||||
var isValid = !string.IsNullOrWhiteSpace(GroupNameInput.Text);
|
||||
CreateButton.Disabled = !isValid;
|
||||
}
|
||||
|
||||
private void CreateGroup()
|
||||
{
|
||||
if (CreateButton.Disabled)
|
||||
return;
|
||||
|
||||
var name = GroupNameInput.Text.Trim();
|
||||
OnGroupCreated?.Invoke(name);
|
||||
Close();
|
||||
}
|
||||
|
||||
public void ClearInputs()
|
||||
{
|
||||
GroupNameInput.Text = string.Empty;
|
||||
ValidateInputs();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
-->
|
||||
|
||||
<DefaultWindow xmlns="https://spacestation14.io"
|
||||
Title="{Loc nano-chat-emoji-picker}"
|
||||
MinSize="300 400">
|
||||
<PanelContainer StyleClasses="AngleRect">
|
||||
<BoxContainer Orientation="Vertical" Margin="4">
|
||||
<!-- Search box -->
|
||||
<LineEdit Name="SearchInput"
|
||||
PlaceHolder="{Loc nano-chat-emoji-search}"
|
||||
HorizontalExpand="True"
|
||||
Margin="0 0 0 4" />
|
||||
|
||||
<!-- Emoji grid -->
|
||||
<ScrollContainer VerticalExpand="True"
|
||||
HorizontalExpand="True"
|
||||
Margin="0 4"
|
||||
HScroll="0"
|
||||
VScroll="1">
|
||||
<GridContainer Name="EmojiGrid"
|
||||
Columns="6"
|
||||
HorizontalExpand="True" />
|
||||
</ScrollContainer>
|
||||
|
||||
<!-- Close button -->
|
||||
<BoxContainer Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Margin="0 8 0 0">
|
||||
<Button Name="EmojiCloseButton"
|
||||
Text="{Loc nano-chat-close}"
|
||||
MinSize="80 0" />
|
||||
</BoxContainer>
|
||||
</BoxContainer>
|
||||
</PanelContainer>
|
||||
</DefaultWindow>
|
||||
@@ -0,0 +1,150 @@
|
||||
// SPDX-FileCopyrightText: 2025 Evaisa <evagiacosa1@gmail.com>
|
||||
// SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
|
||||
using System.Linq;
|
||||
using Content.Shared._Funkystation.NanoChat;
|
||||
using Robust.Client.AutoGenerated;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
using Robust.Client.UserInterface.CustomControls;
|
||||
using Robust.Client.UserInterface.XAML;
|
||||
using Robust.Client.ResourceManagement;
|
||||
using Robust.Client.GameObjects;
|
||||
using Robust.Client.Graphics;
|
||||
using Robust.Shared.Utility;
|
||||
using System.Numerics;
|
||||
|
||||
namespace Content.Client._Funkystation.NanoChat;
|
||||
|
||||
[GenerateTypedNameReferences]
|
||||
public sealed partial class EmojiPickerPopup : DefaultWindow
|
||||
{
|
||||
private List<(string name, SpriteSpecifier specifier)> _filteredEmojis;
|
||||
private SpriteSystem? _spriteSystem;
|
||||
|
||||
public event Action<string>? OnEmojiSelected;
|
||||
|
||||
public EmojiPickerPopup()
|
||||
{
|
||||
RobustXamlLoader.Load(this);
|
||||
|
||||
_filteredEmojis = NanoChatEmojis.EmojiSpecifiers.Select(kv => (kv.Key, kv.Value)).ToList();
|
||||
|
||||
ContentsContainer.Margin = new Thickness(3);
|
||||
|
||||
EmojiCloseButton.OnPressed += _ => Close();
|
||||
SearchInput.OnTextChanged += OnSearchChanged;
|
||||
|
||||
OnResized += UpdateGridColumns;
|
||||
|
||||
PopulateEmojiGrid();
|
||||
UpdateGridColumns();
|
||||
}
|
||||
|
||||
private void UpdateGridColumns()
|
||||
{
|
||||
const int buttonSize = 48;
|
||||
const int scrollbarWidth = 12;
|
||||
|
||||
var scrollContainer = EmojiGrid.Parent as ScrollContainer;
|
||||
if (scrollContainer == null)
|
||||
return;
|
||||
|
||||
var availableWidth = scrollContainer.PixelSize.X - scrollbarWidth;
|
||||
|
||||
if (availableWidth <= 0)
|
||||
return;
|
||||
|
||||
var columns = Math.Max(1, (int) (availableWidth / buttonSize));
|
||||
if (EmojiGrid.Columns != columns)
|
||||
{
|
||||
EmojiGrid.Columns = columns;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSearchChanged(LineEdit.LineEditEventArgs args)
|
||||
{
|
||||
var search = args.Text.ToLower().Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(search))
|
||||
{
|
||||
_filteredEmojis = NanoChatEmojis.EmojiSpecifiers.Select(kv => (kv.Key, kv.Value)).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
_filteredEmojis = NanoChatEmojis.EmojiSpecifiers
|
||||
.Where(kv => kv.Key.Contains(search, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(kv => (kv.Key, kv.Value))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
PopulateEmojiGrid();
|
||||
}
|
||||
|
||||
private void PopulateEmojiGrid()
|
||||
{
|
||||
EmojiGrid.RemoveAllChildren();
|
||||
|
||||
var resCache = IoCManager.Resolve<IResourceCache>();
|
||||
_spriteSystem ??= IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<SpriteSystem>();
|
||||
|
||||
foreach (var (name, specifier) in _filteredEmojis)
|
||||
{
|
||||
var button = new Button
|
||||
{
|
||||
MinSize = new Vector2(48, 48),
|
||||
MaxSize = new Vector2(48, 48),
|
||||
ToolTip = $":{name}:",
|
||||
};
|
||||
|
||||
Texture texture;
|
||||
switch (specifier)
|
||||
{
|
||||
case SpriteSpecifier.Texture texSpecifier:
|
||||
if (!resCache.TryGetResource<TextureResource>(texSpecifier.TexturePath, out var textureResource))
|
||||
continue;
|
||||
texture = textureResource.Texture;
|
||||
break;
|
||||
|
||||
case SpriteSpecifier.Rsi rsiSpecifier:
|
||||
var state = _spriteSystem.GetState(rsiSpecifier);
|
||||
texture = state.Frame0;
|
||||
break;
|
||||
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
|
||||
var textureRect = new TextureRect
|
||||
{
|
||||
Texture = texture,
|
||||
Stretch = TextureRect.StretchMode.KeepAspect,
|
||||
MinSize = new Vector2(32, 32),
|
||||
MaxSize = new Vector2(32, 32),
|
||||
HorizontalAlignment = HAlignment.Center,
|
||||
VerticalAlignment = VAlignment.Center,
|
||||
};
|
||||
|
||||
button.AddChild(textureRect);
|
||||
|
||||
button.OnPressed += _ =>
|
||||
{
|
||||
OnEmojiSelected?.Invoke($":{name}:");
|
||||
Close();
|
||||
};
|
||||
|
||||
EmojiGrid.AddChild(button);
|
||||
}
|
||||
|
||||
// Recalculate columns after populating
|
||||
UpdateGridColumns();
|
||||
}
|
||||
|
||||
public static SpriteSpecifier? GetEmojiSpecifier(string emojiName)
|
||||
{
|
||||
return NanoChatEmojis.EmojiSpecifiers.TryGetValue(emojiName, out var specifier) ? specifier : null;
|
||||
}
|
||||
|
||||
public static Dictionary<string, SpriteSpecifier> GetEmojis() => new Dictionary<string, SpriteSpecifier>(NanoChatEmojis.EmojiSpecifiers);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// SPDX-FileCopyrightText: 2025 Evaisa <evagiacosa1@gmail.com>
|
||||
// SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
|
||||
using Robust.Client.GameObjects;
|
||||
using Robust.Client.Graphics;
|
||||
using Robust.Client.ResourceManagement;
|
||||
using Robust.Client.UserInterface;
|
||||
using Robust.Client.UserInterface.RichText;
|
||||
using Content.Shared._Funkystation.NanoChat;
|
||||
using Robust.Shared.Utility;
|
||||
using Content.Client._DV.NanoChat.UI; // DeltaV - Fix emoji alignment
|
||||
using Robust.Shared.Prototypes; // DeltaV - Emoji id for debugging
|
||||
|
||||
namespace Content.Client._Funkystation.NanoChat;
|
||||
|
||||
/// <summary>
|
||||
/// Markup tag for rendering emoji images inline in RichTextLabel.
|
||||
/// </summary>
|
||||
public sealed class EmojiTag : IMarkupTagHandler
|
||||
{
|
||||
[Dependency] private readonly IResourceCache _resourceCache = default!;
|
||||
[Dependency] private readonly IEntitySystemManager _entitySystemManager = default!;
|
||||
|
||||
private SpriteSystem? _spriteSystem;
|
||||
|
||||
public EmojiTag()
|
||||
{
|
||||
IoCManager.InjectDependencies(this);
|
||||
}
|
||||
|
||||
public string Name => "emoji";
|
||||
|
||||
// Begin DeltaV - Render invalid emoji as text
|
||||
|
||||
private static ProtoId<NanoChatEmojiPrototype>? GetEmoji(MarkupNode node, out string text, out SpriteSpecifier? emoji)
|
||||
{
|
||||
if (!node.Value.TryGetString(out var emojiName))
|
||||
{
|
||||
// If we don't have a value, then render nothing
|
||||
emoji = null;
|
||||
text = string.Empty;
|
||||
return null;
|
||||
}
|
||||
else if (NanoChatEmojis.EmojiSpecifiers.TryGetValue(emojiName, out emoji))
|
||||
{
|
||||
// If we have a valid emoji, render the emoji, but no text
|
||||
text = string.Empty;
|
||||
return emojiName;
|
||||
}
|
||||
else
|
||||
{
|
||||
// If we have an invalid emoji, render only text
|
||||
text = $":{emojiName}:";
|
||||
return emojiName;
|
||||
}
|
||||
}
|
||||
|
||||
string IMarkupTagHandler.TextBefore(MarkupNode node)
|
||||
{
|
||||
GetEmoji(node, out var text, out _);
|
||||
return text;
|
||||
}
|
||||
|
||||
// End DeltaV - Render invalid emoji as text
|
||||
|
||||
public bool TryCreateControl(MarkupNode node, out Control control)
|
||||
{
|
||||
control = default!;
|
||||
|
||||
var emojiId = GetEmoji(node, out _, out var maybeSpecifier);
|
||||
if (maybeSpecifier is not { } specifier)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_spriteSystem ??= _entitySystemManager.GetEntitySystem<SpriteSystem>();
|
||||
|
||||
Texture texture;
|
||||
switch (specifier)
|
||||
{
|
||||
case SpriteSpecifier.Texture texSpecifier:
|
||||
if (!_resourceCache.TryGetResource<TextureResource>(texSpecifier.TexturePath, out var textureResource))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
texture = textureResource.Texture;
|
||||
break;
|
||||
|
||||
case SpriteSpecifier.Rsi rsiSpecifier:
|
||||
var state = _spriteSystem.GetState(rsiSpecifier);
|
||||
texture = state.Frame0;
|
||||
break;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
// Begin DeltaV - Fix emoji alignment, replace TextureRect with custom EmojiControl
|
||||
control = new EmojiControl
|
||||
{
|
||||
Texture = texture,
|
||||
SetWidth = 32f,
|
||||
SetHeight = 32f,
|
||||
Name = $"Emoji[{emojiId}]",
|
||||
// Evilly hard-coded Margin and Offset, don't ask me where they come from please
|
||||
Margin = new(5f, 0f, 2f, 0),
|
||||
Offset = new(5f, -5f),
|
||||
};
|
||||
// End DeltaV
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
-->
|
||||
|
||||
<DefaultWindow xmlns="https://spacestation14.io"
|
||||
Title="{Loc nano-chat-group-members-title}"
|
||||
MinSize="400 300">
|
||||
<PanelContainer StyleClasses="AngleRect">
|
||||
<BoxContainer Orientation="Vertical" Margin="4">
|
||||
<!-- Member list -->
|
||||
<ScrollContainer VerticalExpand="True"
|
||||
HorizontalExpand="True"
|
||||
Margin="0 4">
|
||||
<BoxContainer Name="MembersList"
|
||||
Orientation="Vertical"
|
||||
VerticalExpand="True"
|
||||
HorizontalExpand="True" />
|
||||
</ScrollContainer>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<BoxContainer Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Margin="0 8 0 0">
|
||||
<Button Name="MembersCloseButton"
|
||||
Text="{Loc nano-chat-close}"
|
||||
MinSize="80 0" />
|
||||
</BoxContainer>
|
||||
</BoxContainer>
|
||||
</PanelContainer>
|
||||
</DefaultWindow>
|
||||
@@ -0,0 +1,201 @@
|
||||
// SPDX-FileCopyrightText: 2025 Evaisa <evagiacosa1@gmail.com>
|
||||
// SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using Content.Client.Resources;
|
||||
using Content.Shared._DV.CartridgeLoader.Cartridges;
|
||||
using Robust.Client.AutoGenerated;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
using Robust.Client.UserInterface.CustomControls;
|
||||
using Robust.Client.UserInterface.XAML;
|
||||
using Robust.Shared.IoC;
|
||||
using Robust.Client.ResourceManagement;
|
||||
|
||||
namespace Content.Client._Funkystation.NanoChat;
|
||||
|
||||
[GenerateTypedNameReferences]
|
||||
public sealed partial class GroupMembersPopup : DefaultWindow
|
||||
{
|
||||
private List<NanoChatRecipient> _allContacts = new();
|
||||
private HashSet<uint> _members = new();
|
||||
private HashSet<uint> _admins = new();
|
||||
private uint _ownNumber;
|
||||
private uint? _creatorId;
|
||||
|
||||
public event Action<uint>? OnKick;
|
||||
public event Action<uint>? OnAdmin;
|
||||
public event Action<uint>? OnDeadmin;
|
||||
|
||||
public GroupMembersPopup()
|
||||
{
|
||||
RobustXamlLoader.Load(this);
|
||||
|
||||
ContentsContainer.Margin = new Thickness(3);
|
||||
|
||||
MembersCloseButton.OnPressed += _ => Close();
|
||||
}
|
||||
|
||||
public void SetMembers(List<NanoChatRecipient> allContacts, HashSet<uint> members, uint ownNumber, uint? creatorId, HashSet<uint>? admins = null)
|
||||
{
|
||||
_allContacts = allContacts;
|
||||
_members = members;
|
||||
_ownNumber = ownNumber;
|
||||
_creatorId = creatorId;
|
||||
_admins = admins ?? new HashSet<uint>();
|
||||
UpdateMembersList();
|
||||
}
|
||||
|
||||
private void UpdateMembersList()
|
||||
{
|
||||
MembersList.RemoveAllChildren();
|
||||
|
||||
if (_members.Count == 0)
|
||||
{
|
||||
MembersList.AddChild(new Label { Text = Loc.GetString("nano-chat-no-members") });
|
||||
return;
|
||||
}
|
||||
|
||||
var isCreator = _creatorId == _ownNumber;
|
||||
var isAdmin = _admins.Contains(_ownNumber);
|
||||
var canManage = isCreator || isAdmin;
|
||||
|
||||
var memberContacts = _allContacts
|
||||
.Where(c => _members.Contains(c.Number))
|
||||
.OrderBy(c => c.Name)
|
||||
.ToList();
|
||||
|
||||
for (var idx = 0; idx < memberContacts.Count; idx++)
|
||||
{
|
||||
var contact = memberContacts[idx];
|
||||
var isEvenRow = idx % 2 == 0;
|
||||
var isMemberAdmin = _admins.Contains(contact.Number);
|
||||
var isMemberCreator = contact.Number == _creatorId;
|
||||
var isSelf = contact.Number == _ownNumber;
|
||||
|
||||
var canKick = false;
|
||||
if (isSelf) // can always leave yourself.
|
||||
{
|
||||
canKick = true;
|
||||
}
|
||||
else if (!isSelf && canManage && !isMemberCreator)
|
||||
{
|
||||
if (isCreator)
|
||||
canKick = true;
|
||||
else if (isAdmin && !isMemberAdmin)
|
||||
canKick = true;
|
||||
}
|
||||
|
||||
var canAdmin = canManage && !isSelf && !isMemberCreator && !isMemberAdmin;
|
||||
|
||||
var canDeadmin = canManage && !isSelf && !isMemberCreator && isMemberAdmin;
|
||||
if (!isCreator && canDeadmin)
|
||||
canDeadmin = false;
|
||||
|
||||
var contactControl = new MemberContainer(contact, isEvenRow, isMemberCreator, isMemberAdmin, isSelf, canKick, canAdmin, canDeadmin, OnKick, OnAdmin, OnDeadmin);
|
||||
MembersList.AddChild(contactControl);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MemberContainer : PanelContainer
|
||||
{
|
||||
public MemberContainer(NanoChatRecipient contact, bool isEvenRow, bool isCreator, bool isAdmin, bool isSelf, bool canKick, bool canMakeAdmin, bool canDeadmin, Action<uint>? onKick, Action<uint>? onAdmin, Action<uint>? onDeadmin)
|
||||
{
|
||||
HorizontalExpand = true;
|
||||
StyleClasses.Add(isEvenRow ? "PanelBackgroundBaseDark" : "PanelBackgroundLight");
|
||||
|
||||
var resCache = IoCManager.Resolve<IResourceCache>();
|
||||
|
||||
// Show name with job title if available
|
||||
var displayName = contact.Name;
|
||||
if (!string.IsNullOrWhiteSpace(contact.JobTitle))
|
||||
displayName = $"{contact.Name} ({contact.JobTitle})";
|
||||
|
||||
var nameLabel = new Label
|
||||
{
|
||||
Text = displayName + (isCreator ? $" {Loc.GetString("nano-chat-owner-suffix")}" : isAdmin ? $" {Loc.GetString("nano-chat-admin-suffix")}" : ""),
|
||||
HorizontalAlignment = HAlignment.Left,
|
||||
HorizontalExpand = true
|
||||
};
|
||||
|
||||
var numberLabel = new Label
|
||||
{
|
||||
Text = $"#{contact.Number:D4}",
|
||||
HorizontalAlignment = HAlignment.Right,
|
||||
Margin = new Thickness(0, 0, (canKick || canMakeAdmin || canDeadmin) ? 108 : 4, 0),
|
||||
};
|
||||
|
||||
AddChild(nameLabel);
|
||||
AddChild(numberLabel);
|
||||
|
||||
// Admin/Deadmin button
|
||||
if (canMakeAdmin)
|
||||
{
|
||||
var adminButton = new Button
|
||||
{
|
||||
HorizontalAlignment = HAlignment.Right,
|
||||
MinSize = new Vector2(32, 32),
|
||||
MaxSize = new Vector2(32, 32),
|
||||
ToolTip = Loc.GetString("nano-chat-admin"),
|
||||
Margin = new Thickness(0, 0, canKick ? 72 : 36, 0),
|
||||
};
|
||||
|
||||
adminButton.AddStyleClass("OpenBoth");
|
||||
adminButton.AddChild(new TextureRect
|
||||
{
|
||||
Texture = resCache.GetTexture("/Textures/_Funkystation/Interface/NanoChat/admin.png"),
|
||||
Stretch = TextureRect.StretchMode.KeepAspectCentered,
|
||||
MinSize = new Vector2(28, 28),
|
||||
MaxSize = new Vector2(28, 28),
|
||||
});
|
||||
adminButton.OnPressed += _ => onAdmin?.Invoke(contact.Number);
|
||||
|
||||
AddChild(adminButton);
|
||||
}
|
||||
else if (canDeadmin)
|
||||
{
|
||||
var deadminButton = new Button
|
||||
{
|
||||
HorizontalAlignment = HAlignment.Right,
|
||||
MinSize = new Vector2(32, 32),
|
||||
MaxSize = new Vector2(32, 32),
|
||||
ToolTip = Loc.GetString("nano-chat-deadmin"),
|
||||
Margin = new Thickness(0, 0, canKick ? 72 : 36, 0),
|
||||
};
|
||||
|
||||
deadminButton.AddStyleClass("OpenBoth");
|
||||
deadminButton.AddChild(new TextureRect
|
||||
{
|
||||
Texture = resCache.GetTexture("/Textures/_Funkystation/Interface/NanoChat/deadmin.png"),
|
||||
Stretch = TextureRect.StretchMode.KeepAspectCentered,
|
||||
MinSize = new Vector2(28, 28),
|
||||
MaxSize = new Vector2(28, 28),
|
||||
});
|
||||
deadminButton.OnPressed += _ => onDeadmin?.Invoke(contact.Number);
|
||||
|
||||
AddChild(deadminButton);
|
||||
}
|
||||
|
||||
// Kick/Leave button
|
||||
if (canKick)
|
||||
{
|
||||
var kickButton = new Button
|
||||
{
|
||||
Text = isSelf ? Loc.GetString("nano-chat-leave") : "X",
|
||||
HorizontalAlignment = HAlignment.Right,
|
||||
MinSize = new Vector2(isSelf ? 64 : 32, 32),
|
||||
MaxSize = new Vector2(isSelf ? 64 : 32, 32),
|
||||
ToolTip = isSelf ? Loc.GetString("nano-chat-leave") : Loc.GetString("nano-chat-kick"),
|
||||
Margin = new Thickness(0, 0, 36, 0),
|
||||
};
|
||||
|
||||
kickButton.AddStyleClass("OpenBoth");
|
||||
kickButton.OnPressed += _ => onKick?.Invoke(contact.Number);
|
||||
|
||||
AddChild(kickButton);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
-->
|
||||
|
||||
<DefaultWindow xmlns="https://spacestation14.io"
|
||||
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
|
||||
Title="{Loc nano-chat-invite-to-group-title}"
|
||||
MinSize="400 300">
|
||||
<PanelContainer StyleClasses="AngleRect">
|
||||
<BoxContainer Orientation="Vertical" Margin="4">
|
||||
<!-- Search input -->
|
||||
<BoxContainer Orientation="Vertical" Margin="0 4">
|
||||
<Label Text="{Loc nano-chat-search-label}"
|
||||
StyleClasses="LabelHeading" />
|
||||
<PanelContainer StyleClasses="ButtonSquare">
|
||||
<LineEdit Name="SearchInput"
|
||||
PlaceHolder="{Loc nano-chat-search-placeholder}" />
|
||||
</PanelContainer>
|
||||
</BoxContainer>
|
||||
|
||||
<!-- Contact list -->
|
||||
<ScrollContainer VerticalExpand="True"
|
||||
HorizontalExpand="True"
|
||||
Margin="0 4">
|
||||
<BoxContainer Name="ContactsList"
|
||||
Orientation="Vertical"
|
||||
VerticalExpand="True"
|
||||
HorizontalExpand="True" />
|
||||
</ScrollContainer>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<BoxContainer Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Margin="0 8 0 0">
|
||||
<Button Name="InviteCloseButton"
|
||||
Text="{Loc nano-chat-close}"
|
||||
MinSize="80 0" />
|
||||
</BoxContainer>
|
||||
</BoxContainer>
|
||||
</PanelContainer>
|
||||
</DefaultWindow>
|
||||
@@ -0,0 +1,191 @@
|
||||
// SPDX-FileCopyrightText: 2025 Evaisa <evagiacosa1@gmail.com>
|
||||
// SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using Content.Shared._DV.CartridgeLoader.Cartridges;
|
||||
using Content.Shared.Roles;
|
||||
using Robust.Client.AutoGenerated;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
using Robust.Client.UserInterface.CustomControls;
|
||||
using Robust.Client.UserInterface.XAML;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Content.Client._Funkystation.NanoChat;
|
||||
|
||||
[GenerateTypedNameReferences]
|
||||
public sealed partial class InviteToGroupPopup : DefaultWindow
|
||||
{
|
||||
private List<NanoChatRecipient> _allContacts = new();
|
||||
private HashSet<uint> _existingMembers = new();
|
||||
private uint _ownNumber;
|
||||
|
||||
public event Action<uint>? OnInvite;
|
||||
|
||||
public InviteToGroupPopup()
|
||||
{
|
||||
RobustXamlLoader.Load(this);
|
||||
|
||||
ContentsContainer.Margin = new Thickness(3);
|
||||
|
||||
InviteCloseButton.OnPressed += _ => Close();
|
||||
|
||||
SearchInput.OnTextChanged += _ => UpdateContactList();
|
||||
}
|
||||
|
||||
public void SetContacts(List<NanoChatRecipient> contacts, HashSet<uint> existingMembers, uint ownNumber)
|
||||
{
|
||||
_allContacts = contacts;
|
||||
_existingMembers = existingMembers;
|
||||
_ownNumber = ownNumber;
|
||||
UpdateContactList();
|
||||
}
|
||||
|
||||
private void UpdateContactList()
|
||||
{
|
||||
ContactsList.RemoveAllChildren();
|
||||
|
||||
if (_allContacts.Count == 0)
|
||||
{
|
||||
ContactsList.AddChild(new Label { Text = Loc.GetString("nano-chat-look-up-no-server") });
|
||||
return;
|
||||
}
|
||||
|
||||
var searchText = SearchInput.Text.ToLower();
|
||||
var filteredContacts = _allContacts
|
||||
.Where(c => c.Number != _ownNumber &&
|
||||
!_existingMembers.Contains(c.Number) &&
|
||||
(string.IsNullOrWhiteSpace(searchText) ||
|
||||
c.Name.ToLower().Contains(searchText) ||
|
||||
c.Number.ToString().Contains(searchText) ||
|
||||
(!string.IsNullOrWhiteSpace(c.JobTitle) && c.JobTitle.ToLower().Contains(searchText))))
|
||||
.ToList();
|
||||
|
||||
if (filteredContacts.Count == 0)
|
||||
{
|
||||
ContactsList.AddChild(new Label { Text = Loc.GetString("nano-chat-no-results") });
|
||||
return;
|
||||
}
|
||||
|
||||
// Group contacts by department like crew manifest
|
||||
var prototypeManager = IoCManager.Resolve<IPrototypeManager>();
|
||||
var departmentGroups = new Dictionary<DepartmentPrototype, List<NanoChatRecipient>>();
|
||||
var noDepartmentContacts = new List<NanoChatRecipient>();
|
||||
|
||||
foreach (var contact in filteredContacts)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(contact.Department) &&
|
||||
prototypeManager.TryIndex<DepartmentPrototype>(contact.Department, out var dept))
|
||||
{
|
||||
departmentGroups.GetOrNew(dept).Add(contact);
|
||||
}
|
||||
else
|
||||
{
|
||||
noDepartmentContacts.Add(contact);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort departments by weight
|
||||
var sortedDepartments = departmentGroups.Keys.ToList();
|
||||
sortedDepartments.Sort(DepartmentUIComparer.Instance);
|
||||
|
||||
var rowIndex = 0;
|
||||
foreach (var department in sortedDepartments)
|
||||
{
|
||||
var departmentContacts = departmentGroups[department];
|
||||
departmentContacts.Sort((a, b) => string.CompareOrdinal(a.Name, b.Name));
|
||||
|
||||
// Add department header
|
||||
ContactsList.AddChild(new Label
|
||||
{
|
||||
Text = Loc.GetString(department.Name),
|
||||
StyleClasses = { "LabelBig" },
|
||||
Margin = new Thickness(0, rowIndex == 0 ? 0 : 8, 0, 2)
|
||||
});
|
||||
|
||||
foreach (var contact in departmentContacts)
|
||||
{
|
||||
var isEvenRow = rowIndex % 2 == 0;
|
||||
var contactControl = new ContactContainer(contact, isEvenRow, OnInvite);
|
||||
ContactsList.AddChild(contactControl);
|
||||
rowIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// Add contacts without departments at the end
|
||||
if (noDepartmentContacts.Count > 0)
|
||||
{
|
||||
noDepartmentContacts.Sort((a, b) => string.CompareOrdinal(a.Name, b.Name));
|
||||
|
||||
if (sortedDepartments.Count > 0)
|
||||
{
|
||||
ContactsList.AddChild(new Label
|
||||
{
|
||||
Text = Loc.GetString("nano-chat-other-department"),
|
||||
StyleClasses = { "LabelBig" },
|
||||
Margin = new Thickness(0, 8, 0, 2)
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var contact in noDepartmentContacts)
|
||||
{
|
||||
var isEvenRow = rowIndex % 2 == 0;
|
||||
var contactControl = new ContactContainer(contact, isEvenRow, OnInvite);
|
||||
ContactsList.AddChild(contactControl);
|
||||
rowIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearSearch()
|
||||
{
|
||||
SearchInput.Text = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class ContactContainer : PanelContainer
|
||||
{
|
||||
public ContactContainer(NanoChatRecipient contact, bool isEvenRow, Action<uint>? onInvite)
|
||||
{
|
||||
HorizontalExpand = true;
|
||||
StyleClasses.Add(isEvenRow ? "PanelBackgroundBaseDark" : "PanelBackgroundLight");
|
||||
|
||||
// Show name with job title if available
|
||||
var displayName = contact.Name;
|
||||
if (!string.IsNullOrWhiteSpace(contact.JobTitle))
|
||||
displayName = $"{contact.Name} ({contact.JobTitle})";
|
||||
|
||||
var nameLabel = new Label
|
||||
{
|
||||
Text = displayName,
|
||||
HorizontalAlignment = HAlignment.Left,
|
||||
HorizontalExpand = true
|
||||
};
|
||||
|
||||
var numberLabel = new Label
|
||||
{
|
||||
Text = $"#{contact.Number:D4}",
|
||||
HorizontalAlignment = HAlignment.Right,
|
||||
Margin = new Thickness(0, 0, 36, 0),
|
||||
};
|
||||
|
||||
var inviteButton = new Button
|
||||
{
|
||||
Text = "+",
|
||||
HorizontalAlignment = HAlignment.Right,
|
||||
MinSize = new Vector2(32, 32),
|
||||
MaxSize = new Vector2(32, 32),
|
||||
ToolTip = Loc.GetString("nano-chat-invite"),
|
||||
};
|
||||
|
||||
inviteButton.AddStyleClass("OpenBoth");
|
||||
inviteButton.OnPressed += _ => onInvite?.Invoke(contact.Number);
|
||||
|
||||
AddChild(nameLabel);
|
||||
AddChild(numberLabel);
|
||||
AddChild(inviteButton);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,11 +46,17 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
|
||||
|
||||
SubscribeLocalEvent<NanoChatCartridgeComponent, CartridgeUiReadyEvent>(OnUiReady);
|
||||
SubscribeLocalEvent<NanoChatCartridgeComponent, CartridgeMessageEvent>(OnMessage);
|
||||
SubscribeLocalEvent<NanoChatCartridgeComponent, MapInitEvent>(OnMapInit);
|
||||
|
||||
Subs.CVar(_cfg, CCVars.MaxNameLength, x => _maxNameLength = x, true);
|
||||
Subs.CVar(_cfg, CCVars.MaxIdJobLength, x => _maxIdJobLength = x, true);
|
||||
}
|
||||
|
||||
private void OnMapInit(Entity<NanoChatCartridgeComponent> ent, ref MapInitEvent args)
|
||||
{
|
||||
UpdateUIForAllCards();
|
||||
}
|
||||
|
||||
private void UpdateClosed(Entity<NanoChatCartridgeComponent> ent)
|
||||
{
|
||||
if (!TryComp<CartridgeComponent>(ent, out var cartridge) ||
|
||||
@@ -124,7 +130,7 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
|
||||
HandleCloseChat(card);
|
||||
break;
|
||||
case NanoChatUiMessageType.ToggleMute:
|
||||
HandleToggleMute(card);
|
||||
HandleToggleMute(card, msg);
|
||||
break;
|
||||
case NanoChatUiMessageType.ToggleMuteChat:
|
||||
HandleToggleMuteChat(card, msg);
|
||||
@@ -136,7 +142,22 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
|
||||
HandleSendMessage(ent, card, msg);
|
||||
break;
|
||||
case NanoChatUiMessageType.ToggleListNumber:
|
||||
HandleToggleListNumber(card);
|
||||
HandleToggleListNumber(card, msg);
|
||||
break;
|
||||
case NanoChatUiMessageType.CreateGroupChat:
|
||||
HandleCreateGroupChat(card, msg);
|
||||
break;
|
||||
case NanoChatUiMessageType.InviteToGroup:
|
||||
HandleInviteToGroup(card, msg);
|
||||
break;
|
||||
case NanoChatUiMessageType.KickFromGroup:
|
||||
HandleKickFromGroup(card, msg);
|
||||
break;
|
||||
case NanoChatUiMessageType.AdminUser:
|
||||
HandleAdminUser(card, msg);
|
||||
break;
|
||||
case NanoChatUiMessageType.DeadminUser:
|
||||
HandleDeadminUser(card, msg);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -190,16 +211,24 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
|
||||
}
|
||||
|
||||
// Add new recipient
|
||||
var recipient = new NanoChatRecipient(msg.RecipientNumber.Value,
|
||||
var recipient = new NanoChatRecipient(
|
||||
msg.RecipientNumber.Value,
|
||||
name,
|
||||
jobTitle);
|
||||
jobTitle,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
// Initialize or update recipient
|
||||
_nanoChat.SetRecipient((card, card.Comp), msg.RecipientNumber.Value, recipient);
|
||||
|
||||
_adminLogger.Add(LogType.Action,
|
||||
LogImpact.Low,
|
||||
$"{ToPrettyString(msg.Actor):user} created new NanoChat conversation with #{msg.RecipientNumber:D4} ({name})");
|
||||
$"{ToPrettyString(msg.Actor)} created new NanoChat conversation with #{msg.RecipientNumber:D4} ({name})");
|
||||
|
||||
var recipientEv = new NanoChatRecipientUpdatedEvent(card);
|
||||
RaiseLocalEvent(ref recipientEv);
|
||||
@@ -277,15 +306,65 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
|
||||
if (msg.RecipientNumber == null || card.Comp.Number == null)
|
||||
return;
|
||||
|
||||
var chatNumber = msg.RecipientNumber.Value;
|
||||
var recipient = _nanoChat.GetRecipient((card, card.Comp), chatNumber);
|
||||
|
||||
if (recipient is { IsGroup: true } groupChat)
|
||||
{
|
||||
var members = groupChat.Members ?? new HashSet<uint>();
|
||||
if (members.Remove(card.Comp.Number.Value))
|
||||
{
|
||||
var admins = groupChat.Admins ?? new HashSet<uint>();
|
||||
admins.Remove(card.Comp.Number.Value);
|
||||
|
||||
// If the creator is leaving, transfer ownership
|
||||
uint? newCreatorId = groupChat.CreatorId;
|
||||
var isCreatorLeaving = card.Comp.Number.Value == groupChat.CreatorId;
|
||||
if (isCreatorLeaving)
|
||||
{
|
||||
if (admins.Count > 0)
|
||||
{
|
||||
newCreatorId = admins.First();
|
||||
}
|
||||
else if (members.Count > 0)
|
||||
{
|
||||
newCreatorId = members.First();
|
||||
}
|
||||
else
|
||||
{
|
||||
newCreatorId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the group for all remaining members
|
||||
var updatedRecipient = newCreatorId != null
|
||||
? groupChat with { Members = members, Admins = admins, CreatorId = newCreatorId }
|
||||
: groupChat with { Members = members, Admins = admins };
|
||||
|
||||
foreach (var memberNumber in members)
|
||||
{
|
||||
var cardQuery = EntityQueryEnumerator<NanoChatCardComponent>();
|
||||
while (cardQuery.MoveNext(out var memberCardUid, out var memberCard))
|
||||
{
|
||||
if (memberCard.Number == memberNumber)
|
||||
{
|
||||
_nanoChat.SetRecipient((memberCardUid, memberCard), chatNumber, updatedRecipient);
|
||||
UpdateUIForCard(memberCardUid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete chat but keep the messages
|
||||
var deleted = _nanoChat.TryDeleteChat((card, card.Comp), msg.RecipientNumber.Value, true);
|
||||
var deleted = _nanoChat.TryDeleteChat((card, card.Comp), chatNumber, true);
|
||||
|
||||
if (!deleted)
|
||||
return;
|
||||
|
||||
_adminLogger.Add(LogType.Action,
|
||||
LogImpact.Low,
|
||||
$"{ToPrettyString(msg.Actor):user} deleted NanoChat conversation with #{msg.RecipientNumber:D4}");
|
||||
$"{ToPrettyString(msg.Actor)} deleted NanoChat conversation with #{chatNumber:D4}");
|
||||
|
||||
UpdateUIForCard(card);
|
||||
}
|
||||
@@ -293,9 +372,15 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
|
||||
/// <summary>
|
||||
/// Handles toggling notification mute state.
|
||||
/// </summary>
|
||||
private void HandleToggleMute(Entity<NanoChatCardComponent> card)
|
||||
private void HandleToggleMute(Entity<NanoChatCardComponent> card, NanoChatUiMessageEvent msg)
|
||||
{
|
||||
_nanoChat.SetNotificationsMuted((card, card.Comp), !_nanoChat.GetNotificationsMuted((card, card.Comp)));
|
||||
var muted = _nanoChat.GetNotificationsMuted((card, card.Comp));
|
||||
_nanoChat.SetNotificationsMuted((card, card.Comp), !muted);
|
||||
|
||||
_adminLogger.Add(LogType.Chat,
|
||||
LogImpact.Low,
|
||||
$"{ToPrettyString(msg.Actor)} {(muted ? "un" : "")}muted the NanoChat on {ToPrettyString(card)}");
|
||||
|
||||
UpdateUIForCard(card);
|
||||
}
|
||||
|
||||
@@ -303,13 +388,25 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
|
||||
{
|
||||
if (msg.RecipientNumber is not uint chat)
|
||||
return;
|
||||
|
||||
_nanoChat.ToggleChatMuted((card, card.Comp), chat);
|
||||
|
||||
_adminLogger.Add(LogType.Chat,
|
||||
LogImpact.Low,
|
||||
$"{ToPrettyString(msg.Actor)} toggled NanoChat notifcations for chat #{chat:D4} on {ToPrettyString(card)}");
|
||||
|
||||
UpdateUIForCard(card);
|
||||
}
|
||||
|
||||
private void HandleToggleListNumber(Entity<NanoChatCardComponent> card)
|
||||
private void HandleToggleListNumber(Entity<NanoChatCardComponent> card, NanoChatUiMessageEvent msg)
|
||||
{
|
||||
_nanoChat.SetListNumber((card, card.Comp), !_nanoChat.GetListNumber((card, card.Comp)));
|
||||
var numberListed = _nanoChat.GetListNumber((card, card.Comp));
|
||||
_nanoChat.SetListNumber((card, card.Comp), !numberListed);
|
||||
|
||||
_adminLogger.Add(LogType.Chat,
|
||||
LogImpact.Low,
|
||||
$"{ToPrettyString(msg.Actor)} toggled the NanoChat listing of {ToPrettyString(card)} {(numberListed ? "off" : "on")}");
|
||||
|
||||
UpdateUIForAllCards();
|
||||
}
|
||||
|
||||
@@ -323,7 +420,11 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
|
||||
if (msg.RecipientNumber == null || msg.Content == null || card.Comp.Number == null)
|
||||
return;
|
||||
|
||||
if (!EnsureRecipientExists(card, msg.RecipientNumber.Value))
|
||||
var recipient = _nanoChat.GetRecipient((card, card.Comp), msg.RecipientNumber.Value);
|
||||
var isGroupChat = recipient?.IsGroup ?? false;
|
||||
|
||||
// Only ensure recipient exists for non-group chats
|
||||
if (!isGroupChat && !EnsureRecipientExists(card, msg.RecipientNumber.Value))
|
||||
return;
|
||||
|
||||
var content = msg.Content;
|
||||
@@ -341,8 +442,19 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
|
||||
(uint)card.Comp.Number
|
||||
);
|
||||
|
||||
// Attempt delivery
|
||||
var (deliveryFailed, recipients) = AttemptMessageDelivery(cartridge, msg.RecipientNumber.Value);
|
||||
List<Entity<NanoChatCardComponent>> recipients;
|
||||
bool deliveryFailed;
|
||||
|
||||
if (isGroupChat && recipient != null)
|
||||
{
|
||||
// For group chats, deliver to all members
|
||||
(deliveryFailed, recipients) = AttemptGroupMessageDelivery(cartridge, recipient.Value, card.Comp.Number.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
// For regular chats, deliver to single recipient
|
||||
(deliveryFailed, recipients) = AttemptMessageDelivery(cartridge, msg.RecipientNumber.Value);
|
||||
}
|
||||
|
||||
// Update delivery status
|
||||
message = message with { DeliveryFailed = deliveryFailed };
|
||||
@@ -357,7 +469,7 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
|
||||
|
||||
_adminLogger.Add(LogType.Chat,
|
||||
LogImpact.Low,
|
||||
$"{ToPrettyString(card):user} sent NanoChat message to {recipientsText}: {content}{(deliveryFailed ? " [DELIVERY FAILED]" : "")}");
|
||||
$"{ToPrettyString(msg.Actor)} sent NanoChat message to {recipientsText}: {content}{(deliveryFailed ? " [DELIVERY FAILED]" : "")}");
|
||||
|
||||
var msgEv = new NanoChatMessageReceivedEvent(card);
|
||||
RaiseLocalEvent(ref msgEv);
|
||||
@@ -365,9 +477,9 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
|
||||
if (deliveryFailed)
|
||||
return;
|
||||
|
||||
foreach (var recipient in recipients)
|
||||
foreach (var recipientCard in recipients)
|
||||
{
|
||||
DeliverMessageToRecipient(card, recipient, message);
|
||||
DeliverMessageToRecipient(card, recipientCard, message, msg.RecipientNumber.Value, recipient);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,27 +592,88 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
|
||||
/// <param name="sender">The sender's card entity</param>
|
||||
/// <param name="recipient">The recipient's card entity</param>
|
||||
/// <param name="message">The <see cref="NanoChatMessage" /> to deliver</param>
|
||||
/// <param name="chatNumber">The chat number (for group chats, this is the group number)</param>
|
||||
/// <param name="groupRecipient">Optional group recipient info if this is a group chat</param>
|
||||
private void DeliverMessageToRecipient(Entity<NanoChatCardComponent> sender,
|
||||
Entity<NanoChatCardComponent> recipient,
|
||||
NanoChatMessage message)
|
||||
NanoChatMessage message,
|
||||
uint chatNumber,
|
||||
NanoChatRecipient? groupRecipient = null)
|
||||
{
|
||||
if (sender.Comp.Number is not uint senderNumber)
|
||||
return;
|
||||
|
||||
// Always try to get and add sender info to recipient's contacts
|
||||
if (!EnsureRecipientExists(recipient, senderNumber))
|
||||
// For group chats, use the group number. For regular chats, use the sender's number
|
||||
var recipientNumber = (groupRecipient != null && groupRecipient.Value.IsGroup) ? chatNumber : senderNumber;
|
||||
|
||||
if (groupRecipient != null && groupRecipient.Value.IsGroup)
|
||||
{
|
||||
var existingRecipient = _nanoChat.GetRecipient((recipient, recipient.Comp), recipientNumber);
|
||||
|
||||
if (existingRecipient == null || !existingRecipient.Value.IsGroup)
|
||||
{
|
||||
_nanoChat.SetRecipient((recipient, recipient.Comp), recipientNumber, groupRecipient.Value);
|
||||
}
|
||||
else if (groupRecipient.Value.Members != null)
|
||||
{
|
||||
var existingMembers = existingRecipient.Value.Members ?? new HashSet<uint>();
|
||||
if (!existingMembers.SetEquals(groupRecipient.Value.Members))
|
||||
{
|
||||
_nanoChat.SetRecipient((recipient, recipient.Comp), recipientNumber, groupRecipient.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
var existingAdmins = existingRecipient.Value.Admins ?? new HashSet<uint>();
|
||||
var groupAdmins = groupRecipient.Value.Admins ?? new HashSet<uint>();
|
||||
if (!existingAdmins.SetEquals(groupAdmins))
|
||||
{
|
||||
_nanoChat.SetRecipient((recipient, recipient.Comp), recipientNumber, groupRecipient.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (!EnsureRecipientExists(recipient, recipientNumber))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_nanoChat.AddMessage((recipient, recipient.Comp), senderNumber, message with { DeliveryFailed = false });
|
||||
_nanoChat.AddMessage((recipient, recipient.Comp), recipientNumber, message with { DeliveryFailed = false });
|
||||
|
||||
if (recipient.Comp.IsClosed || _nanoChat.GetCurrentChat((recipient, recipient.Comp)) != senderNumber)
|
||||
HandleUnreadNotification(recipient, message, senderNumber);
|
||||
if (recipient.Comp.IsClosed || _nanoChat.GetCurrentChat((recipient, recipient.Comp)) != recipientNumber)
|
||||
HandleUnreadNotification(recipient, message, recipientNumber);
|
||||
|
||||
var msgEv = new NanoChatMessageReceivedEvent(recipient);
|
||||
RaiseLocalEvent(ref msgEv);
|
||||
UpdateUIForCard(recipient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to deliver a message to all members of a group chat.
|
||||
/// </summary>
|
||||
private (bool failed, List<Entity<NanoChatCardComponent>> recipients) AttemptGroupMessageDelivery(
|
||||
Entity<NanoChatCartridgeComponent> sender,
|
||||
NanoChatRecipient groupRecipient,
|
||||
uint senderNumber)
|
||||
{
|
||||
if (groupRecipient.Members == null)
|
||||
return (true, new List<Entity<NanoChatCardComponent>>());
|
||||
|
||||
var deliverableRecipients = new List<Entity<NanoChatCardComponent>>();
|
||||
|
||||
foreach (var memberNumber in groupRecipient.Members)
|
||||
{
|
||||
if (memberNumber == senderNumber)
|
||||
continue;
|
||||
|
||||
var (failed, memberCards) = AttemptMessageDelivery(sender, memberNumber);
|
||||
if (!failed)
|
||||
deliverableRecipients.AddRange(memberCards);
|
||||
}
|
||||
|
||||
// Delivery failed if no recipients could receive the message
|
||||
return (deliverableRecipients.Count == 0, deliverableRecipients);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles unread message notifications and updates unread status.
|
||||
/// </summary>
|
||||
@@ -510,21 +683,21 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
|
||||
{
|
||||
// Get sender name from contacts or fall back to number
|
||||
var recipients = _nanoChat.GetRecipients((recipient, recipient.Comp));
|
||||
var senderName = recipients.TryGetValue(message.SenderId, out var senderRecipient)
|
||||
var senderName = recipients.TryGetValue(senderNumber, out var senderRecipient)
|
||||
? senderRecipient.Name
|
||||
: $"#{message.SenderId:D4}";
|
||||
: $"#{senderNumber:D4}";
|
||||
var hasSelectedCurrentChat = _nanoChat.GetCurrentChat((recipient, recipient.Comp)) == senderNumber;
|
||||
|
||||
// Update unread status
|
||||
if (!hasSelectedCurrentChat)
|
||||
_nanoChat.SetRecipient((recipient, recipient.Comp),
|
||||
message.SenderId,
|
||||
senderNumber,
|
||||
senderRecipient with { HasUnread = true });
|
||||
|
||||
// Temporary local to avoid trouble with read-only access; Contains doesn't modify the collection
|
||||
HashSet<uint> mutedChats = recipient.Comp.MutedChats;
|
||||
if (recipient.Comp.NotificationsMuted ||
|
||||
mutedChats.Contains(message.SenderId) ||
|
||||
mutedChats.Contains(senderNumber) ||
|
||||
recipient.Comp.PdaUid is not { } pdaUid ||
|
||||
!TryComp<CartridgeLoaderComponent>(pdaUid, out var loader) ||
|
||||
// Don't notify if the recipient has the NanoChat program open with this chat selected.
|
||||
@@ -593,14 +766,27 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
|
||||
|
||||
// Try to get job title from ID card if possible
|
||||
string? jobTitle = null;
|
||||
string? department = null;
|
||||
var name = "Unknown";
|
||||
if (TryComp<IdCardComponent>(uid, out var idCard))
|
||||
{
|
||||
jobTitle = idCard.LocalizedJobTitle;
|
||||
name = idCard.FullName ?? name;
|
||||
if (idCard.JobDepartments is { } depts && depts.Count > 0)
|
||||
department = depts[0].ToString();
|
||||
}
|
||||
|
||||
return new NanoChatRecipient(number, name, jobTitle);
|
||||
return new NanoChatRecipient(
|
||||
number,
|
||||
name,
|
||||
jobTitle,
|
||||
department,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -624,12 +810,34 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
|
||||
var query = AllEntityQuery<NanoChatCardComponent, IdCardComponent>();
|
||||
while (query.MoveNext(out var entityId, out var nanoChatCard, out var idCardComponent))
|
||||
{
|
||||
if (nanoChatCard.ListNumber && nanoChatCard.Number is uint nanoChatNumber && idCardComponent.FullName is string fullName && _station.GetOwningStation(entityId) == station)
|
||||
{
|
||||
contacts.Add(new NanoChatRecipient(nanoChatNumber, fullName));
|
||||
}
|
||||
if (!nanoChatCard.ListNumber)
|
||||
continue;
|
||||
if (nanoChatCard.PdaUid is null)
|
||||
continue;
|
||||
if (nanoChatCard.Number is not { } nanoChatNumber)
|
||||
continue;
|
||||
if (idCardComponent.FullName is not { } fullName)
|
||||
continue;
|
||||
if (_station.GetOwningStation(entityId) != station)
|
||||
continue;
|
||||
|
||||
string? department = null;
|
||||
if (idCardComponent.JobDepartments is { } depts && depts.Count > 0)
|
||||
department = depts[0].ToString();
|
||||
|
||||
contacts.Add(new NanoChatRecipient(
|
||||
nanoChatNumber,
|
||||
fullName,
|
||||
idCardComponent.LocalizedJobTitle,
|
||||
department,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
contacts.Sort((contactA, contactB) => string.CompareOrdinal(contactA.Name, contactB.Name));
|
||||
contacts.Sort();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -668,4 +876,314 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
|
||||
listNumber);
|
||||
_cartridge.UpdateCartridgeUiState(loader, state);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles creation of a new group chat.
|
||||
/// </summary>
|
||||
private void HandleCreateGroupChat(Entity<NanoChatCardComponent> card, NanoChatUiMessageEvent msg)
|
||||
{
|
||||
if (msg.Content == null || card.Comp.Number == null)
|
||||
return;
|
||||
|
||||
var name = msg.Content;
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
name = name.Trim();
|
||||
if (name.Length > _maxNameLength)
|
||||
name = name[.._maxNameLength];
|
||||
}
|
||||
|
||||
// Generate a unique group number (surely unique I actually have no idea how to generate good unique numbers.)
|
||||
var groupNumber = (uint)(HashCode.Combine(card.Comp.Number.Value, _timing.CurTime.Ticks) & 0x7FFFFFFF);
|
||||
|
||||
// This fucking sucks (fire emoji)
|
||||
while (_nanoChat.GetRecipient((card, card.Comp), groupNumber) != null)
|
||||
{
|
||||
groupNumber++;
|
||||
}
|
||||
|
||||
// Create group chat recipient
|
||||
var recipient = new NanoChatRecipient(
|
||||
groupNumber,
|
||||
name,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
[card.Comp.Number.Value],
|
||||
card.Comp.Number.Value,
|
||||
null
|
||||
);
|
||||
|
||||
_nanoChat.SetRecipient((card, card.Comp), groupNumber, recipient);
|
||||
|
||||
_adminLogger.Add(LogType.Action,
|
||||
LogImpact.Low,
|
||||
$"{ToPrettyString(msg.Actor)} created group chat '{name}' (#{groupNumber:D4})");
|
||||
|
||||
var recipientEv = new NanoChatRecipientUpdatedEvent(card);
|
||||
RaiseLocalEvent(ref recipientEv);
|
||||
UpdateUIForCard(card);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles inviting a user to a group chat.
|
||||
/// </summary>
|
||||
private void HandleInviteToGroup(Entity<NanoChatCardComponent> card, NanoChatUiMessageEvent msg)
|
||||
{
|
||||
if (msg.RecipientNumber == null || msg.Content == null || card.Comp.Number == null)
|
||||
return;
|
||||
|
||||
var groupNumber = msg.RecipientNumber.Value;
|
||||
if (!uint.TryParse(msg.Content, out var inviteeNumber))
|
||||
return;
|
||||
|
||||
var recipient = _nanoChat.GetRecipient((card, card.Comp), groupNumber);
|
||||
if (recipient == null || !recipient.Value.IsGroup)
|
||||
return;
|
||||
|
||||
// Only the creator or admins can invite
|
||||
var isCreator = recipient.Value.CreatorId == card.Comp.Number.Value;
|
||||
var admins = recipient.Value.Admins ?? new HashSet<uint>();
|
||||
var isAdmin = admins.Contains(card.Comp.Number.Value);
|
||||
|
||||
if (!isCreator && !isAdmin)
|
||||
return;
|
||||
|
||||
var members = recipient.Value.Members ?? new HashSet<uint>();
|
||||
if (members.Contains(inviteeNumber))
|
||||
return;
|
||||
|
||||
// Add member to group
|
||||
members.Add(inviteeNumber);
|
||||
var updatedRecipient = recipient.Value with { Members = members };
|
||||
_nanoChat.SetRecipient((card, card.Comp), groupNumber, updatedRecipient);
|
||||
|
||||
// Update member lists for all members
|
||||
var memberCards = new List<Entity<NanoChatCardComponent>>();
|
||||
var cardQuery = EntityQueryEnumerator<NanoChatCardComponent>();
|
||||
while (cardQuery.MoveNext(out var cardUid, out var memberCard))
|
||||
{
|
||||
if (memberCard.Number != null && members.Contains(memberCard.Number.Value))
|
||||
{
|
||||
memberCards.Add((cardUid, memberCard));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var memberCard in memberCards)
|
||||
{
|
||||
_nanoChat.SetRecipient((memberCard, memberCard.Comp), groupNumber, updatedRecipient);
|
||||
UpdateUIForCard(memberCard);
|
||||
}
|
||||
|
||||
_adminLogger.Add(LogType.Action,
|
||||
LogImpact.Low,
|
||||
$"{ToPrettyString(msg.Actor)} invited #{inviteeNumber:D4} to group chat #{groupNumber:D4}");
|
||||
|
||||
var recipientEv = new NanoChatRecipientUpdatedEvent(card);
|
||||
RaiseLocalEvent(ref recipientEv);
|
||||
UpdateUIForCard(card);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles kicking a user from a group chat.
|
||||
/// </summary>
|
||||
private void HandleKickFromGroup(Entity<NanoChatCardComponent> card, NanoChatUiMessageEvent msg)
|
||||
{
|
||||
if (msg.RecipientNumber == null || msg.Content == null || card.Comp.Number == null)
|
||||
return;
|
||||
|
||||
var groupNumber = msg.RecipientNumber.Value;
|
||||
if (!uint.TryParse(msg.Content, out var kickeeNumber))
|
||||
return;
|
||||
|
||||
var recipient = _nanoChat.GetRecipient((card, card.Comp), groupNumber);
|
||||
if (recipient == null || !recipient.Value.IsGroup)
|
||||
return;
|
||||
|
||||
var isCreator = recipient.Value.CreatorId == card.Comp.Number.Value;
|
||||
var admins = recipient.Value.Admins ?? new HashSet<uint>();
|
||||
var isAdmin = admins.Contains(card.Comp.Number.Value);
|
||||
var isSelfKick = kickeeNumber == card.Comp.Number.Value;
|
||||
|
||||
if (!isSelfKick && !isCreator && !isAdmin)
|
||||
return;
|
||||
|
||||
// Allow creator to leave the group but not be kicked by other members.
|
||||
var isCreatorLeaving = kickeeNumber == recipient.Value.CreatorId && isCreator;
|
||||
if (kickeeNumber == recipient.Value.CreatorId && !isCreatorLeaving)
|
||||
return;
|
||||
|
||||
// Find all cards belonging to the kickee
|
||||
var kickeeCards = new List<Entity<NanoChatCardComponent>>();
|
||||
var cardQuery = EntityQueryEnumerator<NanoChatCardComponent>();
|
||||
while (cardQuery.MoveNext(out var cardUid, out var kickeeCard))
|
||||
{
|
||||
if (kickeeCard.Number == kickeeNumber)
|
||||
{
|
||||
kickeeCards.Add((cardUid, kickeeCard));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var kickeeCard in kickeeCards)
|
||||
{
|
||||
var deleteMsg = new NanoChatUiMessageEvent(NanoChatUiMessageType.DeleteChat, groupNumber, null, null)
|
||||
{
|
||||
Actor = msg.Actor
|
||||
};
|
||||
HandleDeleteChat(kickeeCard, deleteMsg);
|
||||
}
|
||||
|
||||
if (isCreatorLeaving)
|
||||
{
|
||||
_adminLogger.Add(LogType.Action,
|
||||
LogImpact.Low,
|
||||
$"{ToPrettyString(msg.Actor)} left group chat #{groupNumber:D4}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_adminLogger.Add(LogType.Action,
|
||||
LogImpact.Low,
|
||||
$"{ToPrettyString(msg.Actor)} kicked #{kickeeNumber:D4} from group chat #{groupNumber:D4}");
|
||||
}
|
||||
|
||||
var recipientEv = new NanoChatRecipientUpdatedEvent(card);
|
||||
RaiseLocalEvent(ref recipientEv);
|
||||
UpdateUIForCard(card);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles promoting a user to admin in a group chat.
|
||||
/// </summary>
|
||||
private void HandleAdminUser(Entity<NanoChatCardComponent> card, NanoChatUiMessageEvent msg)
|
||||
{
|
||||
if (msg.RecipientNumber == null || msg.Content == null || card.Comp.Number == null)
|
||||
return;
|
||||
|
||||
var groupNumber = msg.RecipientNumber.Value;
|
||||
if (!uint.TryParse(msg.Content, out var targetNumber))
|
||||
return;
|
||||
|
||||
var recipient = _nanoChat.GetRecipient((card, card.Comp), groupNumber);
|
||||
if (recipient == null || !recipient.Value.IsGroup)
|
||||
return;
|
||||
|
||||
// Only the creator or admins can make admins
|
||||
var isCreator = recipient.Value.CreatorId == card.Comp.Number.Value;
|
||||
var admins = recipient.Value.Admins ?? new HashSet<uint>();
|
||||
var isAdmin = admins.Contains(card.Comp.Number.Value);
|
||||
|
||||
if (!isCreator && !isAdmin)
|
||||
return;
|
||||
|
||||
var members = recipient.Value.Members ?? new HashSet<uint>();
|
||||
if (!members.Contains(targetNumber))
|
||||
return; // how tf
|
||||
|
||||
if (targetNumber == recipient.Value.CreatorId)
|
||||
return;
|
||||
|
||||
if (!isCreator && admins.Contains(targetNumber))
|
||||
return;
|
||||
|
||||
if (!admins.Add(targetNumber))
|
||||
return;
|
||||
|
||||
var updatedRecipient = recipient.Value with { Admins = admins };
|
||||
_nanoChat.SetRecipient((card, card.Comp), groupNumber, updatedRecipient);
|
||||
|
||||
// Sync to all members
|
||||
foreach (var memberNumber in members)
|
||||
{
|
||||
var memberCards = new List<Entity<NanoChatCardComponent>>();
|
||||
var cardQuery = EntityQueryEnumerator<NanoChatCardComponent>();
|
||||
while (cardQuery.MoveNext(out var cardUid, out var memberCard))
|
||||
{
|
||||
if (memberCard.Number == memberNumber)
|
||||
{
|
||||
memberCards.Add((cardUid, memberCard));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var memberCard in memberCards)
|
||||
{
|
||||
_nanoChat.SetRecipient((memberCard, memberCard.Comp), groupNumber, updatedRecipient);
|
||||
UpdateUIForCard(memberCard);
|
||||
}
|
||||
}
|
||||
|
||||
_adminLogger.Add(LogType.Action,
|
||||
LogImpact.Low,
|
||||
$"{ToPrettyString(msg.Actor)} promoted #{targetNumber:D4} to admin in group chat #{groupNumber:D4}");
|
||||
|
||||
var recipientEv = new NanoChatRecipientUpdatedEvent(card);
|
||||
RaiseLocalEvent(ref recipientEv);
|
||||
UpdateUIForCard(card);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles demoting a user from admin in a group chat.
|
||||
/// </summary>
|
||||
private void HandleDeadminUser(Entity<NanoChatCardComponent> card, NanoChatUiMessageEvent msg)
|
||||
{
|
||||
if (msg.RecipientNumber == null || msg.Content == null || card.Comp.Number == null)
|
||||
return;
|
||||
|
||||
var groupNumber = msg.RecipientNumber.Value;
|
||||
if (!uint.TryParse(msg.Content, out var targetNumber))
|
||||
return;
|
||||
|
||||
var recipient = _nanoChat.GetRecipient((card, card.Comp), groupNumber);
|
||||
if (recipient == null || !recipient.Value.IsGroup)
|
||||
return;
|
||||
|
||||
// Only the creator or admins can remove admins
|
||||
var isCreator = recipient.Value.CreatorId == card.Comp.Number.Value;
|
||||
var admins = recipient.Value.Admins ?? new HashSet<uint>();
|
||||
var isAdmin = admins.Contains(card.Comp.Number.Value);
|
||||
|
||||
if (!isCreator && !isAdmin)
|
||||
return;
|
||||
|
||||
var members = recipient.Value.Members ?? new HashSet<uint>();
|
||||
if (!members.Contains(targetNumber))
|
||||
return; // how
|
||||
|
||||
if (!isCreator && (targetNumber == recipient.Value.CreatorId || admins.Contains(targetNumber)))
|
||||
return;
|
||||
|
||||
if (!admins.Remove(targetNumber))
|
||||
return;
|
||||
|
||||
var updatedRecipient = recipient.Value with { Admins = admins };
|
||||
_nanoChat.SetRecipient((card, card.Comp), groupNumber, updatedRecipient);
|
||||
|
||||
// Sync to all members
|
||||
foreach (var memberNumber in members)
|
||||
{
|
||||
var memberCards = new List<Entity<NanoChatCardComponent>>();
|
||||
var cardQuery = EntityQueryEnumerator<NanoChatCardComponent>();
|
||||
while (cardQuery.MoveNext(out var cardUid, out var memberCard))
|
||||
{
|
||||
if (memberCard.Number == memberNumber)
|
||||
{
|
||||
memberCards.Add((cardUid, memberCard));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var memberCard in memberCards)
|
||||
{
|
||||
_nanoChat.SetRecipient((memberCard, memberCard.Comp), groupNumber, updatedRecipient);
|
||||
UpdateUIForCard(memberCard);
|
||||
}
|
||||
}
|
||||
|
||||
_adminLogger.Add(LogType.Action,
|
||||
LogImpact.Low,
|
||||
$"{ToPrettyString(msg.Actor)} removed admin from #{targetNumber:D4} in group chat #{groupNumber:D4}");
|
||||
|
||||
var recipientEv = new NanoChatRecipientUpdatedEvent(card);
|
||||
RaiseLocalEvent(ref recipientEv);
|
||||
UpdateUIForCard(card);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Content.Shared.CartridgeLoader;
|
||||
using Content.Shared.Roles;
|
||||
using Robust.Shared.Serialization;
|
||||
|
||||
namespace Content.Shared._DV.CartridgeLoader.Cartridges;
|
||||
@@ -57,45 +58,107 @@ public enum NanoChatUiMessageType : byte
|
||||
ToggleMute,
|
||||
ToggleMuteChat,
|
||||
ToggleListNumber,
|
||||
CreateGroupChat,
|
||||
InviteToGroup,
|
||||
KickFromGroup,
|
||||
ViewGroupMembers,
|
||||
AdminUser,
|
||||
DeadminUser,
|
||||
}
|
||||
|
||||
// putting this here because i can
|
||||
/// <summary>
|
||||
/// Represents an actual NanoChat Chat
|
||||
/// Either a Group, if <paramref name="isGroup"/> is <see langword="true" />, else a normal user.
|
||||
/// </summary>
|
||||
/// <param name="number">The recipient's NanoChat number</param>
|
||||
/// <param name="name">The recipient's display name</param>
|
||||
/// <param name="jobTitle">Optional job title for the recipient</param>
|
||||
/// <param name="department">Optional department ID for the recipient</param>
|
||||
/// <param name="hasUnread">Whether there are unread messages from this recipient</param>
|
||||
/// <param name="isGroup">Whether this is a group chat</param>
|
||||
/// <param name="members">For group chats: list of member NanoChat numbers</param>
|
||||
/// <param name="creatorId">For group chats: the creator's NanoChat number</param>
|
||||
/// <param name="admins">For group chats: set of admin NanoChat numbers</param>
|
||||
[Serializable, NetSerializable, DataRecord]
|
||||
public struct NanoChatRecipient
|
||||
public struct NanoChatRecipient(
|
||||
uint number,
|
||||
string name,
|
||||
string? jobTitle,
|
||||
string? department,
|
||||
bool hasUnread,
|
||||
bool isGroup,
|
||||
HashSet<uint>? members,
|
||||
uint? creatorId,
|
||||
HashSet<uint>? admins
|
||||
) : IComparable<NanoChatRecipient>
|
||||
{
|
||||
/// <summary>
|
||||
/// The recipient's unique NanoChat number.
|
||||
/// </summary>
|
||||
public uint Number;
|
||||
public uint Number = number;
|
||||
|
||||
/// <summary>
|
||||
/// The recipient's display name, typically from their ID card.
|
||||
/// </summary>
|
||||
public string Name;
|
||||
public string Name = name;
|
||||
|
||||
/// <summary>
|
||||
/// The recipient's job title, if available.
|
||||
/// </summary>
|
||||
public string? JobTitle;
|
||||
public string? JobTitle = jobTitle;
|
||||
|
||||
/// <summary>
|
||||
/// The recipient's department ID, if available.
|
||||
/// </summary>
|
||||
public string? Department = department;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this recipient has unread messages.
|
||||
/// </summary>
|
||||
public bool HasUnread;
|
||||
public bool HasUnread = hasUnread;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new NanoChat recipient.
|
||||
/// Whether this is a group chat.
|
||||
/// </summary>
|
||||
/// <param name="number">The recipient's NanoChat number</param>
|
||||
/// <param name="name">The recipient's display name</param>
|
||||
/// <param name="jobTitle">Optional job title for the recipient</param>
|
||||
/// <param name="hasUnread">Whether there are unread messages from this recipient</param>
|
||||
public NanoChatRecipient(uint number, string name, string? jobTitle = null, bool hasUnread = false)
|
||||
public bool IsGroup = isGroup;
|
||||
|
||||
/// <summary>
|
||||
/// For group chats: list of member NanoChat numbers.
|
||||
/// </summary>
|
||||
public HashSet<uint>? Members = members;
|
||||
|
||||
/// <summary>
|
||||
/// For group chats: the NanoChat number of the creator.
|
||||
/// </summary>
|
||||
public uint? CreatorId = creatorId;
|
||||
|
||||
/// <summary>
|
||||
/// For group chats: set of admin NanoChat numbers who can invite/kick.
|
||||
/// </summary>
|
||||
public HashSet<uint>? Admins = admins;
|
||||
|
||||
readonly int IComparable<NanoChatRecipient>.CompareTo(NanoChatRecipient other)
|
||||
{
|
||||
Number = number;
|
||||
Name = name;
|
||||
JobTitle = jobTitle;
|
||||
HasUnread = hasUnread;
|
||||
// Groups should go before normal users
|
||||
if (IsGroup && !other.IsGroup)
|
||||
return -1;
|
||||
else if (!IsGroup && other.IsGroup)
|
||||
return 1;
|
||||
|
||||
// FIXME: Order by Department?
|
||||
|
||||
// Order by name
|
||||
var nameCompare = string.CompareOrdinal(Name, other.Name);
|
||||
if (nameCompare != 0)
|
||||
return nameCompare;
|
||||
|
||||
// Smaller number goes first
|
||||
if (Number > other.Number)
|
||||
return 1;
|
||||
else if (Number < other.Number)
|
||||
return -1;
|
||||
else
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Content.Shared._DV.CartridgeLoader.Cartridges;
|
||||
using Content.Shared._Funkystation.NanoChat;
|
||||
using Content.Shared.Examine;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Timing;
|
||||
|
||||
namespace Content.Shared._DV.NanoChat;
|
||||
@@ -10,11 +12,13 @@ namespace Content.Shared._DV.NanoChat;
|
||||
public abstract class SharedNanoChatSystem : EntitySystem
|
||||
{
|
||||
[Dependency] private readonly IGameTiming _timing = default!;
|
||||
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
SubscribeLocalEvent<NanoChatCardComponent, ExaminedEvent>(OnExamined);
|
||||
_prototypeManager.PrototypesReloaded += (_) => NanoChatEmojis.ClearCache();
|
||||
}
|
||||
|
||||
private void OnExamined(Entity<NanoChatCardComponent> ent, ref ExaminedEvent args)
|
||||
@@ -36,6 +40,9 @@ public abstract class SharedNanoChatSystem : EntitySystem
|
||||
/// </summary>
|
||||
public static string Truncate(string text, int maxLength, string overflowText = "...")
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return string.Empty;
|
||||
|
||||
return text.Length > maxLength
|
||||
? text[..(maxLength - overflowText.Length)] + overflowText
|
||||
: text;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// SPDX-FileCopyrightText: 2025 Evaisa <evagiacosa1@gmail.com>
|
||||
// SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Content.Shared._Funkystation.NanoChat;
|
||||
|
||||
/// <summary>
|
||||
/// Prototype for NanoChat emoji definitions.
|
||||
/// </summary>
|
||||
[Prototype]
|
||||
public sealed partial class NanoChatEmojiPrototype : IPrototype
|
||||
{
|
||||
/// <summary>
|
||||
/// The unique identifier for this emoji (e.g., "godo")
|
||||
/// </summary>
|
||||
[IdDataField]
|
||||
public string ID { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional category for grouping emojis (e.g., "Godo", "Plushies")
|
||||
/// </summary>
|
||||
[DataField]
|
||||
public string Category { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The sprite specifier for this emoji
|
||||
/// </summary>
|
||||
[DataField(required: true)]
|
||||
public SpriteSpecifier Sprite = default!;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-FileCopyrightText: 2025 Evaisa <evagiacosa1@gmail.com>
|
||||
// SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
|
||||
using System.Linq;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Content.Shared._Funkystation.NanoChat;
|
||||
|
||||
/// <summary>
|
||||
/// Shared emoji definitions for NanoChat.
|
||||
/// This class provides access to emoji prototypes.
|
||||
/// </summary>
|
||||
public static class NanoChatEmojis
|
||||
{
|
||||
private static Dictionary<string, SpriteSpecifier>? _cachedEmojis;
|
||||
|
||||
/// <summary>
|
||||
/// Dictionary mapping emoji names to their sprite specifiers.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, SpriteSpecifier> EmojiSpecifiers
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_cachedEmojis == null)
|
||||
{
|
||||
var prototypeManager = IoCManager.Resolve<IPrototypeManager>();
|
||||
_cachedEmojis = new Dictionary<string, SpriteSpecifier>();
|
||||
|
||||
var sortedEmojis = prototypeManager.EnumeratePrototypes<NanoChatEmojiPrototype>()
|
||||
.OrderBy(proto => proto.Category)
|
||||
.ThenBy(proto => proto.ID);
|
||||
|
||||
foreach (var proto in sortedEmojis)
|
||||
{
|
||||
_cachedEmojis[proto.ID] = proto.Sprite;
|
||||
}
|
||||
}
|
||||
|
||||
return _cachedEmojis;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the emoji cache. Call this if prototypes are reloaded.
|
||||
/// </summary>
|
||||
public static void ClearCache()
|
||||
{
|
||||
_cachedEmojis = null;
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,7 @@ nano-chat-title = NanoChat
|
||||
nano-chat-new-chat = New Chat
|
||||
nano-chat-contacts = CONTACTS
|
||||
nano-chat-no-chats = No active chats
|
||||
nano-chat-no-id-card = No ID card inserted
|
||||
nano-chat-select-chat = Select a chat to begin
|
||||
nano-chat-message-placeholder = Type a message...
|
||||
nano-chat-send = Send
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
|
||||
# Group chat
|
||||
nano-chat-create-group = Create Group Chat
|
||||
nano-chat-invite-to-group = Invite to Group
|
||||
nano-chat-view-members = View Members
|
||||
nano-chat-invite = Invite
|
||||
nano-chat-kick = Kick
|
||||
nano-chat-leave = Leave
|
||||
nano-chat-admin = Make Admin
|
||||
nano-chat-deadmin = Remove Admin
|
||||
nano-chat-no-results = No results found
|
||||
nano-chat-no-members = No members
|
||||
nano-chat-close = Close
|
||||
|
||||
# Create group chat popup
|
||||
nano-chat-create-group-title = Create Group Chat
|
||||
nano-chat-group-name-label = Group Name
|
||||
nano-chat-group-name-placeholder = Enter a group name
|
||||
|
||||
# Invite to group popup
|
||||
nano-chat-invite-to-group-title = Invite to Group
|
||||
nano-chat-search-label = Search
|
||||
nano-chat-search-placeholder = Search by name or number...
|
||||
|
||||
# Group members popup
|
||||
nano-chat-group-members-title = Group Members
|
||||
nano-chat-owner-suffix = (Owner)
|
||||
nano-chat-admin-suffix = (Admin)
|
||||
|
||||
# Emoji picker
|
||||
nano-chat-emoji-picker = Emoji Picker
|
||||
nano-chat-emoji-search = Search emojis...
|
||||
|
||||
# Department sorting
|
||||
nano-chat-other-department = Other
|
||||
nano-chat-unknown-sender = Unknown
|
||||
@@ -0,0 +1,313 @@
|
||||
# SPDX-FileCopyrightText: 2025 Evaisa <evagiacosa1@gmail.com>
|
||||
# SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
|
||||
# Godo emotes
|
||||
- type: nanoChatEmoji
|
||||
id: godo
|
||||
category: Godo
|
||||
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/godo.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: do
|
||||
category: Godo
|
||||
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/do.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: donow
|
||||
category: Godo
|
||||
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/donow.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: gocrazy
|
||||
category: Godo
|
||||
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/donot.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: gosad
|
||||
category: Godo
|
||||
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/dontdo.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: godover
|
||||
category: Godo
|
||||
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/godover.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: golove
|
||||
category: Godo
|
||||
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/golove.png
|
||||
|
||||
# Character emotes
|
||||
- type: nanoChatEmoji
|
||||
id: buzz
|
||||
category: Character
|
||||
sprite: /Textures/Interface/Emotes/buzztwo.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: chime
|
||||
category: Character
|
||||
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/chime.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: chirp
|
||||
category: Character
|
||||
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/chirp.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: clap
|
||||
category: Character
|
||||
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/clap.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: click
|
||||
category: Character
|
||||
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/click.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: cough
|
||||
category: Character
|
||||
sprite: /Textures/Interface/Emotes/cough.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: cry
|
||||
category: Character
|
||||
sprite: /Textures/Interface/Emotes/cry.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: deathgasp
|
||||
category: Character
|
||||
sprite: /Textures/Interface/Emotes/deathgasp.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: hew
|
||||
category: Character
|
||||
sprite: /Textures/Interface/Emotes/hew.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: honk
|
||||
category: Character
|
||||
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/honk.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: laugh
|
||||
category: Character
|
||||
sprite: /Textures/Interface/Emotes/laugh.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: salute
|
||||
category: Character
|
||||
sprite: /Textures/Interface/Emotes/salute.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: scream
|
||||
category: Character
|
||||
sprite: /Textures/Interface/Actions/scream.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: sigh
|
||||
category: Character
|
||||
sprite: /Textures/Interface/Emotes/sigh.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: snap
|
||||
category: Character
|
||||
sprite: /Textures/Interface/Emotes/snap.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: squeak
|
||||
category: Character
|
||||
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/squeak.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: tailslap
|
||||
category: Character
|
||||
sprite: /Textures/Interface/Emotes/tailslap.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: vocal
|
||||
category: Character
|
||||
sprite: /Textures/Interface/Emotes/vocal.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: weh
|
||||
category: Character
|
||||
sprite: /Textures/Interface/Emotes/weh.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: whistle
|
||||
category: Character
|
||||
sprite: /Textures/Interface/Emotes/whistle.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: yawn
|
||||
category: Character
|
||||
sprite: /Textures/Interface/Emotes/yawn.png
|
||||
|
||||
# Plushie emotes
|
||||
- type: nanoChatEmoji
|
||||
id: bee
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/Objects/Fun/Plushies/bee.rsi
|
||||
state: icon
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: ratvar
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/Objects/Fun/Plushies/ratvar.rsi
|
||||
state: icon
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: penguin
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/Objects/Fun/Plushies/penguin.rsi
|
||||
state: icon
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: moth
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/Objects/Fun/Plushies/moth.rsi
|
||||
state: icon
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: rouny
|
||||
category: Plushies
|
||||
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/rouny.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: lizard
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/Objects/Fun/Plushies/lizard.rsi
|
||||
state: icon
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: spacelizard
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/Objects/Fun/Plushies/lizard.rsi
|
||||
state: plushie_spacelizard
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: hampter
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/Objects/Fun/Plushies/hampter.rsi
|
||||
state: icon
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: arachnid
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/Objects/Fun/Plushies/arachnid.rsi
|
||||
state: icon
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: lamp
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/Objects/Fun/Plushies/lamp.rsi
|
||||
state: icon
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: nukie
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/Objects/Fun/Plushies/nukie.rsi
|
||||
state: icon
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: atmosian
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/Objects/Fun/Plushies/atmosian.rsi
|
||||
state: icon
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: slime
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/Objects/Fun/Plushies/slime.rsi
|
||||
state: icon
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: snake
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/Objects/Fun/Plushies/snake.rsi
|
||||
state: icon
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: vox
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/Objects/Fun/Plushies/vox.rsi
|
||||
state: icon
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: diona
|
||||
category: Plushies
|
||||
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/plushie_diona.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: human
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/Objects/Fun/Plushies/human.rsi
|
||||
state: icon
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: xeno
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/Objects/Fun/Plushies/xeno.rsi
|
||||
state: icon
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: carp
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/Objects/Fun/Plushies/carp.rsi
|
||||
state: carpplush
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: rainbowcarp
|
||||
category: Plushies
|
||||
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/rainbowcarp.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: rainbowlizard
|
||||
category: Plushies
|
||||
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/rainbowlizardplush.png
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: nar
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/Objects/Fun/Plushies/narsie.rsi
|
||||
state: icon
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: mothroach
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/_Funkystation/Objects/Fun/toys.rsi
|
||||
state: plushie_mothroach
|
||||
|
||||
- type: nanoChatEmoji
|
||||
id: cosmic
|
||||
category: Plushies
|
||||
sprite:
|
||||
sprite: /Textures/_DV/CosmicCult/Objects/unknownplushie.rsi
|
||||
state: plushie_cosmic
|
||||
|
||||
# Other
|
||||
- type: nanoChatEmoji
|
||||
id: floorgoblin
|
||||
category: Misc
|
||||
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/floorgoblin.png
|
||||
|
After Width: | Height: | Size: 177 B |
|
After Width: | Height: | Size: 270 B |
|
After Width: | Height: | Size: 191 B |
|
After Width: | Height: | Size: 276 B |
|
After Width: | Height: | Size: 516 B |
|
After Width: | Height: | Size: 580 B |
|
After Width: | Height: | Size: 739 B |
|
After Width: | Height: | Size: 738 B |
|
After Width: | Height: | Size: 220 B |
|
After Width: | Height: | Size: 710 B |
|
After Width: | Height: | Size: 707 B |
|
After Width: | Height: | Size: 776 B |
|
After Width: | Height: | Size: 220 B |
|
After Width: | Height: | Size: 560 B |
|
After Width: | Height: | Size: 734 B |
|
After Width: | Height: | Size: 611 B |
|
After Width: | Height: | Size: 273 B |
|
After Width: | Height: | Size: 230 B |
|
After Width: | Height: | Size: 436 B |
|
After Width: | Height: | Size: 744 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 517 B |
|
After Width: | Height: | Size: 741 B |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": 1,
|
||||
"license": "CC-BY-SA-3.0",
|
||||
"copyright": "meijunkie on Discord - https://funkystation.org",
|
||||
"size": {
|
||||
"x": 32,
|
||||
"y": 32
|
||||
},
|
||||
"states": [
|
||||
{
|
||||
"name": "plushie_mothroach"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 349 B |