s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
link Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=thedimin.global_chat&take=20
Showing code results for query:
*
(15 total matches found)
Editor
library
using System;
using System.Collections.Generic;
using System.Globalization;
namespace Editor.GlobalChat;
/// <summary>Matches complete @names, @SteamIds and <@SteamId> mentions, without matching email addresses.</summary>
public static class ChatMentionPolicy
{
public static bool ContainsMention( string text, ulong steamId, string displayName )
{
if ( string.IsNullOrEmpty( text ) ) return false;
var id = steamId.ToString( CultureInfo.InvariantCulture );
return (steamId != 0 && (ContainsToken( text, "@" + id ) || ContainsToken( text, "<@" + id + ">" )))
|| (!string.IsNullOrWhiteSpace( displayName ) && ContainsToken( text, "@" + displayName.Trim() ));
}
static bool ContainsToken( string text, string token )
{
for ( int start = 0; start <= text.Length - token.Length; )
{
var index = text.IndexOf( token, start, StringComparison.OrdinalIgnoreCase );
if ( index < 0 ) return false;
var end = index + token.Length;
if ( (index == 0 || !IsNameCharacter( text[index - 1] ))
&& (end == text.Length || !IsNameCharacter( text[end] )
|| (text[end] == '.' && (end + 1 == text.Length || char.IsWhiteSpace( text[end + 1] )))) ) return true;
start = index + 1;
}
return false;
}
static bool IsNameCharacter( char value ) => char.IsLetterOrDigit( value ) || value is '_' or '@' or '-' or '.';
}
/// <summary>Consumes message identities even while alerts are disabled, so enabling alerts does not replay old messages.</summary>
public sealed class ChatMentionGate
{
readonly HashSet<(ulong, Guid)> seen = new();
readonly Queue<(ulong, Guid)> order = new();
public bool ShouldNotify( Guid id, ulong sender, ulong localUser, string localName, string text, bool live, bool blocked, bool enabled )
{
if ( id == Guid.Empty || sender == 0 || !seen.Add( (sender, id) ) ) return false;
order.Enqueue( (sender, id) );
if ( order.Count > 256 ) seen.Remove( order.Dequeue() );
return live && !blocked && enabled && sender != localUser && ChatMentionPolicy.ContainsMention( text, localUser, localName );
}
}
Editor
library
using Sandbox;
using System;
using System.Linq;
namespace Editor.GlobalChat;
/// <summary>Places the Razor chat surface in the editor's normal dock layout.</summary>
[Dock( "Editor", "Global Chat", "forum", DockArea.Bottom )]
public sealed class GlobalChatDock : Widget
{
internal GlobalChatWindow ChatWindow { get; private set; }
ChatDockNativeHost nativeHost;
internal bool SurfaceAttached => ChatWindow is { IsOpen: true } && nativeHost is not null && nativeHost.IsAttached;
internal IntPtr NativeHandle => nativeHost?.Handle ?? IntPtr.Zero;
internal static DockWidget Dock => EditorWindow.IsValid() && EditorWindow.DockManager.IsValid()
? EditorWindow.DockManager.FindDockWidget( "Global Chat" ) : null;
public GlobalChatDock( Widget parent ) : base( parent )
{
MinimumSize = new Vector2( 540, 420 );
FocusMode = FocusMode.TabOrClick;
OnPaintOverride = SuppressParentPaint;
}
// A named callback survives hotload when constructor fields change.
static bool SuppressParentPaint()
{
return true;
}
internal static void Open()
{
// Restore the library registration if project startup cleared the dock types.
if ( !EditorWindow.DockManager.DockTypes.Any( info => info.Title == "Global Chat" ) )
EditorWindow.DockManager.RegisterDockType( new DockManager.DockInfo { Title = "Global Chat", Icon = "forum", Area = DockArea.Bottom, CreateAction = CreateDock } );
EditorWindow.DockManager.SetDockState( "Global Chat", true );
var dock = Dock ?? throw new InvalidOperationException( "The editor has not registered the Global Chat dock." );
dock.MinimumSizeFromContent = true;
dock.SetAsCurrentTab();
var host = dock.Widget as GlobalChatDock ?? throw new InvalidOperationException( "Global Chat has an unexpected dock host." );
host.EnsureWindow();
host.Synchronize();
host.Focus();
}
static Widget CreateDock()
{
return new GlobalChatDock( EditorWindow );
}
internal static void Hide( GlobalChatWindow window )
{
var dock = Dock;
if ( dock is not null && dock.Widget is GlobalChatDock host && host.ChatWindow == window ) dock.ToggleView( false );
}
void EnsureWindow()
{
if ( ChatWindow is { IsOpen: true } ) return;
ChatWindow = GlobalChatWindow.CreateDocked();
try { nativeHost = new ChatDockNativeHost( ChatWindow, this ); }
catch { ChatWindow.Close(); ChatWindow = null; throw; }
}
[EditorEvent.Frame]
internal void Synchronize()
{
// Rebind existing docks as well as docks made after a code reload.
OnPaintOverride = SuppressParentPaint;
var dock = EditorWindow.DockManager.FindDockWidget( this );
if ( dock is null || dock.IsClosed )
{
nativeHost?.Dispose();
ChatWindow?.CloseSurface();
ChatWindow = null;
nativeHost = null;
return;
}
dock.MinimumSizeFromContent = true;
if ( !Visible ) { ChatWindow?.Hide(); return; }
EnsureWindow();
nativeHost.UpdateBounds();
if ( !ChatWindow.IsVisible ) ChatWindow.Show();
}
internal static void ResizeFloating( Vector2 size )
{
var dock = Dock;
if ( dock is null || !dock.IsFloating ) throw new InvalidOperationException( "Float Global Chat before requesting a capture size, or omit width and height to capture the dock area." );
var host = (GlobalChatDock)dock.Widget;
var floatingWindow = host.GetWindow();
floatingWindow.Size += size - host.Size;
host.Synchronize();
}
public override void OnDestroyed()
{
// The native widget is already gone. Release chat without querying its dock.
nativeHost?.Dispose();
ChatWindow?.CloseSurface();
ChatWindow = null;
nativeHost = null;
base.OnDestroyed();
}
}
Editor
library
// Adapted from s&box menu GlobalChatService. Keep the message and history formats compatible.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Sandbox;
using System.Text;
using System.Text.Json;
namespace Editor.GlobalChat;
/// <summary>
/// Global chat membership exists while its view is open, with recent history shared through the lobby.
/// </summary>
internal sealed class GlobalChatSession : IDisposable
{
readonly EngineChatBridge bridge = new();
CancellationTokenSource cancellation = new();
bool disposed;
internal event Action<ChatLine> LiveMessageReceived;
internal IDisposable EnterMenuScope() => bridge.EnterMenuScope();
internal Task JoinPartyAsync( ulong id ) => bridge.JoinPartyAsync( id, cancellation.Token );
internal ChatChannel Channel { get; private set; }
internal List<ChatLine> Messages { get; } = new();
internal bool Connecting { get; private set; }
internal string Error { get; private set; }
internal int Revision { get; private set; }
internal int InvitationRevision { get; private set; }
internal bool Visible { get; private set; }
internal string Draft { get; set; } = "";
/// <summary>
/// Whether chat is connected and the shared message cooldown has expired.
/// </summary>
internal bool CanSend => Channel is not null && RealTime.Now - lastSent >= 1;
/// <summary>
/// Whether the party leader can post another invitation, including the shared message cooldown.
/// </summary>
internal bool CanInviteParty => CanSend && PartyRoom.Current is { Owner.IsMe: true } && RealTime.Now - lastInvite >= 10;
internal record ChatLine( Friend Sender, string Text, ulong PartyId, Guid Id );
/// <summary>
/// A snapshot of a shared party. Unavailable also covers failed refreshes, so stale invites cannot be joined.
/// </summary>
internal record PartyInvitation( string Name, string GameTitle, int Members, int Capacity, bool IsPlaying, bool Available )
{
internal bool IsFull => Members >= Capacity;
}
readonly Dictionary<ulong, PartyInvitation> invitations = new();
bool refreshingInvitations;
double nextInvitationRefresh;
readonly Dictionary<ulong, double> lastMessages = new();
const string HistoryKey = "chat_history";
const int HistoryLimit = 20;
const int HistoryByteLimit = 8191;
readonly List<Message> history = new();
int generation;
bool refreshing;
double nextRefresh;
double nextJoin;
double lastSent = -10;
double lastInvite = -10;
sealed class Message
{
/// <summary>
/// Identifies a message across live delivery and lobby history.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Steam sender recorded by the receiver, never trusted from a live payload.
/// </summary>
public ulong SenderId { get; set; }
/// <summary>
/// Plain text sent to everyone in global chat.
/// </summary>
public string Text { get; set; }
/// <summary>
/// Optional invitation to an existing party.
/// </summary>
public ulong PartyId { get; set; }
}
internal void Open()
{
ObjectDisposedException.ThrowIf( disposed, this );
if ( Visible ) return;
Visible = true;
Tick();
}
internal void Close()
{
if ( !Visible && Channel is null ) return;
Visible = false;
generation++;
cancellation.Cancel();
cancellation.Dispose();
cancellation = new();
Channel?.Dispose();
Channel = null;
Connecting = false;
refreshing = false;
refreshingInvitations = false;
// Leaving the page releases the channel, but keeps the conversation and draft for returning to it.
invitations.Clear();
lastMessages.Clear();
history.Clear();
Error = null;
nextJoin = 0;
nextRefresh = 0;
nextInvitationRefresh = 0;
Revision++;
}
internal void Tick()
{
if ( Channel is { IsJoined: false } )
{
Close();
Open();
return;
}
if ( !Visible ) return;
if ( Channel is null )
{
if ( !Connecting && RealTime.Now >= nextJoin ) _ = Join();
return;
}
if ( !Connecting && !refreshing && RealTime.Now >= nextRefresh ) _ = Refresh();
if ( !refreshingInvitations && RealTime.Now >= nextInvitationRefresh ) _ = RefreshInvitations();
}
async Task Join()
{
Connecting = true;
var version = generation;
ChatChannel joined = null;
try
{
var found = await bridge.FindAsync( cancellation.Token );
if ( version != generation || !Visible ) return;
foreach ( var channel in found.OrderBy( x => x ) )
{
try { joined = await bridge.JoinAsync( channel ); }
catch ( InvalidOperationException ) { }
if ( joined is not null || version != generation || !Visible ) break;
}
if ( version != generation || !Visible ) return;
joined ??= await bridge.CreateAsync();
if ( version != generation || !Visible ) return;
Attach( joined );
joined = null;
Error = null;
}
catch ( Exception e )
{
if ( version == generation && Visible )
{
Error = e.Message;
nextJoin = RealTime.Now + 10;
}
}
finally
{
joined?.Dispose();
if ( version == generation ) { Connecting = false; Revision++; }
}
}
void Attach( ChatChannel channel )
{
Channel?.Dispose();
Channel = channel;
// Rejoining or merging channels must not erase messages already received in this session.
invitations.Clear();
nextInvitationRefresh = 0;
lastMessages.Clear();
history.Clear();
LoadHistory();
channel.MessageReceived += Receive;
channel.Changed += Changed;
Changed();
}
/// <summary>
/// All messages sharing the same party use one periodically refreshed snapshot.
/// </summary>
internal PartyInvitation GetInvitation( ulong id ) => invitations.GetValueOrDefault( id );
async Task RefreshInvitations()
{
using var scope = EnterMenuScope();
refreshingInvitations = true;
nextInvitationRefresh = RealTime.Now + 15;
var version = generation;
var channel = Channel;
try
{
var ids = Messages.Where( x => x.PartyId != 0 ).Select( x => x.PartyId ).Distinct().ToArray();
foreach ( var id in invitations.Keys.Except( ids ).ToArray() ) invitations.Remove( id );
// Bound concurrent Steam requests, including when chat contains many different invitations.
foreach ( var batch in ids.Chunk( 8 ) )
{
if ( version != generation || !Visible || Channel != channel ) return;
await Task.WhenAll( batch.Select( async id =>
{
PartyRoom.Entry? entry = null;
try
{
entry = await PartyRoom.GetEntryAsync( id );
}
catch ( Exception )
{
// A failed check must not leave an old invitation looking live.
}
if ( version != generation || !Visible || Channel != channel ) return;
invitations[id] = entry is { } party
? new( party.Name, party.GameTitle, party.Members, party.MaxMembers, party.IsPlaying, true )
: new( invitations.GetValueOrDefault( id )?.Name, invitations.GetValueOrDefault( id )?.GameTitle, 0, 0, false, false );
InvitationRevision++;
} ) );
}
}
finally
{
if ( version == generation ) refreshingInvitations = false;
}
}
async Task Refresh()
{
refreshing = true;
nextRefresh = RealTime.Now + 15;
var version = generation;
try
{
// Merge channels created at the same time once Steam lists both of them.
var current = Channel;
if ( current is null || Connecting ) return;
var found = await bridge.FindAsync( cancellation.Token );
if ( version != generation || !Visible || Channel != current || Connecting ) return;
Error = null;
var older = found.Where( x => x < current.Id ).OrderBy( x => x ).FirstOrDefault();
if ( older == 0 ) return;
Connecting = true;
try
{
var joined = await bridge.JoinAsync( older );
if ( version != generation || !Visible )
{
joined.Dispose();
}
else
{
Attach( joined );
}
}
finally { if ( version == generation ) Connecting = false; }
}
catch ( Exception e )
{
if ( version == generation && Visible ) Error = e.Message;
}
finally { if ( version == generation ) { refreshing = false; Revision++; } }
}
internal void InviteParty()
{
if ( !CanInviteParty || PartyRoom.Current is not { } party ) return;
try
{
party.MakePublic();
if ( SendMessage( "", party.Id.ValueUnsigned ) ) lastInvite = RealTime.Now;
nextRefresh = 0;
}
catch ( Exception e ) { Error = e.Message; }
}
internal bool Send( string text ) => SendMessage( text, 0 );
bool SendMessage( string text, ulong partyId )
{
text = text?.Trim() ?? "";
if ( !CanSend || (text.Length == 0 && partyId == 0) ) return false;
if ( text.Length > 400 ) text = text[..400];
if ( !Channel.Send( JsonSerializer.Serialize( new Message { Id = Guid.NewGuid(), Text = text, PartyId = partyId } ) ) )
{
Error = "The message could not be sent.";
return false;
}
Error = null;
lastSent = RealTime.Now;
return true;
}
void Receive( Friend sender, string payload )
{
if ( sender.IsBlocked || !Visible ) return;
if ( lastMessages.TryGetValue( sender.Id, out var last ) && RealTime.Now - last < 0.5 ) return;
try
{
var message = JsonSerializer.Deserialize<Message>( payload );
if ( !IsValid( message ) ) return;
lastMessages[sender.Id] = RealTime.Now;
// Older clients do not include a message ID.
if ( message.Id == Guid.Empty ) message.Id = Guid.NewGuid();
message.SenderId = sender.Id;
Remember( message );
AddMessage( message, true );
SaveHistory();
}
catch ( JsonException ) { }
}
bool IsValid( Message message ) => message is not null && message.Text?.Length is not > 400 && (message.PartyId != 0 || !string.IsNullOrWhiteSpace( message.Text ));
void Remember( Message message )
{
if ( history.Any( x => x.Id == message.Id && x.SenderId == message.SenderId ) ) return;
history.Add( message );
if ( history.Count > HistoryLimit ) history.RemoveAt( 0 );
}
void AddMessage( Message message, bool live = false )
{
var sender = new Friend( message.SenderId );
if ( sender.IsBlocked || Messages.Any( x => x.Id == message.Id && x.Sender.Id == message.SenderId ) ) return;
var text = Sandbox.Utility.Steam.FilterChat( message.Text, sender.Id );
var line = new ChatLine( sender, text, message.PartyId, message.Id );
Messages.Add( line );
if ( message.PartyId != 0 && !invitations.ContainsKey( message.PartyId ) ) nextInvitationRefresh = 0;
if ( Messages.Count > 100 ) Messages.RemoveAt( 0 );
Revision++;
if ( live )
{
try { LiveMessageReceived?.Invoke( line ); }
catch ( Exception e ) { Log.Warning( e, "Global chat mention alert failed." ); }
}
}
void LoadHistory()
{
var payload = Channel.GetData( HistoryKey );
if ( string.IsNullOrEmpty( payload ) || Encoding.UTF8.GetByteCount( payload ) > HistoryByteLimit ) return;
try
{
var messages = JsonSerializer.Deserialize<List<Message>>( payload );
if ( messages is null ) return;
foreach ( var message in messages.TakeLast( HistoryLimit ) )
{
if ( !IsValid( message ) || message.Id == Guid.Empty || message.SenderId == 0 ) continue;
Remember( message );
AddMessage( message );
}
}
catch ( JsonException ) { }
}
void SaveHistory()
{
if ( Channel is null || !Channel.Owner.IsMe ) return;
// Steam limits each metadata value to 8 KB, including its terminating byte.
var payload = JsonSerializer.Serialize( history );
while ( Encoding.UTF8.GetByteCount( payload ) > HistoryByteLimit && history.Count > 0 )
{
history.RemoveAt( 0 );
payload = JsonSerializer.Serialize( history );
}
try
{
Channel.SetData( HistoryKey, payload );
}
catch ( InvalidOperationException e )
{
Error = e.Message;
Revision++;
}
}
void Changed()
{
// Every member keeps recent messages so a new owner can continue saving history.
SaveHistory();
Revision++;
}
public void Dispose()
{
if ( disposed ) return;
Close();
disposed = true;
cancellation.Dispose();
LiveMessageReceived = null;
}
}
Editor
library
using Sandbox;
using Sandbox.UI;
using System;
using System.Linq;
using System.Reflection;
namespace Editor.GlobalChat;
/// <summary>Captures the chat panel through the editor window camera.</summary>
internal static class ChatWindowCapture
{
internal static Bitmap Capture( PanelWindow window, GlobalChatSession session )
{
using var scope = session.EnterMenuScope();
EngineChatBridge.Invoke( EngineChatBridge.RequireMethod( typeof( PanelWindow ), "Frame" ), window );
var camera = typeof( PanelWindow ).GetField( "_camera", BindingFlags.Instance | BindingFlags.NonPublic )?.GetValue( window ) as SceneCamera
?? throw new InvalidOperationException( "The engine does not expose the chat window camera." );
var render = typeof( SceneCamera ).GetMethods( BindingFlags.Instance | BindingFlags.NonPublic )
.Single( x => x.Name == "RenderToBitmap" && x.GetParameters().Length == 2 );
var config = Activator.CreateInstance( render.GetParameters()[1].ParameterType.GetElementType() );
var size = (Vector2)EngineChatBridge.RequireProperty( typeof( PanelWindow ), "PixelSize" ).GetValue( window );
var bitmap = new Bitmap( (int)size.x, (int)size.y );
try { EngineChatBridge.Invoke( render, camera, bitmap, config ); return bitmap; }
catch { bitmap.Dispose(); throw; }
}
}
Editor
library
using Sandbox;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
namespace Editor.GlobalChat;
/// <summary>
/// The only reflection boundary. Uses the engine's Steam query semaphore, membership handles,
/// authenticated callbacks and wire protocol. Does not change engine methods or global settings.
/// </summary>
internal sealed class EngineChatBridge
{
const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance;
readonly Type channelType;
readonly PropertyInfo lobbyList;
readonly MethodInfo worldwide, withKeyValue, request, join, create, menuScope;
readonly PropertyInfo lobbyId;
readonly FieldInfo steamIdValue;
readonly Dictionary<string, string> filters;
public EngineChatBridge()
{
var engine = typeof( Scene ).Assembly;
var matchmaking = RequireType( engine, "Steamworks.SteamMatchmaking" );
var query = RequireType( engine, "Steamworks.Data.LobbyQuery" );
var lobby = RequireType( engine, "Steamworks.Data.Lobby" );
var protocol = RequireType( engine, "Sandbox.Engine.Protocol" );
var menu = AppDomain.CurrentDomain.GetAssemblies().SingleOrDefault( x => x.GetName().Name == "Sandbox.Menu" )
?? Assembly.Load( "Sandbox.Menu" );
channelType = RequireType( menu, "Sandbox.MenuChatChannel" );
lobbyList = RequireProperty( matchmaking, "LobbyList" );
lobbyId = RequireProperty( lobby, "Id" );
steamIdValue = lobbyId.PropertyType.GetField( "Value", All )
?? throw Incompatible( "Steamworks.SteamId.Value" );
worldwide = RequireMethod( query, "FilterDistanceWorldwide" );
withKeyValue = RequireMethod( query, "WithKeyValue", typeof( string ), typeof( string ) );
request = RequireMethod( query, "RequestAsync", typeof( CancellationToken ) );
join = RequireMethod( channelType, "JoinAsync", typeof( ulong ) );
create = RequireMethod( channelType, "CreateAsync", typeof( int ), typeof( Dictionary<string, string> ) );
menuScope = RequireMethod( RequireType( engine, "Sandbox.Engine.GlobalContext" ), "MenuScope" );
filters = new()
{
["menu_protocol"] = "1",
["api"] = RequireProperty( protocol, "Api" ).GetValue( null ).ToString(),
["protocol"] = RequireProperty( protocol, "Network" ).GetValue( null ).ToString(),
["lobby_type"] = "menu_chat",
["channel"] = "global",
// Existing normal menu clients still search for this metadata. Never create an editor-only room.
["dev"] = "0"
};
// Resolve the complete handle contract before joining Steam.
foreach ( var name in new[] { "Id", "Owner", "MemberCount", "Members", "IsJoined" } ) RequireProperty( channelType, name );
RequireMethod( channelType, "GetData", typeof( string ) );
RequireMethod( channelType, "SetData", typeof( string ), typeof( string ) );
RequireMethod( channelType, "Send", typeof( string ) );
foreach ( var name in new[] { "MessageReceived", "Changed" } )
if ( channelType.GetEvent( name, All ) is null ) throw Incompatible( $"{channelType.FullName}.{name}" );
}
public IDisposable EnterMenuScope() => (IDisposable)Invoke( menuScope, null );
public async Task<ulong[]> FindAsync( CancellationToken cancellation )
{
using var scope = EnterMenuScope();
var query = Invoke( worldwide, lobbyList.GetValue( null ) );
foreach ( var pair in filters ) query = Invoke( withKeyValue, query, pair.Key, pair.Value );
var found = (IEnumerable)await AwaitResult( Invoke( request, query, cancellation ) );
return found?.Cast<object>().Select( x => (ulong)steamIdValue.GetValue( lobbyId.GetValue( x ) ) ).OrderBy( x => x ).ToArray() ?? [];
}
public async Task<ChatChannel> JoinAsync( ulong id )
{
using var scope = EnterMenuScope();
return new ChatChannel( await AwaitResult( Invoke( join, null, id ) ), channelType );
}
public async Task<ChatChannel> CreateAsync()
{
using var scope = EnterMenuScope();
return new ChatChannel( await AwaitResult( Invoke( create, null, 250, new Dictionary<string, string>( filters ) ) ), channelType );
}
public async Task JoinPartyAsync( ulong id, CancellationToken cancellation )
{
using var scope = EnterMenuScope();
if ( PartyRoom.Current is not null ) throw new InvalidOperationException( "Leave your current party before joining another." );
var lobbyType = lobbyId.DeclaringType;
var lobbyConstructor = lobbyType.GetConstructor( All, null, [lobbyId.PropertyType], null ) ?? throw Incompatible( "Lobby constructor" );
var partyConstructor = typeof( PartyRoom ).GetConstructor( All, null, [lobbyType], null ) ?? throw Incompatible( "PartyRoom constructor" );
var current = RequireProperty( typeof( PartyRoom ), "Current" );
var setCurrent = current.GetSetMethod( true ) ?? throw Incompatible( "PartyRoom.Current setter" );
var joinLobby = RequireMethod( lobbyType, "Join" );
var leaveLobby = RequireMethod( lobbyType, "Leave" );
var waitForMembership = RequireMethod( channelType, "WaitForMembership", lobbyType );
var eventSystem = RequireProperty( RequireType( typeof( Scene ).Assembly, "Sandbox.Event" ), "EventSystem" ).GetValue( null );
var runInterface = eventSystem.GetType().GetMethods( All ).Single( x => x.Name == "RunInterface" && x.IsGenericMethodDefinition && x.GetParameters().Length == 1 )
.MakeGenericMethod( typeof( PartyRoom.IEventListener ) );
var entry = await PartyRoom.GetEntryAsync( id );
cancellation.ThrowIfCancellationRequested();
if ( entry is not { } available || available.Members >= available.MaxMembers ) throw new InvalidOperationException( "The party is unavailable or full." );
if ( PartyRoom.Current is not null ) throw new InvalidOperationException( "Your party changed while joining. Please try again." );
var steamId = Activator.CreateInstance( lobbyId.PropertyType );
steamIdValue.SetValue( steamId, id );
var lobby = lobbyConstructor.Invoke( [steamId] );
// The public party join leaves the current party, then rejects all editor clients.
// Use the same Steam join and real PartyRoom lifecycle without changing IsEditor.
var response = await AwaitResult( Invoke( joinLobby, lobby ) );
if ( response.ToString() != "Success" ) throw new InvalidOperationException( $"Could not join the party: {response}." );
PartyRoom party = null;
try
{
await (Task)Invoke( waitForMembership, null, lobby );
cancellation.ThrowIfCancellationRequested();
if ( PartyRoom.Current is not null ) throw new InvalidOperationException( "Your party changed while joining. Please try again." );
party = (PartyRoom)partyConstructor.Invoke( [lobby] );
Invoke( setCurrent, null, party );
Invoke( runInterface, eventSystem, (Action<PartyRoom.IEventListener>)(listener => listener.OnJoinedParty( party )) );
}
catch
{
if ( party is not null ) party.Leave();
else Invoke( leaveLobby, lobby );
throw;
}
}
internal async Task<int> ValidatePrivatePartyAsync()
{
using var scope = EnterMenuScope();
if ( PartyRoom.Current is not null ) throw new InvalidOperationException( "Leave your party before running the private party check." );
var matchmaking = lobbyList.DeclaringType;
var lobbyType = lobbyId.DeclaringType;
var accessType = RequireType( typeof( Scene ).Assembly, "Steamworks.LobbyType" );
var createLobby = RequireMethod( matchmaking, "CreateLobbyAsync", accessType, typeof( int ) );
var setData = RequireMethod( lobbyType, "SetData", typeof( string ), typeof( string ) );
var leave = RequireMethod( lobbyType, "Leave" );
var lobby = await AwaitResult( Invoke( createLobby, null, Enum.Parse( accessType, "Private" ), 2 ) )
?? throw new InvalidOperationException( "Steam could not create the private validation party." );
var id = (ulong)steamIdValue.GetValue( lobbyId.GetValue( lobby ) );
try
{
if ( !(bool)Invoke( setData, lobby, "lobby_type", "party" ) || !(bool)Invoke( setData, lobby, "name", "Editor Global Chat validation" ) )
throw new InvalidOperationException( "Steam could not set up the private validation party." );
await JoinPartyAsync( id, CancellationToken.None );
var party = PartyRoom.Current;
if ( party?.Id.ValueUnsigned != id || !party.Owner.IsMe || party.MemberCount != 1 )
throw new InvalidOperationException( "The private validation party did not match the expected membership." );
return party.MemberCount;
}
finally
{
if ( PartyRoom.Current?.Id.ValueUnsigned == id ) PartyRoom.Current.Leave();
else Invoke( leave, lobby );
}
}
internal static Type RequireType( Assembly assembly, string name )
{
return assembly.GetType( name, false ) ?? throw Incompatible( name );
}
internal static PropertyInfo RequireProperty( Type type, string name )
{
return type.GetProperty( name, All ) ?? throw Incompatible( $"{type.FullName}.{name}" );
}
internal static MethodInfo RequireMethod( Type type, string name, params Type[] parameters )
{
return type.GetMethod( name, All, null, parameters, null ) ?? throw Incompatible( $"{type.FullName}.{name}" );
}
internal static object Invoke( MethodInfo method, object target, params object[] arguments )
{
try { return method.Invoke( target, arguments ); }
catch ( TargetInvocationException e ) when ( e.InnerException is not null )
{
ExceptionDispatchInfo.Capture( e.InnerException ).Throw();
throw;
}
}
static async Task<object> AwaitResult( object value )
{
if ( value is not Task task ) throw Incompatible( "an asynchronous engine method result" );
await task;
return RequireProperty( task.GetType(), "Result" ).GetValue( task );
}
static InvalidOperationException Incompatible( string member )
{
return new( $"Editor Global Chat cannot use this engine version: missing {member}. Update the library's EngineChatBridge." );
}
}
internal sealed class ChatChannel : IDisposable
{
readonly object handle;
readonly PropertyInfo id, owner, members, memberCount, isJoined;
readonly MethodInfo getData, setData, send;
readonly EventInfo received, changed;
readonly Action<Friend, string> onReceived;
readonly Action onChanged;
bool disposed;
public event Action<Friend, string> MessageReceived;
public event Action Changed;
public ulong Id => (ulong)id.GetValue( handle );
public Friend Owner => (Friend)owner.GetValue( handle );
public int MemberCount => (int)memberCount.GetValue( handle );
public IEnumerable<Friend> Members => (IEnumerable<Friend>)members.GetValue( handle );
public bool IsJoined => !disposed && (bool)isJoined.GetValue( handle );
public ChatChannel( object handle, Type type )
{
this.handle = handle;
id = EngineChatBridge.RequireProperty( type, "Id" );
owner = EngineChatBridge.RequireProperty( type, "Owner" );
members = EngineChatBridge.RequireProperty( type, "Members" );
memberCount = EngineChatBridge.RequireProperty( type, "MemberCount" );
isJoined = EngineChatBridge.RequireProperty( type, "IsJoined" );
getData = EngineChatBridge.RequireMethod( type, "GetData", typeof( string ) );
setData = EngineChatBridge.RequireMethod( type, "SetData", typeof( string ), typeof( string ) );
send = EngineChatBridge.RequireMethod( type, "Send", typeof( string ) );
received = type.GetEvent( "MessageReceived" );
changed = type.GetEvent( "Changed" );
onReceived = (friend, text) => MessageReceived?.Invoke( friend, text );
onChanged = () => Changed?.Invoke();
try
{
received.AddEventHandler( handle, onReceived );
changed.AddEventHandler( handle, onChanged );
}
catch
{
received.RemoveEventHandler( handle, onReceived );
((IDisposable)handle).Dispose();
throw;
}
}
public string GetData( string key ) => (string)EngineChatBridge.Invoke( getData, handle, key );
public void SetData( string key, string value ) => EngineChatBridge.Invoke( setData, handle, key, value );
public bool Send( string text ) => (bool)EngineChatBridge.Invoke( send, handle, text );
public void Dispose()
{
if ( disposed ) return;
disposed = true;
received.RemoveEventHandler( handle, onReceived );
changed.RemoveEventHandler( handle, onChanged );
((IDisposable)handle).Dispose();
MessageReceived = null;
Changed = null;
}
}
Editor
library
using Sandbox;
using System;
using System.Net;
using System.Threading.Tasks;
namespace Editor.GlobalChat;
/// <summary>Hosts the menu-style Razor chat in an editor panel window.</summary>
public sealed class GlobalChatWindow : PanelWindow
{
internal static GlobalChatWindow Current { get; private set; }
static GlobalChatSession sharedSession;
internal GlobalChatSession Session { get; }
internal GlobalChatView View { get; }
static readonly ChatMentionGate mentionGate = new();
internal bool PartyBusy { get; private set; }
internal string PartyError { get; private set; }
bool released;
internal int PopupCount { get; private set; }
internal int SoundCount { get; private set; }
internal string SoundError { get; private set; }
public static bool MentionPopups
{
get => EditorCookie.Get( "GlobalChat.MentionPopups", true );
set => EditorCookie.Set( "GlobalChat.MentionPopups", value );
}
public static bool MentionSounds
{
get => EditorCookie.Get( "GlobalChat.MentionSounds", true );
set => EditorCookie.Set( "GlobalChat.MentionSounds", value );
}
[Menu( "Editor", "Chat/Global Chat", "forum" )]
public static void Open()
{
GlobalChatDock.Open();
}
internal static GlobalChatWindow CreateDocked()
{
Current?.CloseSurface();
return Current = new GlobalChatWindow();
}
public GlobalChatWindow() : base( "Global Chat", new Vector2( 920, 720 ), null, borderless: true )
{
try
{
MinSize = new Vector2( 540, 420 );
ResizeBorder = 0;
DropShadow = false;
RoundedCorners = false;
BackgroundColor = Color.Parse( "#171a21" ) ?? Color.Black;
Session = sharedSession ??= new GlobalChatSession();
Session.Draft = EditorCookie.Get( "GlobalChat.Draft", "" );
using var scope = Session.EnterMenuScope();
View = new GlobalChatView { Session = Session, Host = this, Parent = Root };
Session.LiveMessageReceived += NotifyMention;
OnCloseRequested = HandleCloseRequested;
Session.Open();
}
catch { ReleaseWindow(); Dispose(); throw; }
}
bool HandleCloseRequested()
{
ReleaseWindow();
return true;
}
[EditorEvent.Hotload]
static void RebindCloseCallback()
{
if ( Current is { IsOpen: true } ) Current.OnCloseRequested = Current.HandleCloseRequested;
}
[EditorEvent.Frame]
static void Frame()
{
if ( Current is null ) return;
if ( !Current.IsOpen ) { Current.Close(); return; }
using var scope = Current.Session.EnterMenuScope();
Current.Session.Tick();
}
internal async Task JoinParty( ulong id )
{
if ( PartyBusy || Session.GetInvitation( id ) is not { Available: true, IsFull: false } ) return;
PartyBusy = true;
PartyError = null;
try
{
using var scope = Session.EnterMenuScope();
if ( PartyRoom.Current is not null ) throw new InvalidOperationException( "Leave your current party before joining another." );
await Session.JoinPartyAsync( id );
}
catch ( Exception e ) { PartyError = e.Message; }
finally { PartyBusy = false; }
}
internal void NotifyMention( GlobalChatSession.ChatLine message )
{
var me = new Friend( Game.SteamId );
if ( !mentionGate.ShouldNotify( message.Id, message.Sender.Id, me.Id, me.Name, message.Text, true, message.Sender.IsBlocked, MentionPopups || MentionSounds ) ) return;
if ( MentionPopups )
{
var toast = new MentionToast { Title = "Mention in global chat", Subtitle = message.Sender.Name, Icon = "alternate_email", IsRunning = false };
var body = new Widget() { FixedWidth = 300 };
body.Layout = Layout.Column();
body.Layout.Add( new Label( Escape( message.Text ) ) { WordWrap = true } );
var open = body.Layout.Add( new Button( "Open chat", "forum" ) );
open.Clicked = () => { Open(); Current.View.ScrollToLatest(); ToastManager.Dismiss( toast ); };
toast.SetBodyWidget( body );
ToastManager.Remove( toast, 8 );
PopupCount++;
}
if ( MentionSounds )
{
if ( ChatMentionSound.Play( out var error ) ) SoundCount++;
else Log.Warning( $"Global chat mention sound failed: {error}" );
SoundError = error;
}
}
internal static string Escape( string text ) => "<span>" + WebUtility.HtmlEncode( text ?? "" ).Replace( "\n", "<br>" ) + "</span>";
void ReleaseWindow()
{
if ( released ) return;
released = true;
if ( Session is not null ) Session.LiveMessageReceived -= NotifyMention;
if ( Current is not null && Current != this ) return;
Session?.Close();
if ( Current == this ) Current = null;
}
public void Close()
{
GlobalChatDock.Hide( this );
CloseSurface();
}
internal void CloseSurface()
{
ReleaseWindow();
Dispose();
}
[Event( "app.exit" )]
static void Shutdown()
{
Current?.CloseSurface();
sharedSession?.Dispose();
sharedSession = null;
}
sealed class MentionToast : ToastWidget
{
public override bool WantsVisible => MentionPopups;
}
}
Editor
library
using Sandbox;
using System;
using System.ComponentModel;
using System.Reflection;
using System.Collections.Generic;
using System.Runtime.InteropServices;
namespace Editor.GlobalChat;
/// <summary>Embeds the existing SDL surface in a native editor widget on Windows.</summary>
internal sealed class ChatDockNativeHost : IDisposable, IHotloadManaged
{
const int StyleIndex = -16;
const long ChildStyle = 0x40000000;
const long PopupStyle = 0x80000000;
const long CaptionStyle = 0x00C00000;
const long ThickFrameStyle = 0x00040000;
readonly IntPtr child;
readonly IntPtr parent;
readonly Widget widget;
readonly IntPtr sdlHandle;
readonly MethodInfo mouseMove;
readonly MethodInfo mouseButton;
readonly object[] buttons;
readonly GlobalChatWindow chat;
readonly MethodInfo frame;
readonly ChatDockKeyboardInput keyboard;
[SkipHotload] IntPtr callbackRoot;
bool inputHookInstalled;
bool positioned;
internal ChatDockNativeHost( GlobalChatWindow window, Widget widget )
{
this.widget = widget;
chat = window;
frame = EngineChatBridge.RequireMethod( typeof( PanelWindow ), "Frame" );
if ( !OperatingSystem.IsWindows() ) throw new PlatformNotSupportedException( "Docked Global Chat currently requires the Windows editor." );
var nativeWidget = typeof( Widget ).GetField( "_widget", BindingFlags.Instance | BindingFlags.NonPublic )?.GetValue( widget )
?? throw new InvalidOperationException( "The editor does not expose the native dock widget." );
parent = (IntPtr)EngineChatBridge.Invoke( EngineChatBridge.RequireMethod( nativeWidget.GetType(), "winId" ), nativeWidget );
var sdl = EngineChatBridge.RequireType( typeof( Friend ).Assembly, "NativeEngine.Sdl" );
var handle = EngineChatBridge.RequireProperty( typeof( PanelWindow ), "Handle" ).GetValue( window );
sdlHandle = (IntPtr)handle;
var input = EngineChatBridge.RequireType( typeof( Friend ).Assembly, "Sandbox.UI.PanelWindowInput" );
mouseMove = EngineChatBridge.RequireMethod( input, "OnMouseMove", typeof( IntPtr ), typeof( float ), typeof( float ), typeof( float ), typeof( float ) );
var buttonType = EngineChatBridge.RequireType( typeof( Friend ).Assembly, "NativeEngine.ButtonCode" );
mouseButton = EngineChatBridge.RequireMethod( input, "OnMouseButton", typeof( IntPtr ), buttonType, typeof( bool ), typeof( int ), typeof( KeyboardModifiers ) );
buttons = [Enum.Parse( buttonType, "MouseLeft" ), Enum.Parse( buttonType, "MouseRight" ), Enum.Parse( buttonType, "MouseMiddle" )];
var properties = EngineChatBridge.Invoke( EngineChatBridge.RequireMethod( sdl, "GetWindowProperties", typeof( IntPtr ) ), null, handle );
child = (IntPtr)EngineChatBridge.Invoke( EngineChatBridge.RequireMethod( sdl, "GetPointerProperty", typeof( uint ), typeof( string ), typeof( IntPtr ) ), null, properties, "SDL.window.win32.hwnd", IntPtr.Zero );
if ( parent == IntPtr.Zero || child == IntPtr.Zero ) throw new InvalidOperationException( "The editor did not create both native chat handles." );
// SetParent does not change window styles. Set WS_CHILD and clear WS_POPUP first.
var style = GetWindowLongPtr( child, StyleIndex ).ToInt64();
Marshal.SetLastPInvokeError( 0 );
SetWindowLongPtr( child, StyleIndex, new IntPtr( (style | ChildStyle) & ~(PopupStyle | CaptionStyle | ThickFrameStyle) ) );
if ( Marshal.GetLastPInvokeError() != 0 ) throw new Win32Exception( Marshal.GetLastPInvokeError() );
Marshal.SetLastPInvokeError( 0 );
SetParent( child, parent );
if ( GetParent( child ) != parent ) throw new Win32Exception( Marshal.GetLastPInvokeError(), "Could not attach chat to its editor dock." );
UpdateBounds();
// An SDL top-level window relies on activation for keyboard focus. After it
// becomes a Qt child, mouse activation belongs to the editor's top-level window.
InstallInputHook();
try { keyboard = new ChatDockKeyboardInput( child, sdlHandle, window ); }
catch { Dispose(); throw; }
}
void InstallInputHook()
{
SubclassProc inputCallback = HandleNativeInput;
callbackRoot = GCHandle.ToIntPtr( GCHandle.Alloc( inputCallback ) );
if ( !SetWindowSubclass( child, inputCallback, 1, UIntPtr.Zero ) )
{
GCHandle.FromIntPtr( callbackRoot ).Free();
callbackRoot = IntPtr.Zero;
throw new InvalidOperationException( "Could not install input handling for the chat dock." );
}
inputHookInstalled = true;
}
void IHotloadManaged.Destroyed( Dictionary<string, object> state )
{
state["InputHookInstalled"] = inputHookInstalled;
RemoveInputHook();
}
void IHotloadManaged.Created( IReadOnlyDictionary<string, object> state )
{
inputHookInstalled = false;
callbackRoot = IntPtr.Zero;
if ( state.TryGetValue( "InputHookInstalled", out var installed ) && installed is true ) InstallInputHook();
}
void IHotloadManaged.Persisted()
{
if ( !inputHookInstalled ) return;
RemoveInputHook();
InstallInputHook();
}
void IHotloadManaged.Failed()
{
Dispose();
}
IntPtr HandleNativeInput( IntPtr window, uint message, UIntPtr wParam, IntPtr lParam, UIntPtr id, UIntPtr data )
{
// Focus only follows a deliberate click. The keyboard hook routes this
// focused native child's keys before Qt can consume editor shortcuts.
try
{
if ( message is 0x0007 or 0x0008 ) keyboard?.FocusChanged( message == 0x0007 );
if ( message is >= 0x0200 and <= 0x0209 )
{
using var scope = chat.Session.EnterMenuScope();
var coordinates = lParam.ToInt64();
// SDL retains mouse tracking, leave events and wheel routing.
if ( message == 0x0200 )
{
EngineChatBridge.Invoke( mouseMove, null, sdlHandle, (float)(short)coordinates, (float)(short)(coordinates >> 16), 0f, 0f );
return DefSubclassProc( window, message, wParam, lParam );
}
var down = message is 0x0201 or 0x0203 or 0x0204 or 0x0206 or 0x0207 or 0x0209;
if ( down ) { widget.Focus(); SetFocus( window ); SetCapture( window ); }
else ReleaseCapture();
// Qt focus briefly leaves the SDL child and clears its mouse state.
// Restore the click position after focus, before dispatching the button.
EngineChatBridge.Invoke( mouseMove, null, sdlHandle, (float)(short)coordinates, (float)(short)(coordinates >> 16), 0f, 0f );
var button = buttons[message <= 0x0203 ? 0 : message <= 0x0206 ? 1 : 2];
var modifiers = KeyboardModifiers.None;
if ( (GetKeyState( 0x10 ) & 0x8000) != 0 ) modifiers |= KeyboardModifiers.Shift;
if ( (GetKeyState( 0x11 ) & 0x8000) != 0 ) modifiers |= KeyboardModifiers.Ctrl;
if ( (GetKeyState( 0x12 ) & 0x8000) != 0 ) modifiers |= KeyboardModifiers.Alt;
EngineChatBridge.Invoke( mouseButton, null, sdlHandle, button, down, message is 0x0203 or 0x0206 or 0x0209 ? 2 : 1, modifiers );
// Process each edge before Windows dispatches the next one. Otherwise a
// press and release in the same frame can disappear from the UI state.
EngineChatBridge.Invoke( frame, chat );
return IntPtr.Zero;
}
}
catch ( Exception e ) { Log.Error( e, "Global chat native mouse input failed." ); }
if ( message == 0x0082 )
{
RemoveInputHook();
}
return DefSubclassProc( window, message, wParam, lParam );
}
public void Dispose()
{
keyboard?.Dispose();
RemoveInputHook();
}
void RemoveInputHook()
{
if ( !inputHookInstalled ) return;
var registeredCallback = (SubclassProc)GCHandle.FromIntPtr( callbackRoot ).Target;
if ( !RemoveWindowSubclass( child, registeredCallback, 1 ) ) throw new InvalidOperationException( "Could not remove input handling for the chat dock." );
inputHookInstalled = false;
GCHandle.FromIntPtr( callbackRoot ).Free();
callbackRoot = IntPtr.Zero;
}
internal bool IsAttached => GetParent( child ) == parent;
internal IntPtr Handle => child;
internal void UpdateBounds()
{
if ( !GetClientRect( parent, out var target ) ) throw new Win32Exception( Marshal.GetLastPInvokeError() );
if ( !GetClientRect( child, out var current ) ) throw new Win32Exception( Marshal.GetLastPInvokeError() );
if ( target.Right < 1 || target.Bottom < 1 || (positioned && target.Right == current.Right && target.Bottom == current.Bottom) ) return;
// Native client sizes are already in device pixels. This also handles DPI changes.
if ( !SetWindowPos( child, IntPtr.Zero, 0, 0, target.Right, target.Bottom, 0x0014 | 0x0020 ) ) throw new Win32Exception( Marshal.GetLastPInvokeError() );
positioned = true;
}
[StructLayout( LayoutKind.Sequential )]
struct NativeRect { internal int Left, Top, Right, Bottom; }
[UnmanagedFunctionPointer( CallingConvention.Winapi )]
delegate IntPtr SubclassProc( IntPtr window, uint message, UIntPtr wParam, IntPtr lParam, UIntPtr id, UIntPtr data );
[DllImport( "comctl32.dll" )]
[return: MarshalAs( UnmanagedType.Bool )]
static extern bool SetWindowSubclass( IntPtr window, SubclassProc callback, UIntPtr id, UIntPtr data );
[DllImport( "comctl32.dll" )]
[return: MarshalAs( UnmanagedType.Bool )]
static extern bool RemoveWindowSubclass( IntPtr window, SubclassProc callback, UIntPtr id );
[DllImport( "comctl32.dll" )]
static extern IntPtr DefSubclassProc( IntPtr window, uint message, UIntPtr wParam, IntPtr lParam );
[DllImport( "user32.dll" )]
static extern IntPtr SetFocus( IntPtr window );
[DllImport( "user32.dll" )]
static extern IntPtr SetCapture( IntPtr window );
[DllImport( "user32.dll" )]
static extern bool ReleaseCapture();
[DllImport( "user32.dll" )]
static extern short GetKeyState( int key );
[DllImport( "user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true )]
static extern IntPtr GetWindowLongPtr( IntPtr window, int index );
[DllImport( "user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true )]
static extern IntPtr SetWindowLongPtr( IntPtr window, int index, IntPtr value );
[DllImport( "user32.dll", SetLastError = true )]
static extern IntPtr SetParent( IntPtr child, IntPtr parent );
[DllImport( "user32.dll" )]
static extern IntPtr GetParent( IntPtr child );
[DllImport( "user32.dll", SetLastError = true )]
[return: MarshalAs( UnmanagedType.Bool )]
static extern bool GetClientRect( IntPtr window, out NativeRect rect );
[DllImport( "user32.dll", SetLastError = true )]
[return: MarshalAs( UnmanagedType.Bool )]
static extern bool SetWindowPos( IntPtr window, IntPtr after, int x, int y, int width, int height, uint flags );
}
Editor
library
using Sandbox;
using Sandbox.UI;
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
namespace Editor.GlobalChat;
/// <summary>Checks the Windows mouse, focus, keyboard and SDL route without sending chat.</summary>
internal static class ChatNativeInputValidation
{
internal static async Task<object> Run( GlobalChatWindow window )
{
if ( !OperatingSystem.IsWindows() ) throw new PlatformNotSupportedException( "Native chat input validation requires Windows." );
if ( (GetAsyncKeyState( 0x10 ) & 0x8000) != 0 || (GetAsyncKeyState( 0x11 ) & 0x8000) != 0 || (GetAsyncKeyState( 0x12 ) & 0x8000) != 0 )
throw new InvalidOperationException( "Release Shift, Ctrl and Alt before checking native chat input." );
using var scope = window.Session.EnterMenuScope();
var host = (GlobalChatDock)GlobalChatDock.Dock.Widget;
var child = host.NativeHandle;
var input = (TextEntry)ChatViewValidation.Find( window.Root, "chat-input" );
var draft = window.Session.Draft;
var previousFocus = GetFocus();
if ( !GetCursorPos( out var cursor ) ) throw new Win32Exception( Marshal.GetLastPInvokeError() );
try
{
window.View.DismissUserActions();
input.Text = "";
window.Session.Draft = "";
await Task.Delay( 150 );
var root = GetAncestor( child, 2 );
host.GetWindow().Raise();
host.Focus();
SetForegroundWindow( root );
SetFocus( root );
var point = new NativePoint { X = (int)input.Box.Rect.Center.x, Y = (int)input.Box.Rect.Center.y };
if ( !ClientToScreen( child, ref point ) || !SetCursorPos( point.X, point.Y ) ) throw new Win32Exception( Marshal.GetLastPInvokeError() );
var underCursor = WindowFromPoint( point );
GetWindowRect( child, out var nativeRect );
GetCursorPos( out var actualCursor );
Send( [new() { Type = 0, Data = new() { Mouse = new() { Flags = 0x0002 } } }, new() { Type = 0, Data = new() { Mouse = new() { Flags = 0x0004 } } }] );
await Task.Delay( 250 );
if ( GetFocus() != child || !input.HasFocus ) throw new InvalidOperationException( $"Clicking the message field did not focus chat. Windows focus: {GetFocus() == child}; field focus: {input.HasFocus}; hit chat window: {underCursor == child}; target: {point.X},{point.Y}; cursor: {actualCursor.X},{actualCursor.Y}; window: {nativeRect.Left},{nativeRect.Top},{nativeRect.Right},{nativeRect.Bottom}; Qt: {host.ScreenPosition}; visible: {IsWindowVisible( child )}; root: {root}; hit: {underCursor}." );
const string sample = "Native keyboard check ✓ 😊 ~ `";
TypeUnicode( sample );
await Task.Delay( 300 );
if ( input.Text != sample || window.Session.Draft != sample ) throw new InvalidOperationException( $"Windows typing did not reach chat: expected '{sample}', received '{input.Text}'. Field focus: {input.HasFocus}; dock focus: {host.IsFocused}; editor focus: {Application.FocusWidget?.GetType().FullName}." );
PressKey( 0x08 );
await Task.Delay( 200 );
if ( input.Text != sample[..^1] || window.Session.Draft != input.Text ) throw new InvalidOperationException( "Windows Backspace did not edit the chat draft." );
PressKey( 0x24 ); // Home followed immediately by text must retain event order.
TypeUnicode( "#@" );
await Task.Delay( 150 );
if ( input.Text != "#@" + sample[..^1] ) throw new InvalidOperationException( "Home followed by typing did not retain keyboard event order." );
PressKey( 0x41, control: true );
PressKey( 0x08 );
await Task.Delay( 150 );
if ( input.Text.Length != 0 || window.Session.Draft.Length != 0 ) throw new InvalidOperationException( "Ctrl+A and Backspace did not clear the draft." );
// Test the current clipboard through the public paste policy. Do not replace
// clipboard contents or expose them in the result.
var clipboardText = EditorUtility.Clipboard.Paste();
input.OnPaste( clipboardText );
var pasted = input.Text;
input.Text = "Paste selection check";
window.Session.Draft = input.Text;
// The engine does not invoke paste when the clipboard has no text.
if ( string.IsNullOrEmpty( clipboardText ) ) pasted = input.Text;
PressKey( 0x41, control: true );
PressKey( 0x56, control: true );
await Task.Delay( 150 );
if ( input.Text != pasted || window.Session.Draft != pasted ) throw new InvalidOperationException( "Ctrl+V did not reach the chat paste policy." );
SetFocus( root );
TypeUnicode( "✓" );
await Task.Delay( 150 );
if ( input.Text != pasted ) throw new InvalidOperationException( "Chat consumed keys after losing native focus." );
return new { NativeClickFocus = true, NativeUnicodeTyping = true, NativeBackspace = true, NativeHome = true, NativeSelectAll = true, NativePaste = true, FocusReleased = true, DraftBinding = true, SentMessages = 0 };
}
finally
{
input.Text = draft;
window.Session.Draft = draft;
EditorCookie.Set( "GlobalChat.Draft", draft );
SetCursorPos( cursor.X, cursor.Y );
if ( previousFocus != IntPtr.Zero ) SetFocus( previousFocus );
}
}
internal static async Task Click( GlobalChatWindow window, Panel target )
{
if ( target is null ) throw new InvalidOperationException( "The expected Razor control is absent." );
var host = (GlobalChatDock)GlobalChatDock.Dock.Widget;
var child = host.NativeHandle;
if ( !GetCursorPos( out var cursor ) ) throw new Win32Exception( Marshal.GetLastPInvokeError() );
try
{
host.GetWindow().Raise();
host.Focus();
SetForegroundWindow( GetAncestor( child, 2 ) );
await Task.Delay( 100 );
var point = new NativePoint { X = (int)target.Box.Rect.Center.x, Y = (int)target.Box.Rect.Center.y };
if ( !ClientToScreen( child, ref point ) || !SetCursorPos( point.X, point.Y ) ) throw new Win32Exception( Marshal.GetLastPInvokeError() );
await Task.Delay( 100 );
if ( WindowFromPoint( point ) != child )
{
GetWindowRect( child, out var rect );
throw new InvalidOperationException( $"The chat control is covered by another window. Target: {point.X},{point.Y}; child bounds: {rect.Left},{rect.Top},{rect.Right},{rect.Bottom}; hit: {WindowFromPoint( point )}; child: {child}; root: {GetAncestor( child, 2 )}." );
}
Send( [new() { Type = 0, Data = new() { Mouse = new() { Flags = 0x0002 } } }] );
await Task.Delay( 50 );
Send( [new() { Type = 0, Data = new() { Mouse = new() { Flags = 0x0004 } } }] );
await Task.Delay( 150 );
}
finally { SetCursorPos( cursor.X, cursor.Y ); }
}
internal static void TypeUnicode( string text )
{
var keys = new NativeInput[text.Length * 2];
for ( var i = 0; i < text.Length; i++ )
{
keys[i * 2] = new() { Type = 1, Data = new() { Keyboard = new() { Scan = text[i], Flags = 0x0004 } } };
keys[i * 2 + 1] = new() { Type = 1, Data = new() { Keyboard = new() { Scan = text[i], Flags = 0x0006 } } };
}
Send( keys );
}
internal static void PressKey( ushort key, bool control = false )
{
var press = new NativeInput { Type = 1, Data = new() { Keyboard = new() { Key = key } } };
var release = new NativeInput { Type = 1, Data = new() { Keyboard = new() { Key = key, Flags = 0x0002 } } };
if ( control ) Send( [new() { Type = 1, Data = new() { Keyboard = new() { Key = 0x11 } } }, press, release, new() { Type = 1, Data = new() { Keyboard = new() { Key = 0x11, Flags = 0x0002 } } }] );
else Send( [press, release] );
}
static void Send( NativeInput[] events )
{
if ( SendInput( (uint)events.Length, events, Marshal.SizeOf<NativeInput>() ) != events.Length ) throw new Win32Exception( Marshal.GetLastPInvokeError(), "Windows did not accept the native input check." );
}
[StructLayout( LayoutKind.Sequential )]
struct NativeInput { internal uint Type; internal InputData Data; }
[StructLayout( LayoutKind.Explicit )]
struct InputData { [FieldOffset( 0 )] internal MouseInput Mouse; [FieldOffset( 0 )] internal KeyboardInput Keyboard; }
[StructLayout( LayoutKind.Sequential )]
struct MouseInput { internal int X, Y; internal uint Data, Flags, Time; internal UIntPtr Extra; }
[StructLayout( LayoutKind.Sequential )]
struct KeyboardInput { internal ushort Key, Scan; internal uint Flags, Time; internal UIntPtr Extra; }
[StructLayout( LayoutKind.Sequential )]
struct NativePoint { internal int X, Y; }
[StructLayout( LayoutKind.Sequential )]
struct NativeRect { internal int Left, Top, Right, Bottom; }
[DllImport( "user32.dll", SetLastError = true )]
static extern uint SendInput( uint count, NativeInput[] events, int size );
[DllImport( "user32.dll" )] static extern IntPtr GetFocus();
[DllImport( "user32.dll" )] static extern IntPtr WindowFromPoint( NativePoint point );
[DllImport( "user32.dll" )] static extern bool GetWindowRect( IntPtr window, out NativeRect rect );
[DllImport( "user32.dll" )] static extern bool IsWindowVisible( IntPtr window );
[DllImport( "user32.dll" )] static extern IntPtr SetFocus( IntPtr window );
[DllImport( "user32.dll" )] static extern IntPtr GetAncestor( IntPtr window, uint flags );
[DllImport( "user32.dll" )] static extern bool SetForegroundWindow( IntPtr window );
[DllImport( "user32.dll" )] static extern short GetAsyncKeyState( int key );
[DllImport( "user32.dll", SetLastError = true )] static extern bool GetCursorPos( out NativePoint point );
[DllImport( "user32.dll", SetLastError = true )] static extern bool SetCursorPos( int x, int y );
[DllImport( "user32.dll", SetLastError = true )] static extern bool ClientToScreen( IntPtr window, ref NativePoint point );
}
Editor
library
namespace Editor.GlobalChat;
// Menu chat SCSS, with class selectors for the editor Razor view. Tokens and grain stay engine-owned.
internal static class GlobalChatTheme
{
internal const string Styles = """
@import "/styles/_front.scss";
.editor-global-chat
{
@include front-card;
flex-direction: row;
flex-shrink: 1;
min-width: 500px;
flex-grow: 1;
min-height: 0;
font-family: $front-font;
color: $front-body;
gap: 0;
border-radius: $front-radius;
overflow: hidden;
.global-conversation
{
min-height: 0;
width: 0;
flex-shrink: 1;
flex-grow: 1;
min-width: 0;
flex-direction: column;
}
.conversation-heading
{
align-items: center;
gap: 8px;
padding: 6px 16px;
min-height: 40px;
border-bottom: 1px solid $front-border;
flex-shrink: 0;
> label
{
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
width: 0;
font-size: 11px;
color: $front-secondary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
button
{
@include front-icon-button;
width: 28px;
height: 28px;
padding: 4px;
icon { font-size: 18px; }
&.active { color: $front-heading; }
}
}
.chat-notice
{
margin: 12px 24px 0;
padding: 12px;
background-color: rgba( $front-primary, 0.12 );
color: $front-body;
flex-shrink: 0;
}
.chat-notice.error
{
background-color: rgba( $front-danger, 0.1 );
color: $front-danger;
}
.chat-messages
{
flex-grow: 1;
min-height: 0;
flex-direction: column;
overflow-y: scroll;
overflow-x: hidden;
padding: $front-inset;
gap: 18px;
}
.chat-empty
{
flex-grow: 1;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 12px;
color: $front-secondary;
}
.chat-empty icon
{
font-size: 40px;
}
}
.chat-message
{
gap: 12px;
flex-shrink: 0;
.chat-avatar
{
@include front-row-media;
cursor: pointer;
border-radius: 50%;
}
.chat-message-body
{
flex-direction: column;
min-width: 0;
flex-grow: 1;
gap: 6px;
word-break: normal;
}
.chat-name
{
align-self: flex-start;
cursor: pointer;
font-weight: 600;
color: $front-heading;
font-size: 13px;
&:hover
{
color: $front-link;
}
}
}
.chat-members
{
width: 200px;
min-width: 200px;
max-width: 200px;
min-height: 0;
border-left: 1px solid $front-border;
flex-shrink: 0;
flex-direction: column;
padding: $front-inset;
gap: 16px;
.people-heading
{
@include front-section-label;
justify-content: space-between;
flex-shrink: 0;
}
.chat-people
{
// Fill the side column and let the virtual list own its scrolling.
height: 0;
width: 100%;
flex-grow: 1;
min-height: 0;
gap: 0;
}
.chat-person
{
width: 100%;
height: 44px;
align-items: center;
gap: 10px;
padding: 6px;
flex-shrink: 0;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
.chat-avatar
{
@include front-row-media;
width: 28px;
height: 28px;
cursor: pointer;
border-radius: 50%;
}
> label
{
width: 0;
flex-grow: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover
{
background-color: rgba( 255, 255, 255, 0.05 );
sound-in: "ui.button.over";
}
&:active
{
sound-in: "ui.button.press";
}
}
}
.chat-composer-container
{
flex-direction: column;
flex-shrink: 0;
.chat-composer
{
margin: 0 $front-inset;
gap: 8px;
flex-shrink: 0;
}
.chat-composer textentry
{
width: 0;
min-width: 0;
flex-grow: 1;
padding: 14px;
background-color: $front-card-deep;
border: 1px solid $front-border;
border-radius: $front-radius;
&:focus
{
border-color: $front-primary;
}
}
.chat-actions
{
flex-wrap: wrap;
padding: $front-inset;
gap: 12px;
align-items: center;
flex-shrink: 0;
font-size: 13px;
color: $front-secondary;
}
button
{
cursor: pointer;
&:hover
{
sound-in: "ui.button.over";
}
&:active
{
sound-in: "ui.button.press";
}
}
.chat-actions button
{
@include front-pill;
iconpanel
{
font-size: 14px;
}
}
.chat-composer button
{
@include front-icon-button;
align-self: center;
}
button.disabled
{
opacity: 0.3;
pointer-events: none;
}
}
.party-invitation
{
align-self: flex-start;
max-width: 100%;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 16px;
padding: 12px;
border: 1px solid $front-border;
border-radius: $front-radius;
background-color: $front-card-deep;
font-family: $front-font;
word-break: normal;
.invitation-summary
{
flex-direction: column;
flex-shrink: 1;
min-width: 0;
max-width: 100%;
gap: 4px;
}
.invitation-name
{
color: $front-heading;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.invitation-details
{
color: $front-secondary;
font-size: 12px;
}
button
{
@include front-pill;
cursor: pointer;
&:hover
{
sound-in: "ui.button.over";
}
&:active
{
sound-in: "ui.button.press";
}
}
button.primary
{
background-color: $front-primary;
border-color: $front-primary;
label
{
color: $front-heading;
}
&:hover
{
background-color: lighten( $front-primary, 8% );
}
}
button.disabled
{
opacity: 0.3;
pointer-events: none;
}
}
.editor-global-chat { width: 100%; height: 100%; min-width: 0; border-radius: 0; font-size: 13px; pointer-events: all; }
.chat-members { width: 184px; min-width: 184px; max-width: 184px; }
.chat-members .chat-people { flex-direction: column; overflow-y: scroll; overflow-x: hidden; }
.chat-settings { padding: 12px 16px; gap: 16px; flex-wrap: wrap; flex-shrink: 0; border-bottom: 1px solid $front-border; font-size: 12px; }
.chat-settings checkbox { gap: 8px; align-items: center; cursor: pointer; }
.chat-settings checkbox .checkmark { width: 16px; height: 16px; border: 1px solid $front-secondary; border-radius: 3px; margin-right: 6px; }
.chat-settings checkbox.checked .checkmark { background-color: $front-primary; border-color: $front-primary; }
.editor-global-chat::after { display: none; background-image: none; }
.editor-global-chat .chat-grain { position: absolute; left: 0; top: 0; right: 0; bottom: 0; background-repeat: repeat; background-size: 512px 512px; opacity: $front-grain-opacity; pointer-events: none; }
.editor-global-chat .global-conversation, .editor-global-chat .chat-members { z-index: 1; }
.editor-global-chat icon { font-family: "Material Icons"; }
.chat-settings checkbox .checkmark { font-family: "Material Icons"; font-size: 14px; color: transparent; }
.chat-settings checkbox.checked .checkmark { color: $front-heading; }
.chat-user-dismiss { position: absolute; left: 0; top: 0; right: 0; bottom: 0; z-index: 10; pointer-events: all; }
.chat-user-actions
{
position: absolute; left: 8px; top: 8px; z-index: 11;
width: 224px; padding: 8px; flex-direction: column; gap: 2px;
background-color: $front-card-deep; border: 1px solid $front-border; border-radius: $front-radius;
box-shadow: 0px 8px 24px rgba( black, 0.45 );
.chat-user-heading { align-items: center; padding: 4px 6px 8px; border-bottom: 1px solid $front-border; margin-bottom: 4px; }
.chat-user-heading > label { flex-grow: 1; flex-shrink: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; color: $front-heading; }
button { padding: 9px 6px; gap: 10px; align-items: center; border-radius: 4px; cursor: pointer; font-size: 12px; }
button:hover { background-color: rgba( $front-primary, 0.16 ); color: $front-heading; }
button.disabled { opacity: 0.35; pointer-events: none; }
button icon { font-size: 16px; color: $front-secondary; }
.chat-user-heading button { padding: 2px; }
}
""";
}
Editor
library
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Rendering
@using Sandbox
@using Sandbox.UI
@using System
@using System.Linq
@namespace Editor.GlobalChat
@inherits Panel
<root class="editor-global-chat">
<div class="chat-grain" @ref="grain"></div>
<div class="global-conversation">
<div id="chat-conversation-heading" class="conversation-heading">
<label>@(Session.Channel is null ? "Connecting to global chat…" : $"{Session.Channel.MemberCount} here · say hello or share your party")</label>
<Sandbox.UI.Button Icon="refresh" onclick=@Reconnect tooltip="Reconnect to global chat" />
<Sandbox.UI.Button id="chat-settings-toggle" Icon="settings" onclick=@ToggleSettings tooltip="Mention settings" />
<Sandbox.UI.Button id="chat-members-toggle" class=@(MembersVisible ? "active" : "") Icon="group" onclick=@(() => MembersVisible = !MembersVisible) tooltip=@(MembersVisible ? "Hide users in chat" : "Show users in chat") />
</div>
@if ( settingsOpen )
{
<div class="chat-settings">
<Sandbox.UI.Checkbox id="chat-popups" [email protected] ValueChanged=@((bool value) => { GlobalChatWindow.MentionPopups = value; })>Mention popups</Sandbox.UI.Checkbox>
<Sandbox.UI.Checkbox id="chat-sounds" [email protected] ValueChanged=@((bool value) => { GlobalChatWindow.MentionSounds = value; })>Mention sound</Sandbox.UI.Checkbox>
</div>
}
@if ( Session.Connecting ) { <div class="chat-notice">Joining global chat…</div> }
@if ( !string.IsNullOrEmpty( Session.Error ?? Host.PartyError ) ) { <div class="chat-notice error">@(Session.Error ?? Host.PartyError)</div> }
<div class="chat-messages" @ref="messageList">
@if ( DisplayMessages.Count == 0 )
{
<div class="chat-empty"><icon>forum</icon><label>Say hello. Find your next group.</label></div>
}
@foreach ( var message in DisplayMessages.Where( x => !x.Sender.IsBlocked ) )
{
<div class="chat-message" @key=message.Id>
<div class="chat-avatar" style="background-image: url(avatar:@message.Sender.Id)" onclick=@((PanelEvent e) => ShowUserActions( message.Sender, e.This )) onrightclick=@(() => InsertMention( message.Sender ))></div>
<div class="chat-message-body">
<label id=@($"chat-name-{message.Id}") class="chat-name" onclick=@((PanelEvent e) => ShowUserActions( message.Sender, e.This )) onrightclick=@(() => InsertMention( message.Sender ))>@message.Sender.Name</label>
@if ( message.PartyId == 0 ) { <label>@message.Text</label> }
else
{
var entry = Session.GetInvitation( message.PartyId );
<div class="party-invitation">
<div class="invitation-summary">
<label class="invitation-name">@(entry?.Name ?? "Party invitation")</label>
<label class="invitation-details">@(string.IsNullOrWhiteSpace( entry?.GameTitle ) ? "Deciding together" : entry.GameTitle)</label>
<label class="invitation-details">@(entry is null ? "Checking party…" : !entry.Available ? "Party unavailable · ended or could not be reached" : $"{entry.Members}/{entry.Capacity} players · {(entry.IsFull ? "Party full" : entry.IsPlaying ? "In game" : "Looking for players")}")</label>
</div>
<Sandbox.UI.Button class="primary" disabled=@(!CanJoin( message.PartyId )) onclick=@(() => JoinParty( message.PartyId )) tooltip="Leave your current party before joining another.">@(PartyRoom.Current?.Id.ValueUnsigned == message.PartyId ? "Your party" : "Join party")</Sandbox.UI.Button>
</div>
}
</div>
</div>
}
</div>
<div class="chat-composer-container">
<div class="chat-composer">
<Sandbox.UI.TextEntry id="chat-input" @ref="input" [email protected] OnTextEdited=@SaveDraft MaxLength=@(400) Placeholder="Say hello…" onsubmit=@Submit />
<Sandbox.UI.Button Icon="send" disabled=@(!Session.CanSend) onclick=@Submit tooltip="Send message" />
</div>
<div class="chat-actions">
@if ( PartyRoom.Current is { } party )
{
<label>@party.MemberCount in your party</label>
@if ( party.Owner.IsMe ) { <Sandbox.UI.Button Icon="campaign" disabled=@(!Session.CanInviteParty) onclick=@InviteParty tooltip="Post an invitation so anyone in global chat can join">Post party invite</Sandbox.UI.Button> }
}
</div>
</div>
</div>
@if ( MembersVisible )
{
<div id="chat-member-list" class="chat-members">
<div class="people-heading"><label>In chat</label><label>@(Session.Channel?.MemberCount ?? 0)</label></div>
<div class="chat-people">
@foreach ( var member in Members )
{
<button id=@($"chat-member-{member.Id}") class="chat-person" @key=member.Id onclick=@((PanelEvent e) => ShowUserActions( member, e.This )) onrightclick=@(() => InsertMention( member )) tooltip="User actions. Right-click to add a mention.">
<div class="chat-avatar" style="background-image: url(avatar:@member.Id)"></div><label>@member.Name</label>
</button>
}
</div>
</div>
}
@if ( actionUser is { } user )
{
<div id="chat-user-dismiss" class="chat-user-dismiss" onclick=@DismissUserActions onrightclick=@DismissUserActions></div>
<div id="chat-user-actions" class="chat-user-actions" @ref="userActions">
<div class="chat-user-heading"><label>@user.Name</label><Sandbox.UI.Button Icon="close" onclick=@DismissUserActions tooltip="Close user actions" /></div>
<Sandbox.UI.Button id="chat-user-steam" Icon="open_in_new" onclick=@(() => OpenProfile( $"https://steamcommunity.com/profiles/{user.Id}" ))>Steam profile</Sandbox.UI.Button>
<Sandbox.UI.Button id="chat-user-website" Icon="language" onclick=@(() => OpenProfile( $"https://sbox.game/u/{user.Id}" ))>s&box website profile</Sandbox.UI.Button>
<Sandbox.UI.Button id="chat-user-mention" Icon="alternate_email" disabled=@(input.Text.Length + GetMentionSuffix( user ).Length > 400) tooltip="Add a mention to the draft. Make space first if the message is full." onclick=@(() => InsertMention( user ))>Mention in chat</Sandbox.UI.Button>
</div>
}
</root>
@code
{
internal GlobalChatSession Session { get; set; }
internal GlobalChatWindow Host { get; set; }
internal System.Collections.Generic.List<GlobalChatSession.ChatLine> PreviewMessages { get; set; }
System.Collections.Generic.List<GlobalChatSession.ChatLine> DisplayMessages => PreviewMessages ?? Session.Messages;
Panel messageList;
Panel grain;
Panel userActions;
Friend? actionUser;
Vector2 actionAnchor;
Sandbox.UI.TextEntry input;
bool settingsOpen, scrollAfterRender;
bool membersVisible = EditorCookie.Get( "GlobalChat.MembersVisible", true );
internal bool MembersVisible
{
get => membersVisible;
set
{
if ( membersVisible == value ) return;
membersVisible = value;
EditorCookie.Set( "GlobalChat.MembersVisible", value );
DismissUserActions();
}
}
internal bool SettingsOpen
{
get => settingsOpen;
set { settingsOpen = value; StateHasChanged(); }
}
int renderedRevision = -1;
Friend[] Members => Session.Channel?.Members.Where( x => !x.IsBlocked ).OrderBy( x => x.Name, StringComparer.OrdinalIgnoreCase ).ToArray() ?? [];
public GlobalChatView()
{
AcceptsFocus = true;
StyleSheet.AddInline( GlobalChatTheme.Styles, "editor-global-chat-theme" );
}
public override void OnHotloaded()
{
using var scope = Session.EnterMenuScope();
// Recreate Razor references and event closures after a template change.
EngineChatBridge.Invoke( EngineChatBridge.RequireMethod( typeof( Panel ), "ClearRenderTree" ), this );
base.OnHotloaded();
StyleSheet.AddInline( GlobalChatTheme.Styles, "editor-global-chat-theme" );
renderedRevision = -1;
StateHasChanged();
}
void SaveDraft( string text )
{
Session.Draft = text;
EditorCookie.Set( "GlobalChat.Draft", text );
}
void Submit()
{
using var scope = Session.EnterMenuScope();
if ( Session.Send( input.Text ) )
{
input.Text = "";
SaveDraft( "" );
ScrollToLatest();
}
input.Focus();
}
internal void ScrollToLatest()
{
scrollAfterRender = true;
StateHasChanged();
}
internal void ShowUserActions( Friend friend, Panel source = null )
{
actionUser = friend;
actionAnchor = source is null ? new Vector2( Box.Rect.Width - 232, 64 ) * ScaleFromScreen
: (source.Box.Rect.BottomLeft - Box.Rect.Position) * ScaleFromScreen;
Focus();
StateHasChanged();
}
internal void DismissUserActions()
{
actionUser = null;
StateHasChanged();
}
void OpenProfile( string url )
{
System.Diagnostics.Process.Start( new System.Diagnostics.ProcessStartInfo( url ) { UseShellExecute = true } );
DismissUserActions();
}
public override void OnButtonTyped( ButtonEvent e )
{
if ( e.Button != "escape" || actionUser is null ) { base.OnButtonTyped( e ); return; }
DismissUserActions();
input.Focus();
e.StopPropagation = true;
}
void ToggleSettings() { settingsOpen = !settingsOpen; StateHasChanged(); }
void Reconnect() { Session.Close(); Session.Open(); }
void InviteParty()
{
using var scope = Session.EnterMenuScope();
Session.InviteParty();
}
bool CanJoin( ulong id ) => !Host.PartyBusy && PartyRoom.Current is null && Session.GetInvitation( id ) is { Available: true, IsFull: false };
void JoinParty( ulong id ) { _ = Host.JoinParty( id ); }
void InsertMention( Friend member )
{
var mention = GetMentionSuffix( member );
// Keep the draft intact when the full mention cannot fit.
if ( input.Text.Length + mention.Length > 400 ) return;
input.Text += mention;
SaveDraft( input.Text );
DismissUserActions();
input.Focus();
}
string GetMentionSuffix( Friend member )
{
var separator = input.Text.Length == 0 || char.IsWhiteSpace( input.Text[^1] ) ? "" : " ";
return separator + "@" + member.Name + " ";
}
public override void Tick()
{
base.Tick();
if ( actionUser is null || !userActions.IsValid() ) return;
var bounds = Box.Rect.Size * ScaleFromScreen;
var size = userActions.Box.Rect.Size * ScaleFromScreen;
userActions.Style.Left = Math.Clamp( actionAnchor.x, 8, Math.Max( 8, bounds.x - size.x - 8 ) );
userActions.Style.Top = Math.Clamp( actionAnchor.y + 4, 8, Math.Max( 8, bounds.y - size.y - 8 ) );
}
protected override void OnAfterTreeRender( bool firstTime )
{
base.OnAfterTreeRender( firstTime );
// SCSS url() textures load lazily in the drawing context. Resolve this menu asset
// explicitly so editor and play-mode frames keep the same background texture.
using var scope = Session.EnterMenuScope();
grain.Style.BackgroundImage = Texture.Load( "/ui/panel-paper.png" );
messageList.PreferScrollToBottom = true;
if ( firstTime || scrollAfterRender || (renderedRevision != Session.Revision && messageList.IsScrollAtBottom) ) messageList.TryScrollToBottom();
renderedRevision = Session.Revision;
scrollAfterRender = false;
}
protected override int BuildHash()
{
var members = new HashCode();
foreach ( var member in Members ) { members.Add( member.Id ); members.Add( member.Name ); }
return HashCode.Combine( HashCode.Combine( Session.Revision, PreviewMessages ), Session.InvitationRevision, Session.Connecting, Session.Error, Host.PartyError,
HashCode.Combine( Session.CanSend, Session.CanInviteParty, PartyRoom.Current?.Id, PartyRoom.Current?.MemberCount, PartyRoom.Current?.Owner.Id, Host.PartyBusy ),
members.ToHashCode(), HashCode.Combine( settingsOpen, MembersVisible, GlobalChatWindow.MentionPopups, GlobalChatWindow.MentionSounds ) );
}
}
Editor
library
using Sandbox;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Reflection;
using System.Runtime.InteropServices;
namespace Editor.GlobalChat;
/// <summary>Routes keys from the focused child before Qt applies editor shortcuts.</summary>
internal sealed class ChatDockKeyboardInput : IDisposable, IHotloadManaged
{
const int GetMessageHook = 3;
readonly IntPtr window;
readonly IntPtr sdlHandle;
readonly GlobalChatWindow chat;
readonly MethodInfo translateKey;
readonly MethodInfo keyInput;
readonly MethodInfo textInput;
readonly MethodInfo focusInput;
readonly object surface;
readonly object uiSystem;
readonly MethodInfo tickInput;
readonly PropertyInfo mouseInside;
// Windows retains a function pointer, so the CLR must retain its delegate too.
[SkipHotload] IntPtr callbackRoot;
IntPtr hook;
internal ChatDockKeyboardInput( IntPtr window, IntPtr sdlHandle, GlobalChatWindow chat )
{
this.window = window;
this.sdlHandle = sdlHandle;
this.chat = chat;
var assembly = typeof( Friend ).Assembly;
var input = EngineChatBridge.RequireType( assembly, "Sandbox.UI.PanelWindowInput" );
var buttons = EngineChatBridge.RequireType( assembly, "NativeEngine.ButtonCode" );
translateKey = EngineChatBridge.RequireMethod( EngineChatBridge.RequireType( assembly, "Sandbox.Engine.KeyTranslation" ), "VirtualKeyToButtonCode", typeof( int ) );
keyInput = EngineChatBridge.RequireMethod( input, "OnKey", typeof( IntPtr ), buttons, typeof( bool ), typeof( KeyboardModifiers ) );
textInput = EngineChatBridge.RequireMethod( input, "OnText", typeof( IntPtr ), typeof( string ) );
focusInput = EngineChatBridge.RequireMethod( input, "OnFocus", typeof( IntPtr ), typeof( bool ) );
surface = EngineChatBridge.RequireProperty( typeof( PanelWindow ), "Surface" ).GetValue( chat );
uiSystem = EngineChatBridge.RequireProperty( surface.GetType(), "System" ).GetValue( surface );
tickInput = EngineChatBridge.RequireMethod( uiSystem.GetType(), "TickSurfaceInput", typeof( bool ) );
mouseInside = EngineChatBridge.RequireProperty( surface.GetType(), "MouseInside" );
Install();
}
internal void FocusChanged( bool focused )
{
using var scope = chat.Session.EnterMenuScope();
EngineChatBridge.Invoke( focusInput, null, sdlHandle, focused );
}
void Install()
{
MessageHook callback = OnMessage;
callbackRoot = GCHandle.ToIntPtr( GCHandle.Alloc( callback ) );
hook = SetWindowsHookEx( GetMessageHook, callback, IntPtr.Zero, GetWindowThreadProcessId( window, out _ ) );
if ( hook == IntPtr.Zero )
{
var error = Marshal.GetLastPInvokeError();
GCHandle.FromIntPtr( callbackRoot ).Free();
callbackRoot = IntPtr.Zero;
throw new Win32Exception( error, "Could not install chat keyboard input." );
}
}
IntPtr OnMessage( int code, UIntPtr removed, IntPtr pointer )
{
// Observe only removed messages for this focused child. Other editor controls
// and other applications retain their normal keyboard handling.
if ( code >= 0 && removed == (UIntPtr)1 && GetFocus() == window )
{
try
{
var message = Marshal.PtrToStructure<NativeMessage>( pointer );
if ( message.Window == window && message.Message is 0x0100 or 0x0101 or 0x0102 or 0x0104 or 0x0105 or 0x0106 )
{
using var scope = chat.Session.EnterMenuScope();
if ( message.Message is 0x0102 or 0x0106 )
{
var character = (char)message.Key.ToUInt64();
if ( !char.IsControl( character ) ) EngineChatBridge.Invoke( textInput, null, sdlHandle, character.ToString() );
}
else
{
var key = (int)message.Key.ToUInt64();
var modifiers = KeyboardModifiers.None;
if ( (GetKeyState( 0x10 ) & 0x8000) != 0 ) modifiers |= KeyboardModifiers.Shift;
if ( (GetKeyState( 0x11 ) & 0x8000) != 0 ) modifiers |= KeyboardModifiers.Ctrl;
if ( (GetKeyState( 0x12 ) & 0x8000) != 0 ) modifiers |= KeyboardModifiers.Alt;
// Leave the operating system's close command intact.
if ( key == 0x73 && modifiers.HasFlag( KeyboardModifiers.Alt ) ) return CallNextHookEx( hook, code, removed, pointer );
var down = message.Message is 0x0100 or 0x0104;
if ( down ) TranslateMessage( ref message );
// VK_PACKET carries Unicode text, which arrives in WM_CHAR.
if ( key != 0xE7 ) EngineChatBridge.Invoke( keyInput, null, sdlHandle, EngineChatBridge.Invoke( translateKey, null, key ), down, modifiers );
}
// Preserve native event order for arrows, selection and typing in one
// frame. Use the engine's input tick without another render or layout.
EngineChatBridge.Invoke( tickInput, uiSystem, (bool)mouseInside.GetValue( surface ) );
message.Message = 0; // WM_NULL prevents duplicate SDL or Qt delivery.
Marshal.StructureToPtr( message, pointer, false );
}
}
catch ( Exception e ) { Log.Error( e, "Global chat native keyboard input failed." ); }
}
return CallNextHookEx( hook, code, removed, pointer );
}
public void Dispose()
{
if ( hook == IntPtr.Zero ) return;
if ( !UnhookWindowsHookEx( hook ) ) throw new Win32Exception( Marshal.GetLastPInvokeError(), "Could not remove chat keyboard input." );
hook = IntPtr.Zero;
GCHandle.FromIntPtr( callbackRoot ).Free();
callbackRoot = IntPtr.Zero;
}
void IHotloadManaged.Destroyed( Dictionary<string, object> state )
{
state["Installed"] = hook != IntPtr.Zero;
Dispose();
}
void IHotloadManaged.Created( IReadOnlyDictionary<string, object> state )
{
// Fields were copied before Destroyed removed the old native registration.
hook = IntPtr.Zero;
callbackRoot = IntPtr.Zero;
if ( state.TryGetValue( "Installed", out var installed ) && installed is true ) Install();
}
void IHotloadManaged.Persisted()
{
if ( hook == IntPtr.Zero ) return;
Dispose();
Install();
}
void IHotloadManaged.Failed()
{
Dispose();
}
[StructLayout( LayoutKind.Sequential )]
struct NativeMessage
{
internal IntPtr Window;
internal uint Message;
internal UIntPtr Key;
internal IntPtr Parameter;
internal uint Time;
internal int X, Y;
internal uint Private;
}
[UnmanagedFunctionPointer( CallingConvention.Winapi )]
delegate IntPtr MessageHook( int code, UIntPtr removed, IntPtr pointer );
[DllImport( "user32.dll", EntryPoint = "SetWindowsHookExW", SetLastError = true )]
static extern IntPtr SetWindowsHookEx( int type, MessageHook callback, IntPtr module, uint thread );
[DllImport( "user32.dll", SetLastError = true )] static extern bool UnhookWindowsHookEx( IntPtr hook );
[DllImport( "user32.dll" )] static extern IntPtr CallNextHookEx( IntPtr hook, int code, UIntPtr removed, IntPtr pointer );
[DllImport( "user32.dll" )] static extern uint GetWindowThreadProcessId( IntPtr window, out uint process );
[DllImport( "user32.dll" )] static extern IntPtr GetFocus();
[DllImport( "user32.dll" )] static extern short GetKeyState( int key );
[DllImport( "user32.dll" )] static extern bool TranslateMessage( ref NativeMessage message );
}
Editor
library
using Sandbox;
using Sandbox.UI;
using System;
using System.Reflection;
using System.Threading.Tasks;
namespace Editor.GlobalChat;
/// <summary>Local checks through Windows input. Never activates Send or a party action.</summary>
internal static class ChatViewValidation
{
internal static async Task<object> Run( GlobalChatWindow window )
{
using var scope = window.Session.EnterMenuScope();
var popups = GlobalChatWindow.MentionPopups;
var sounds = GlobalChatWindow.MentionSounds;
var draft = window.Session.Draft;
var frame = EngineChatBridge.RequireMethod( typeof( PanelWindow ), "Frame" );
window.View.DismissUserActions();
EngineChatBridge.Invoke( frame, window );
var input = (TextEntry)Find( window.Root, "chat-input" );
var settingsWereOpen = Find( window.Root, "chat-popups" ) is not null;
var previewMessages = window.View.PreviewMessages;
var membersWereVisible = window.View.MembersVisible;
try
{
window.View.MembersVisible = true;
EngineChatBridge.Invoke( frame, window );
await Task.Delay( 150 );
var heading = Find( window.Root, "chat-conversation-heading" );
var shownWidth = heading.Box.Rect.Width;
await Click( window, Find( window.Root, "chat-members-toggle" ), frame );
if ( window.View.MembersVisible || Find( window.Root, "chat-member-list" ) is not null || heading.Box.Rect.Width <= shownWidth )
throw new InvalidOperationException( $"The user list button did not hide the list and expand chat. Visible: {window.View.MembersVisible}; list present: {Find( window.Root, "chat-member-list" ) is not null}; width: {heading.Box.Rect.Width}; prior width: {shownWidth}; mouse: {window.MousePosition}; target: {Find( window.Root, "chat-members-toggle" ).Box.Rect}." );
await Click( window, Find( window.Root, "chat-members-toggle" ), frame );
if ( !window.View.MembersVisible || Find( window.Root, "chat-member-list" ) is null )
throw new InvalidOperationException( "The user list button did not show the list." );
if ( !settingsWereOpen ) await Click( window, Find( window.Root, "chat-settings-toggle" ), frame );
await Click( window, Find( window.Root, "chat-popups" ), frame );
if ( GlobalChatWindow.MentionPopups == popups || GlobalChatWindow.MentionSounds != sounds ) throw new InvalidOperationException( "Popup checkbox did not change independently." );
await Click( window, Find( window.Root, "chat-sounds" ), frame );
if ( GlobalChatWindow.MentionSounds == sounds ) throw new InvalidOperationException( "Sound checkbox did not change." );
input.Text = "";
window.Session.Draft = "";
await Click( window, input, frame );
ChatNativeInputValidation.TypeUnicode( "Local input check" );
EngineChatBridge.Invoke( frame, window );
await Task.Delay( 100 );
if ( input.Text != "Local input check" || window.Session.Draft != input.Text ) throw new InvalidOperationException( "Typing did not reach the Razor draft binding." );
var me = new Friend( Game.SteamId );
await Click( window, Find( window.Root, $"chat-member-{me.Id}" ), frame );
var menu = Find( window.Root, "chat-user-actions" );
if ( menu is null || Find( menu, "chat-user-steam" ) is null || Find( menu, "chat-user-website" ) is null )
throw new InvalidOperationException( "The user menu does not contain both profile actions." );
var bounds = window.View.Box.Rect;
if ( menu.Box.Rect.Left < bounds.Left || menu.Box.Rect.Right > bounds.Right || menu.Box.Rect.Top < bounds.Top || menu.Box.Rect.Bottom > bounds.Bottom )
throw new InvalidOperationException( "The user menu extends outside chat." );
await Click( window, Find( menu, "chat-user-mention" ), frame );
if ( input.Text != $"Local input check @{me.Name} " || window.Session.Draft != input.Text || Find( window.Root, "chat-user-actions" ) is not null )
throw new InvalidOperationException( "The mention action did not update the draft and close the menu." );
await Click( window, Find( window.Root, $"chat-member-{me.Id}" ), frame );
await Click( window, Find( window.Root, "chat-user-dismiss" ), frame );
if ( Find( window.Root, "chat-user-actions" ) is not null ) throw new InvalidOperationException( "Clicking outside did not close the user menu." );
var message = new GlobalChatSession.ChatLine( me, "Local user action check", 0, Guid.NewGuid() );
window.View.PreviewMessages = [message];
EngineChatBridge.Invoke( frame, window );
await Click( window, Find( window.Root, $"chat-name-{message.Id}" ), frame );
if ( Find( window.Root, "chat-user-actions" ) is null ) throw new InvalidOperationException( "Clicking a message name did not open the menu." );
window.View.DismissUserActions();
input.Text = new string( 'x', 400 );
window.Session.Draft = input.Text;
EngineChatBridge.Invoke( frame, window );
await Task.Delay( 100 );
EngineChatBridge.Invoke( frame, window );
await Click( window, Find( window.Root, $"chat-name-{message.Id}" ), frame );
var mentionButton = Find( window.Root, "chat-user-mention" )
?? throw new InvalidOperationException( "The message name did not reopen the user menu." );
if ( !mentionButton.HasClass( "disabled" ) || input.Text.Length != 400 )
throw new InvalidOperationException( "The full draft does not disable mention insertion." );
ChatNativeInputValidation.PressKey( 0x1B );
EngineChatBridge.Invoke( frame, window );
await Task.Delay( 100 );
EngineChatBridge.Invoke( frame, window );
if ( Find( window.Root, "chat-user-actions" ) is not null ) throw new InvalidOperationException( "Escape did not close the user menu." );
var font = input.ComputedStyle.FontFamily;
return new { MemberListToggle = true, PopupToggle = true, SoundToggle = true, TypedDraft = true, UserMenu = true, MenuInBounds = true, MessageNameClick = true, MentionDraft = true, FullDraftPreserved = true, OutsideDismiss = true, EscapeDismiss = true, Font = font, SentMessages = 0, OpenedExternalProfiles = 0 };
}
finally
{
window.View.DismissUserActions();
window.View.PreviewMessages = previewMessages;
window.View.MembersVisible = membersWereVisible;
GlobalChatWindow.MentionPopups = popups;
GlobalChatWindow.MentionSounds = sounds;
input.Text = draft;
window.Session.Draft = draft;
EditorCookie.Set( "GlobalChat.Draft", draft );
EngineChatBridge.Invoke( frame, window );
if ( !settingsWereOpen && Find( window.Root, "chat-popups" ) is not null ) await Click( window, Find( window.Root, "chat-settings-toggle" ), frame );
}
}
internal static Panel Find( Panel root, string id )
{
if ( root.Id == id ) return root;
foreach ( var child in root.Children )
{
var found = Find( child, id );
if ( found is not null ) return found;
}
return null;
}
static async Task Click( GlobalChatWindow window, Panel target, MethodInfo frame )
{
await ChatNativeInputValidation.Click( window, target );
EngineChatBridge.Invoke( frame, window );
}
}
Editor
library
using Sandbox;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace Editor.Mcp;
[McpToolset( "editor_global_chat", "Editor global chat connection and local validation. Never sends chat messages." )]
public static class GlobalChatMcpTools
{
/// <summary>Checks a real Windows click, focus, Unicode typing, Backspace, Home, Ctrl+A, Ctrl+V and focus release. Uses existing clipboard text without changing the clipboard or returning its contents. Brings the editor forward, then restores draft, cursor and prior editor focus. Release modifiers first. Never presses Enter or sends chat. Use read_console afterwards.</summary>
[McpTool( "editor_global_chat_validate_keyboard" )]
public static async Task<object> ValidateKeyboard()
{
GlobalChat.GlobalChatWindow.Open();
await Task.Delay( 150 );
return await GlobalChat.ChatNativeInputValidation.Run( GlobalChat.GlobalChatWindow.Current );
}
/// <summary>Opens chat, then floats its editor dock or places it at the bottom. Keeps the same chat session and draft. Use editor_global_chat_status and editor_global_chat_capture next. Does not send a message.</summary>
[McpTool( "editor_global_chat_dock" )]
public static ChatStatus SetDock( bool floating = false )
{
GlobalChat.GlobalChatWindow.Open();
var dock = GlobalChat.GlobalChatDock.Dock;
if ( floating ) dock.Float();
else EditorWindow.DockManager.OpenDock( "Global Chat", DockArea.Bottom );
((GlobalChat.GlobalChatDock)dock.Widget).Synchronize();
return Status();
}
/// <summary>Checks floating and docking with the same surface, connection and draft, then restores the editor layout in all cases. Requires joined chat. Forces C# memory collection to check native callback lifetime, then runs local Windows input checks in both states. Does not send chat or open profiles. Use read_console afterwards.</summary>
[McpTool( "editor_global_chat_validate_docking" )]
public static async Task<object> ValidateDocking()
{
GlobalChat.GlobalChatWindow.Open();
var window = GlobalChat.GlobalChatWindow.Current;
if ( window.Session.Channel is not { IsJoined: true } ) throw new InvalidOperationException( "Wait for global chat to join before checking docking." );
var layout = EditorWindow.DockManager.State;
var channel = window.Session.Channel.Id;
var draft = window.Session.Draft;
try
{
SetDock( true );
// Keep the native input check on-screen, then restore the saved layout below.
GlobalChat.GlobalChatDock.ResizeFloating( new Vector2( 720, 520 ) );
var floatingWindow = ((GlobalChat.GlobalChatDock)GlobalChat.GlobalChatDock.Dock.Widget).GetWindow();
floatingWindow.Position = floatingWindow.ScreenGeometry.Position + (floatingWindow.ScreenGeometry.Size - floatingWindow.Size) * 0.5f;
await Task.Delay( 250 );
var floating = Status();
if ( !floating.Floating || !floating.SurfaceAttached ) throw new InvalidOperationException( "The chat surface did not stay attached when floating." );
// Native callbacks must remain rooted even when the CLR collects delegates.
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
await GlobalChat.ChatViewValidation.Run( window );
await GlobalChat.ChatNativeInputValidation.Run( window );
SetDock( false );
await Task.Delay( 250 );
var docked = Status();
if ( !docked.Docked || !docked.SurfaceAttached ) throw new InvalidOperationException( "The chat surface did not stay attached when docking." );
await GlobalChat.ChatViewValidation.Run( window );
await GlobalChat.ChatNativeInputValidation.Run( window );
if ( GlobalChat.GlobalChatWindow.Current != window || window.Session.Channel?.Id != channel || window.Session.Draft != draft )
throw new InvalidOperationException( "Docking changed the chat session or draft." );
return new { Floating = true, Docked = true, SurfaceAttached = true, SameSession = true, DraftPreserved = true, InputInBothStates = true, SentMessages = 0 };
}
finally
{
if ( !EditorWindow.DockManager.RestoreState( layout ) ) throw new InvalidOperationException( "The editor could not restore its dock layout." );
((GlobalChat.GlobalChatDock)GlobalChat.GlobalChatDock.Dock.Widget).Synchronize();
}
}
/// <summary>Checks settings, draft typing, member menu, mention insertion and outside dismissal through window input. Restores draft and preferences. Requires joined chat. Never activates Send, external profiles or party actions. Use editor_global_chat_capture to review the layout next.</summary>
[McpTool( "editor_global_chat_validate_view" )]
public static async Task<object> ValidateView()
{
GlobalChat.GlobalChatWindow.Open();
return await GlobalChat.ChatViewValidation.Run( GlobalChat.GlobalChatWindow.Current );
}
/// <summary>Captures the actual Razor chat surface. Optional size (540–1800 wide, 420–1400 high) requires a floating dock; omit both dimensions when docked. previewMessages and showSettings apply only to this capture. Does not change history or send chat. Open chat first; use read_console after capture.</summary>
[McpTool( "editor_global_chat_capture" )]
public static McpResult Capture( int width = 0, int height = 0, bool previewMessages = false, bool showSettings = false, bool? showMembers = null )
{
var window = GlobalChat.GlobalChatWindow.Current;
if ( window is not { IsOpen: true } ) throw new InvalidOperationException( "Open Global Chat before capturing it." );
if ( width != 0 || height != 0 )
{
if ( width < 540 || width > 1800 || height < 420 || height > 1400 ) throw new ArgumentOutOfRangeException( nameof( width ), "Use a width of 540–1800 and a height of 420–1400, or leave both zero." );
GlobalChat.GlobalChatDock.ResizeFloating( new Vector2( width, height ) );
}
var settingsWereOpen = window.View.SettingsOpen;
var membersWereVisible = window.View.MembersVisible;
try
{
if ( showSettings ) window.View.SettingsOpen = true;
if ( showMembers.HasValue ) window.View.MembersVisible = showMembers.Value;
if ( previewMessages )
{
var me = new Friend( Game.SteamId );
window.View.PreviewMessages = [
new( me, "Local layout preview. This message is not sent to global chat.", 0, Guid.NewGuid() ),
new( me, "A second sample checks the shared message style and text wrapping when the editor window is narrow. The avatar, name, and message must stay aligned.", 0, Guid.NewGuid() ) ];
}
using var bitmap = GlobalChat.ChatWindowCapture.Capture( window, window.Session );
return McpResult.Image( bitmap );
}
finally { window.View.PreviewMessages = null; window.View.SettingsOpen = settingsWereOpen; window.View.MembersVisible = membersWereVisible; }
}
/// <summary>Creates a temporary private Steam party, joins it through the editor reflection path, verifies one local member, then leaves in all cases. Refuses when already in a party. Sends no chat message or invitation.</summary>
[McpTool( "editor_global_chat_validate_private_party" )]
public static async Task<object> ValidatePrivateParty()
{
var members = await new GlobalChat.EngineChatBridge().ValidatePrivatePartyAsync();
return new { Joined = true, Members = members, Left = PartyRoom.Current is null };
}
/// <summary>Captures chat with the inline user action menu. Use editor_global_chat_preview_profile first. Does not open a browser or send a message.</summary>
[McpTool( "editor_global_chat_capture_profile" )]
public static McpResult CaptureProfile()
{
var window = GlobalChat.GlobalChatWindow.Current;
if ( window is not { IsOpen: true } ) throw new InvalidOperationException( "Open Global Chat before capturing a profile." );
using var bitmap = GlobalChat.ChatWindowCapture.Capture( window, window.Session );
return McpResult.Image( bitmap );
}
/// <summary>Opens the inline user action menu for the local user. Does not open a browser, send a message, or change party state. Use editor_global_chat_capture_profile to review it.</summary>
[McpTool( "editor_global_chat_preview_profile" )]
public static ChatStatus PreviewProfile()
{
GlobalChat.GlobalChatWindow.Open();
GlobalChat.GlobalChatWindow.Current.View.ShowUserActions( new Friend( Game.SteamId ) );
return Status();
}
/// <summary>Opens the editor global chat window and connects to the normal menu channel. Does not send a message. Call editor_global_chat_status after connection.</summary>
[McpTool( "editor_global_chat_open" )]
public static ChatStatus Open()
{
GlobalChat.GlobalChatWindow.Open();
return Status();
}
/// <summary>Returns dock/floating state, native surface attachment, connection, channel metadata, member count and alert settings. Contains no message text. Use editor_global_chat_open to connect.</summary>
[McpTool.ReadOnly( "editor_global_chat_status" )]
public static ChatStatus Status()
{
var window = GlobalChat.GlobalChatWindow.Current;
var session = window is { IsOpen: true } ? window.Session : null;
var channel = session?.Channel;
var dock = GlobalChat.GlobalChatDock.Dock;
return new()
{
Open = window is { IsOpen: true }, Connecting = session?.Connecting ?? false,
Docked = dock is not null && !dock.IsClosed && !dock.IsFloating,
Floating = dock is not null && !dock.IsClosed && dock.IsFloating,
SurfaceAttached = dock?.Widget is GlobalChat.GlobalChatDock host && host.SurfaceAttached,
Joined = channel?.IsJoined ?? false, ChannelId = channel?.Id.ToString(),
Channel = channel?.GetData( "channel" ), LobbyType = channel?.GetData( "lobby_type" ),
Dev = channel?.GetData( "dev" ), Api = channel?.GetData( "api" ), Protocol = channel?.GetData( "protocol" ),
Members = channel?.MemberCount ?? 0, Messages = session?.Messages.Count ?? 0, Error = session?.Error,
MentionPopups = GlobalChat.GlobalChatWindow.MentionPopups,
MentionSounds = GlobalChat.GlobalChatWindow.MentionSounds,
PopupsShown = window is { IsOpen: true } ? window.PopupCount : 0,
SoundsPlayed = window is { IsOpen: true } ? window.SoundCount : 0,
SoundError = window is { IsOpen: true } ? window.SoundError : null
};
}
/// <summary>Changes saved editor mention preferences. Does not affect compiler notifications or send chat. Call editor_global_chat_status to read back.</summary>
[McpTool( "editor_global_chat_alert_settings" )]
public static ChatStatus AlertSettings( bool popups, bool sounds )
{
GlobalChat.GlobalChatWindow.MentionPopups = popups;
GlobalChat.GlobalChatWindow.MentionSounds = sounds;
return Status();
}
/// <summary>Exercises the real local mention notification path with one synthetic message. Does not send to Steam or insert into chat history. Opens chat first. Tests the current saved popup and sound settings.</summary>
[McpTool( "editor_global_chat_preview_mention" )]
public static ChatStatus PreviewMention()
{
GlobalChat.GlobalChatWindow.Open();
var window = GlobalChat.GlobalChatWindow.Current;
var me = new Friend( Game.SteamId );
window.NotifyMention( new( new Friend( 76561197960265729 ), $"@{me.Name} Mention alert preview.", 0, Guid.NewGuid() ) );
return Status();
}
/// <summary>Closes the chat window and leaves its channel. Does not send a message. Draft and alert preferences stay saved.</summary>
[McpTool( "editor_global_chat_close" )]
public static ChatStatus Close()
{
GlobalChat.GlobalChatWindow.Current?.Close();
return Status();
}
}
public sealed class ChatStatus
{
public bool Open { get; set; }
public bool Docked { get; set; }
public bool Floating { get; set; }
public bool SurfaceAttached { get; set; }
public bool Connecting { get; set; }
public bool Joined { get; set; }
public string ChannelId { get; set; }
public string Channel { get; set; }
public string LobbyType { get; set; }
public string Dev { get; set; }
public string Api { get; set; }
public string Protocol { get; set; }
public int Members { get; set; }
public int Messages { get; set; }
public string Error { get; set; }
public bool MentionPopups { get; set; }
public bool MentionSounds { get; set; }
public int PopupsShown { get; set; }
/// <summary>Number of sound requests accepted by Windows. This does not verify speaker output.</summary>
public int SoundsPlayed { get; set; }
/// <summary>Failure from the last sound request, or null when Windows accepted playback. Acceptance does not verify speaker output.</summary>
public string SoundError { get; set; }
}
UnitTest
library
using Editor.GlobalChat;
using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public sealed class ChatMentionPolicyTests
{
[TestMethod]
[DataRow( "Hello @Alice", "Alice", true )]
[DataRow( "Hello @Alice.", "Alice", true )]
[DataRow( "@Alice. Next sentence", "Alice", true )]
[DataRow( "@Alice.com", "Alice", false )]
[DataRow( "@aLiCe, hello", "Alice", true )]
[DataRow( "Hello @Alice Smith!", "Alice Smith", true )]
[DataRow( "@AliceOther", "Alice", false )]
[DataRow( "mail@Alice", "Alice", false )]
[DataRow( "@@Alice", "Alice", false )]
[DataRow( "No mention", "Alice", false )]
[DataRow( "@42 hello", "Alice", true )]
[DataRow( "<@42> hello", "Alice", true )]
[DataRow( "@420 hello", "Alice", false )]
public void MatchesCompleteMentions( string text, string name, bool expected )
{
Assert.AreEqual( expected, ChatMentionPolicy.ContainsMention( text, 42, name ) );
}
[TestMethod]
public void HistorySelfBlockedAndDisabledMessagesDoNotNotify()
{
var gate = new ChatMentionGate();
Assert.IsFalse( gate.ShouldNotify( Guid.NewGuid(), 7, 42, "Alice", "@Alice", false, false, true ) );
Assert.IsFalse( gate.ShouldNotify( Guid.NewGuid(), 42, 42, "Alice", "@Alice", true, false, true ) );
Assert.IsFalse( gate.ShouldNotify( Guid.NewGuid(), 7, 42, "Alice", "@Alice", true, true, true ) );
var disabled = Guid.NewGuid();
Assert.IsFalse( gate.ShouldNotify( disabled, 7, 42, "Alice", "@Alice", true, false, false ) );
Assert.IsFalse( gate.ShouldNotify( disabled, 7, 42, "Alice", "@Alice", true, false, true ) );
Assert.IsTrue( gate.ShouldNotify( Guid.NewGuid(), 7, 42, "Alice", "@Alice", true, false, true ) );
}
[TestMethod]
public void EachAuthenticatedSenderAndMessagePairNotifiesOnce()
{
var gate = new ChatMentionGate();
var id = Guid.NewGuid();
Assert.IsTrue( gate.ShouldNotify( id, 7, 42, "Alice", "@Alice", true, false, true ) );
Assert.IsFalse( gate.ShouldNotify( id, 7, 42, "Alice", "@Alice", true, false, true ) );
Assert.IsTrue( gate.ShouldNotify( id, 8, 42, "Alice", "@Alice", true, false, true ) );
}
}
Editor
library
using System;
using System.IO;
using System.Runtime.InteropServices;
namespace Editor.GlobalChat;
/// <summary>Plays mention alerts outside the game audio mixer, including when the editor is in the background.</summary>
internal static class ChatMentionSound
{
// Use the longer editor chime: the compile-success tick is easy to miss.
internal const string SoundFile = "core/sounds/editor/published.wav";
const uint Async = 0x0001;
const uint NoDefault = 0x0002;
const uint FileName = 0x00020000;
internal static bool Play( out string error )
{
error = null;
if ( !OperatingSystem.IsWindows() )
{
error = "Editor chat sound requires Windows.";
return false;
}
var path = FileSystem.Root.GetFullPath( SoundFile );
if ( string.IsNullOrWhiteSpace( path ) || !File.Exists( path ) )
{
error = $"Editor chat sound file is missing: {SoundFile}";
return false;
}
// EditorUtility.PlayRawSound only confirms path resolution. Call Windows directly
// so a failed playback request is reported, rather than counted as a played sound.
if ( PlaySound( path, IntPtr.Zero, Async | NoDefault | FileName ) ) return true;
error = "Windows could not start the editor chat sound. Check the audio output device.";
return false;
}
[DllImport( "winmm.dll", EntryPoint = "PlaySoundW", CharSet = CharSet.Unicode, ExactSpelling = true )]
[return: MarshalAs( UnmanagedType.Bool )]
static extern bool PlaySound( string file, IntPtr module, uint flags );
}
Debug: View Raw JSON Response
{
"TotalCount": 15,
"Files": [
{
"Ident": "thedimin.global_chat",
"Path": "Editor/ChatMentionPolicy.cs",
"FileName": "ChatMentionPolicy.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 422392,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Globalization;\n\nnamespace Editor.GlobalChat;\n\n/// <summary>Matches complete @names, @SteamIds and <@SteamId> mentions, without matching email addresses.</summary>\npublic static class ChatMentionPolicy\n{\n\tpublic static bool ContainsMention( string text, ulong steamId, string displayName )\n\t{\n\t\tif ( string.IsNullOrEmpty( text ) ) return false;\n\t\tvar id = steamId.ToString( CultureInfo.InvariantCulture );\n\t\treturn (steamId != 0 && (ContainsToken( text, \"@\" + id ) || ContainsToken( text, \"<@\" + id + \">\" )))\n\t\t\t|| (!string.IsNullOrWhiteSpace( displayName ) && ContainsToken( text, \"@\" + displayName.Trim() ));\n\t}\n\n\tstatic bool ContainsToken( string text, string token )\n\t{\n\t\tfor ( int start = 0; start <= text.Length - token.Length; )\n\t\t{\n\t\t\tvar index = text.IndexOf( token, start, StringComparison.OrdinalIgnoreCase );\n\t\t\tif ( index < 0 ) return false;\n\t\t\tvar end = index + token.Length;\n\t\t\tif ( (index == 0 || !IsNameCharacter( text[index - 1] ))\n\t\t\t\t&& (end == text.Length || !IsNameCharacter( text[end] )\n\t\t\t\t\t|| (text[end] == '.' && (end + 1 == text.Length || char.IsWhiteSpace( text[end + 1] )))) ) return true;\n\t\t\tstart = index + 1;\n\t\t}\n\t\treturn false;\n\t}\n\n\tstatic bool IsNameCharacter( char value ) => char.IsLetterOrDigit( value ) || value is '_' or '@' or '-' or '.';\n}\n\n/// <summary>Consumes message identities even while alerts are disabled, so enabling alerts does not replay old messages.</summary>\npublic sealed class ChatMentionGate\n{\n\treadonly HashSet<(ulong, Guid)> seen = new();\n\treadonly Queue<(ulong, Guid)> order = new();\n\tpublic bool ShouldNotify( Guid id, ulong sender, ulong localUser, string localName, string text, bool live, bool blocked, bool enabled )\n\t{\n\t\tif ( id == Guid.Empty || sender == 0 || !seen.Add( (sender, id) ) ) return false;\n\t\torder.Enqueue( (sender, id) );\n\t\tif ( order.Count > 256 ) seen.Remove( order.Dequeue() );\n\t\treturn live && !blocked && enabled && sender != localUser && ChatMentionPolicy.ContainsMention( text, localUser, localName );\n\t}\n}\n"
},
{
"Ident": "thedimin.global_chat",
"Path": "Editor/GlobalChatDock.cs",
"FileName": "GlobalChatDock.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 422392,
"IsPrivate": false,
"Code": "using Sandbox;\nusing System;\nusing System.Linq;\n\nnamespace Editor.GlobalChat;\n\n/// <summary>Places the Razor chat surface in the editor's normal dock layout.</summary>\n[Dock( \"Editor\", \"Global Chat\", \"forum\", DockArea.Bottom )]\npublic sealed class GlobalChatDock : Widget\n{\n\tinternal GlobalChatWindow ChatWindow { get; private set; }\n\tChatDockNativeHost nativeHost;\n\tinternal bool SurfaceAttached => ChatWindow is { IsOpen: true } && nativeHost is not null && nativeHost.IsAttached;\n\tinternal IntPtr NativeHandle => nativeHost?.Handle ?? IntPtr.Zero;\n\tinternal static DockWidget Dock => EditorWindow.IsValid() && EditorWindow.DockManager.IsValid()\n\t\t? EditorWindow.DockManager.FindDockWidget( \"Global Chat\" ) : null;\n\n\tpublic GlobalChatDock( Widget parent ) : base( parent )\n\t{\n\t\tMinimumSize = new Vector2( 540, 420 );\n\t\tFocusMode = FocusMode.TabOrClick;\n\t\tOnPaintOverride = SuppressParentPaint;\n\t}\n\n\t// A named callback survives hotload when constructor fields change.\n\tstatic bool SuppressParentPaint()\n\t{\n\t\treturn true;\n\t}\n\n\tinternal static void Open()\n\t{\n\t\t// Restore the library registration if project startup cleared the dock types.\n\t\tif ( !EditorWindow.DockManager.DockTypes.Any( info => info.Title == \"Global Chat\" ) )\n\t\t\tEditorWindow.DockManager.RegisterDockType( new DockManager.DockInfo { Title = \"Global Chat\", Icon = \"forum\", Area = DockArea.Bottom, CreateAction = CreateDock } );\n\t\tEditorWindow.DockManager.SetDockState( \"Global Chat\", true );\n\t\tvar dock = Dock ?? throw new InvalidOperationException( \"The editor has not registered the Global Chat dock.\" );\n\t\tdock.MinimumSizeFromContent = true;\n\t\tdock.SetAsCurrentTab();\n\t\tvar host = dock.Widget as GlobalChatDock ?? throw new InvalidOperationException( \"Global Chat has an unexpected dock host.\" );\n\t\thost.EnsureWindow();\n\t\thost.Synchronize();\n\t\thost.Focus();\n\t}\n\n\tstatic Widget CreateDock()\n\t{\n\t\treturn new GlobalChatDock( EditorWindow );\n\t}\n\n\tinternal static void Hide( GlobalChatWindow window )\n\t{\n\t\tvar dock = Dock;\n\t\tif ( dock is not null && dock.Widget is GlobalChatDock host && host.ChatWindow == window ) dock.ToggleView( false );\n\t}\n\n\tvoid EnsureWindow()\n\t{\n\t\tif ( ChatWindow is { IsOpen: true } ) return;\n\t\tChatWindow = GlobalChatWindow.CreateDocked();\n\t\ttry { nativeHost = new ChatDockNativeHost( ChatWindow, this ); }\n\t\tcatch { ChatWindow.Close(); ChatWindow = null; throw; }\n\t}\n\n\t[EditorEvent.Frame]\n\tinternal void Synchronize()\n\t{\n\t\t// Rebind existing docks as well as docks made after a code reload.\n\t\tOnPaintOverride = SuppressParentPaint;\n\t\tvar dock = EditorWindow.DockManager.FindDockWidget( this );\n\t\tif ( dock is null || dock.IsClosed )\n\t\t{\n\t\t\tnativeHost?.Dispose();\n\t\t\tChatWindow?.CloseSurface();\n\t\t\tChatWindow = null;\n\t\t\tnativeHost = null;\n\t\t\treturn;\n\t\t}\n\t\tdock.MinimumSizeFromContent = true;\n\t\tif ( !Visible ) { ChatWindow?.Hide(); return; }\n\t\tEnsureWindow();\n\t\tnativeHost.UpdateBounds();\n\t\tif ( !ChatWindow.IsVisible ) ChatWindow.Show();\n\t}\n\n\tinternal static void ResizeFloating( Vector2 size )\n\t{\n\t\tvar dock = Dock;\n\t\tif ( dock is null || !dock.IsFloating ) throw new InvalidOperationException( \"Float Global Chat before requesting a capture size, or omit width and height to capture the dock area.\" );\n\t\tvar host = (GlobalChatDock)dock.Widget;\n\t\tvar floatingWindow = host.GetWindow();\n\t\tfloatingWindow.Size += size - host.Size;\n\t\thost.Synchronize();\n\t}\n\n\tpublic override void OnDestroyed()\n\t{\n\t\t// The native widget is already gone. Release chat without querying its dock.\n\t\tnativeHost?.Dispose();\n\t\tChatWindow?.CloseSurface();\n\t\tChatWindow = null;\n\t\tnativeHost = null;\n\t\tbase.OnDestroyed();\n\t}\n}\n"
},
{
"Ident": "thedimin.global_chat",
"Path": "Editor/GlobalChatSession.cs",
"FileName": "GlobalChatSession.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 422392,
"IsPrivate": false,
"Code": "// Adapted from s&box menu GlobalChatService. Keep the message and history formats compatible.\nusing System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing Sandbox;\r\nusing System.Text;\r\nusing System.Text.Json;\r\n\r\nnamespace Editor.GlobalChat;\r\n\r\n/// <summary>\r\n/// Global chat membership exists while its view is open, with recent history shared through the lobby.\r\n/// </summary>\r\ninternal sealed class GlobalChatSession : IDisposable\r\n{\r\n\treadonly EngineChatBridge bridge = new();\n\tCancellationTokenSource cancellation = new();\n\tbool disposed;\n\tinternal event Action<ChatLine> LiveMessageReceived;\n\tinternal IDisposable EnterMenuScope() => bridge.EnterMenuScope();\n\tinternal Task JoinPartyAsync( ulong id ) => bridge.JoinPartyAsync( id, cancellation.Token );\n\tinternal ChatChannel Channel { get; private set; }\r\n\tinternal List<ChatLine> Messages { get; } = new();\r\n\tinternal bool Connecting { get; private set; }\r\n\tinternal string Error { get; private set; }\r\n\tinternal int Revision { get; private set; }\r\n\tinternal int InvitationRevision { get; private set; }\r\n\tinternal bool Visible { get; private set; }\r\n\tinternal string Draft { get; set; } = \"\";\r\n\r\n\t/// <summary>\r\n\t/// Whether chat is connected and the shared message cooldown has expired.\r\n\t/// </summary>\r\n\tinternal bool CanSend => Channel is not null && RealTime.Now - lastSent >= 1;\r\n\r\n\t/// <summary>\r\n\t/// Whether the party leader can post another invitation, including the shared message cooldown.\r\n\t/// </summary>\r\n\tinternal bool CanInviteParty => CanSend && PartyRoom.Current is { Owner.IsMe: true } && RealTime.Now - lastInvite >= 10;\r\n\r\n\tinternal record ChatLine( Friend Sender, string Text, ulong PartyId, Guid Id );\r\n\r\n\t/// <summary>\r\n\t/// A snapshot of a shared party. Unavailable also covers failed refreshes, so stale invites cannot be joined.\r\n\t/// </summary>\r\n\tinternal record PartyInvitation( string Name, string GameTitle, int Members, int Capacity, bool IsPlaying, bool Available )\r\n\t{\r\n\t\tinternal bool IsFull => Members >= Capacity;\r\n\t}\r\n\r\n\treadonly Dictionary<ulong, PartyInvitation> invitations = new();\r\n\tbool refreshingInvitations;\r\n\tdouble nextInvitationRefresh;\r\n\r\n\treadonly Dictionary<ulong, double> lastMessages = new();\r\n\tconst string HistoryKey = \"chat_history\";\r\n\tconst int HistoryLimit = 20;\r\n\tconst int HistoryByteLimit = 8191;\r\n\treadonly List<Message> history = new();\r\n\tint generation;\r\n\tbool refreshing;\r\n\tdouble nextRefresh;\r\n\tdouble nextJoin;\r\n\tdouble lastSent = -10;\r\n\tdouble lastInvite = -10;\r\n\r\n\tsealed class Message\r\n\t{\r\n\t\t/// <summary>\r\n\t\t/// Identifies a message across live delivery and lobby history.\r\n\t\t/// </summary>\r\n\t\tpublic Guid Id { get; set; }\r\n\r\n\t\t/// <summary>\r\n\t\t/// Steam sender recorded by the receiver, never trusted from a live payload.\r\n\t\t/// </summary>\r\n\t\tpublic ulong SenderId { get; set; }\r\n\r\n\t\t/// <summary>\r\n\t\t/// Plain text sent to everyone in global chat.\r\n\t\t/// </summary>\r\n\t\tpublic string Text { get; set; }\r\n\r\n\t\t/// <summary>\r\n\t\t/// Optional invitation to an existing party.\r\n\t\t/// </summary>\r\n\t\tpublic ulong PartyId { get; set; }\r\n\t}\r\n\r\n\tinternal void Open()\r\n\t{\r\n\t\tObjectDisposedException.ThrowIf( disposed, this );\n\t\tif ( Visible ) return;\n\t\tVisible = true;\r\n\t\tTick();\r\n\t}\r\n\r\n\tinternal void Close()\r\n\t{\r\n\t\tif ( !Visible && Channel is null ) return;\r\n\t\tVisible = false;\r\n\t\tgeneration++;\r\n\t\tcancellation.Cancel();\n\t\tcancellation.Dispose();\n\t\tcancellation = new();\n\t\tChannel?.Dispose();\r\n\t\tChannel = null;\r\n\t\tConnecting = false;\n\t\trefreshing = false;\n\t\trefreshingInvitations = false;\n\t\t// Leaving the page releases the channel, but keeps the conversation and draft for returning to it.\r\n\t\tinvitations.Clear();\r\n\t\tlastMessages.Clear();\r\n\t\thistory.Clear();\r\n\t\tError = null;\r\n\t\tnextJoin = 0;\r\n\t\tnextRefresh = 0;\r\n\t\tnextInvitationRefresh = 0;\r\n\t\tRevision++;\r\n\t}\r\n\r\n\tinternal void Tick()\r\n\t{\r\n\t\tif ( Channel is { IsJoined: false } )\n\t\t{\n\t\t\tClose();\n\t\t\tOpen();\n\t\t\treturn;\n\t\t}\n\t\tif ( !Visible ) return;\r\n\t\tif ( Channel is null )\r\n\t\t{\r\n\t\t\tif ( !Connecting && RealTime.Now >= nextJoin ) _ = Join();\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tif ( !Connecting && !refreshing && RealTime.Now >= nextRefresh ) _ = Refresh();\r\n\t\tif ( !refreshingInvitations && RealTime.Now >= nextInvitationRefresh ) _ = RefreshInvitations();\r\n\t}\r\n\r\n\tasync Task Join()\r\n\t{\r\n\t\tConnecting = true;\r\n\t\tvar version = generation;\r\n\t\tChatChannel joined = null;\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar found = await bridge.FindAsync( cancellation.Token );\r\n\t\t\tif ( version != generation || !Visible ) return;\r\n\t\t\tforeach ( var channel in found.OrderBy( x => x ) )\r\n\t\t\t{\r\n\t\t\t\ttry { joined = await bridge.JoinAsync( channel ); }\r\n\t\t\t\tcatch ( InvalidOperationException ) { }\r\n\t\t\t\tif ( joined is not null || version != generation || !Visible ) break;\r\n\t\t\t}\r\n\t\t\tif ( version != generation || !Visible ) return;\r\n\t\t\tjoined ??= await bridge.CreateAsync();\r\n\t\t\tif ( version != generation || !Visible ) return;\r\n\t\t\tAttach( joined );\r\n\t\t\tjoined = null;\r\n\t\t\tError = null;\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tif ( version == generation && Visible )\r\n\t\t\t{\r\n\t\t\t\tError = e.Message;\r\n\t\t\t\tnextJoin = RealTime.Now + 10;\r\n\t\t\t}\r\n\t\t}\r\n\t\tfinally\r\n\t\t{\r\n\t\t\tjoined?.Dispose();\r\n\t\t\tif ( version == generation ) { Connecting = false; Revision++; }\n\t\t}\r\n\t}\r\n\r\n\tvoid Attach( ChatChannel channel )\r\n\t{\r\n\t\tChannel?.Dispose();\r\n\t\tChannel = channel;\r\n\t\t// Rejoining or merging channels must not erase messages already received in this session.\r\n\t\tinvitations.Clear();\r\n\t\tnextInvitationRefresh = 0;\r\n\t\tlastMessages.Clear();\r\n\t\thistory.Clear();\r\n\t\tLoadHistory();\r\n\t\tchannel.MessageReceived += Receive;\r\n\t\tchannel.Changed += Changed;\r\n\t\tChanged();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// All messages sharing the same party use one periodically refreshed snapshot.\r\n\t/// </summary>\r\n\tinternal PartyInvitation GetInvitation( ulong id ) => invitations.GetValueOrDefault( id );\r\n\r\n\tasync Task RefreshInvitations()\r\n\t{\r\n\t\tusing var scope = EnterMenuScope();\n\t\trefreshingInvitations = true;\r\n\t\tnextInvitationRefresh = RealTime.Now + 15;\r\n\t\tvar version = generation;\r\n\t\tvar channel = Channel;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar ids = Messages.Where( x => x.PartyId != 0 ).Select( x => x.PartyId ).Distinct().ToArray();\r\n\t\t\tforeach ( var id in invitations.Keys.Except( ids ).ToArray() ) invitations.Remove( id );\r\n\r\n\t\t\t// Bound concurrent Steam requests, including when chat contains many different invitations.\r\n\t\t\tforeach ( var batch in ids.Chunk( 8 ) )\r\n\t\t\t{\r\n\t\t\t\tif ( version != generation || !Visible || Channel != channel ) return;\r\n\r\n\t\t\t\tawait Task.WhenAll( batch.Select( async id =>\r\n\t\t\t\t{\r\n\t\t\t\t\tPartyRoom.Entry? entry = null;\r\n\t\t\t\t\ttry\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tentry = await PartyRoom.GetEntryAsync( id );\r\n\t\t\t\t\t}\r\n\t\t\t\t\tcatch ( Exception )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\t// A failed check must not leave an old invitation looking live.\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tif ( version != generation || !Visible || Channel != channel ) return;\r\n\r\n\t\t\t\t\tinvitations[id] = entry is { } party\r\n\t\t\t\t\t\t? new( party.Name, party.GameTitle, party.Members, party.MaxMembers, party.IsPlaying, true )\r\n\t\t\t\t\t\t: new( invitations.GetValueOrDefault( id )?.Name, invitations.GetValueOrDefault( id )?.GameTitle, 0, 0, false, false );\r\n\t\t\t\t\tInvitationRevision++;\r\n\t\t\t\t} ) );\r\n\t\t\t}\r\n\t\t}\r\n\t\tfinally\r\n\t\t{\r\n\t\t\tif ( version == generation ) refreshingInvitations = false;\n\t\t}\r\n\t}\r\n\r\n\tasync Task Refresh()\r\n\t{\r\n\t\trefreshing = true;\r\n\t\tnextRefresh = RealTime.Now + 15;\r\n\t\tvar version = generation;\r\n\t\ttry\r\n\t\t{\r\n\t\t\t// Merge channels created at the same time once Steam lists both of them.\r\n\t\t\tvar current = Channel;\r\n\t\t\tif ( current is null || Connecting ) return;\r\n\t\t\tvar found = await bridge.FindAsync( cancellation.Token );\r\n\t\t\tif ( version != generation || !Visible || Channel != current || Connecting ) return;\r\n\t\t\tError = null;\n\t\t\tvar older = found.Where( x => x < current.Id ).OrderBy( x => x ).FirstOrDefault();\r\n\t\t\tif ( older == 0 ) return;\r\n\t\t\tConnecting = true;\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\tvar joined = await bridge.JoinAsync( older );\r\n\t\t\t\tif ( version != generation || !Visible )\r\n\t\t\t\t{\r\n\t\t\t\t\tjoined.Dispose();\r\n\t\t\t\t}\r\n\t\t\t\telse\r\n\t\t\t\t{\r\n\t\t\t\t\tAttach( joined );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t\tfinally { if ( version == generation ) Connecting = false; }\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tif ( version == generation && Visible ) Error = e.Message;\r\n\t\t}\r\n\t\tfinally { if ( version == generation ) { refreshing = false; Revision++; } }\n\t}\r\n\r\n\tinternal void InviteParty()\r\n\t{\r\n\t\tif ( !CanInviteParty || PartyRoom.Current is not { } party ) return;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tparty.MakePublic();\r\n\t\t\tif ( SendMessage( \"\", party.Id.ValueUnsigned ) ) lastInvite = RealTime.Now;\r\n\t\t\tnextRefresh = 0;\r\n\t\t}\r\n\t\tcatch ( Exception e ) { Error = e.Message; }\r\n\t}\r\n\r\n\tinternal bool Send( string text ) => SendMessage( text, 0 );\r\n\r\n\tbool SendMessage( string text, ulong partyId )\r\n\t{\r\n\t\ttext = text?.Trim() ?? \"\";\r\n\t\tif ( !CanSend || (text.Length == 0 && partyId == 0) ) return false;\r\n\r\n\t\tif ( text.Length > 400 ) text = text[..400];\r\n\t\tif ( !Channel.Send( JsonSerializer.Serialize( new Message { Id = Guid.NewGuid(), Text = text, PartyId = partyId } ) ) )\r\n\t\t{\r\n\t\t\tError = \"The message could not be sent.\";\r\n\t\t\treturn false;\r\n\t\t}\r\n\t\tError = null;\r\n\t\tlastSent = RealTime.Now;\r\n\t\treturn true;\r\n\t}\r\n\r\n\tvoid Receive( Friend sender, string payload )\r\n\t{\r\n\t\tif ( sender.IsBlocked || !Visible ) return;\r\n\t\tif ( lastMessages.TryGetValue( sender.Id, out var last ) && RealTime.Now - last < 0.5 ) return;\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar message = JsonSerializer.Deserialize<Message>( payload );\r\n\t\t\tif ( !IsValid( message ) ) return;\r\n\t\t\tlastMessages[sender.Id] = RealTime.Now;\r\n\r\n\t\t\t// Older clients do not include a message ID.\r\n\t\t\tif ( message.Id == Guid.Empty ) message.Id = Guid.NewGuid();\r\n\t\t\tmessage.SenderId = sender.Id;\r\n\t\t\tRemember( message );\r\n\t\t\tAddMessage( message, true );\n\t\t\tSaveHistory();\r\n\t\t}\r\n\t\tcatch ( JsonException ) { }\r\n\t}\r\n\r\n\tbool IsValid( Message message ) => message is not null && message.Text?.Length is not > 400 && (message.PartyId != 0 || !string.IsNullOrWhiteSpace( message.Text ));\r\n\r\n\tvoid Remember( Message message )\r\n\t{\r\n\t\tif ( history.Any( x => x.Id == message.Id && x.SenderId == message.SenderId ) ) return;\r\n\r\n\t\thistory.Add( message );\r\n\t\tif ( history.Count > HistoryLimit ) history.RemoveAt( 0 );\r\n\t}\r\n\r\n\tvoid AddMessage( Message message, bool live = false )\n\t{\r\n\t\tvar sender = new Friend( message.SenderId );\r\n\t\tif ( sender.IsBlocked || Messages.Any( x => x.Id == message.Id && x.Sender.Id == message.SenderId ) ) return;\r\n\r\n\t\tvar text = Sandbox.Utility.Steam.FilterChat( message.Text, sender.Id );\r\n\t\tvar line = new ChatLine( sender, text, message.PartyId, message.Id );\n\t\tMessages.Add( line );\n\t\tif ( message.PartyId != 0 && !invitations.ContainsKey( message.PartyId ) ) nextInvitationRefresh = 0;\r\n\t\tif ( Messages.Count > 100 ) Messages.RemoveAt( 0 );\r\n\t\tRevision++;\r\n\t\tif ( live )\n\t\t{\n\t\t\ttry { LiveMessageReceived?.Invoke( line ); }\n\t\t\tcatch ( Exception e ) { Log.Warning( e, \"Global chat mention alert failed.\" ); }\n\t\t}\n\t}\r\n\r\n\tvoid LoadHistory()\r\n\t{\r\n\t\tvar payload = Channel.GetData( HistoryKey );\r\n\t\tif ( string.IsNullOrEmpty( payload ) || Encoding.UTF8.GetByteCount( payload ) > HistoryByteLimit ) return;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar messages = JsonSerializer.Deserialize<List<Message>>( payload );\r\n\t\t\tif ( messages is null ) return;\r\n\r\n\t\t\tforeach ( var message in messages.TakeLast( HistoryLimit ) )\r\n\t\t\t{\r\n\t\t\t\tif ( !IsValid( message ) || message.Id == Guid.Empty || message.SenderId == 0 ) continue;\r\n\r\n\t\t\t\tRemember( message );\r\n\t\t\t\tAddMessage( message );\r\n\t\t\t}\r\n\t\t}\r\n\t\tcatch ( JsonException ) { }\r\n\t}\r\n\r\n\tvoid SaveHistory()\r\n\t{\r\n\t\tif ( Channel is null || !Channel.Owner.IsMe ) return;\r\n\r\n\t\t// Steam limits each metadata value to 8 KB, including its terminating byte.\r\n\t\tvar payload = JsonSerializer.Serialize( history );\r\n\t\twhile ( Encoding.UTF8.GetByteCount( payload ) > HistoryByteLimit && history.Count > 0 )\r\n\t\t{\r\n\t\t\thistory.RemoveAt( 0 );\r\n\t\t\tpayload = JsonSerializer.Serialize( history );\r\n\t\t}\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tChannel.SetData( HistoryKey, payload );\r\n\t\t}\r\n\t\tcatch ( InvalidOperationException e )\r\n\t\t{\r\n\t\t\tError = e.Message;\r\n\t\t\tRevision++;\r\n\t\t}\r\n\t}\r\n\r\n\tvoid Changed()\r\n\t{\r\n\t\t// Every member keeps recent messages so a new owner can continue saving history.\r\n\t\tSaveHistory();\r\n\t\tRevision++;\r\n\t}\r\n\n\tpublic void Dispose()\n\t{\n\t\tif ( disposed ) return;\n\t\tClose();\n\t\tdisposed = true;\n\t\tcancellation.Dispose();\n\t\tLiveMessageReceived = null;\n\t}\n}\r\n"
},
{
"Ident": "thedimin.global_chat",
"Path": "Editor/ChatWindowCapture.cs",
"FileName": "ChatWindowCapture.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 422392,
"IsPrivate": false,
"Code": "using Sandbox;\nusing Sandbox.UI;\nusing System;\nusing System.Linq;\nusing System.Reflection;\n\nnamespace Editor.GlobalChat;\n\n/// <summary>Captures the chat panel through the editor window camera.</summary>\ninternal static class ChatWindowCapture\n{\n\tinternal static Bitmap Capture( PanelWindow window, GlobalChatSession session )\n\t{\n\t\tusing var scope = session.EnterMenuScope();\n\t\tEngineChatBridge.Invoke( EngineChatBridge.RequireMethod( typeof( PanelWindow ), \"Frame\" ), window );\n\t\tvar camera = typeof( PanelWindow ).GetField( \"_camera\", BindingFlags.Instance | BindingFlags.NonPublic )?.GetValue( window ) as SceneCamera\n\t\t\t?? throw new InvalidOperationException( \"The engine does not expose the chat window camera.\" );\n\t\tvar render = typeof( SceneCamera ).GetMethods( BindingFlags.Instance | BindingFlags.NonPublic )\n\t\t\t.Single( x => x.Name == \"RenderToBitmap\" && x.GetParameters().Length == 2 );\n\t\tvar config = Activator.CreateInstance( render.GetParameters()[1].ParameterType.GetElementType() );\n\t\tvar size = (Vector2)EngineChatBridge.RequireProperty( typeof( PanelWindow ), \"PixelSize\" ).GetValue( window );\n\t\tvar bitmap = new Bitmap( (int)size.x, (int)size.y );\n\t\ttry { EngineChatBridge.Invoke( render, camera, bitmap, config ); return bitmap; }\n\t\tcatch { bitmap.Dispose(); throw; }\n\t}\n\n}\n"
},
{
"Ident": "thedimin.global_chat",
"Path": "Editor/EngineChatBridge.cs",
"FileName": "EngineChatBridge.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 422392,
"IsPrivate": false,
"Code": "using Sandbox;\nusing System;\nusing System.Collections;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Reflection;\nusing System.Runtime.ExceptionServices;\nusing System.Threading;\nusing System.Threading.Tasks;\n\nnamespace Editor.GlobalChat;\n\n/// <summary>\n/// The only reflection boundary. Uses the engine's Steam query semaphore, membership handles,\n/// authenticated callbacks and wire protocol. Does not change engine methods or global settings.\n/// </summary>\ninternal sealed class EngineChatBridge\n{\n\tconst BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance;\n\treadonly Type channelType;\n\treadonly PropertyInfo lobbyList;\n\treadonly MethodInfo worldwide, withKeyValue, request, join, create, menuScope;\n\treadonly PropertyInfo lobbyId;\n\treadonly FieldInfo steamIdValue;\n\treadonly Dictionary<string, string> filters;\n\n\tpublic EngineChatBridge()\n\t{\n\t\tvar engine = typeof( Scene ).Assembly;\n\t\tvar matchmaking = RequireType( engine, \"Steamworks.SteamMatchmaking\" );\n\t\tvar query = RequireType( engine, \"Steamworks.Data.LobbyQuery\" );\n\t\tvar lobby = RequireType( engine, \"Steamworks.Data.Lobby\" );\n\t\tvar protocol = RequireType( engine, \"Sandbox.Engine.Protocol\" );\n\t\tvar menu = AppDomain.CurrentDomain.GetAssemblies().SingleOrDefault( x => x.GetName().Name == \"Sandbox.Menu\" )\n\t\t\t?? Assembly.Load( \"Sandbox.Menu\" );\n\t\tchannelType = RequireType( menu, \"Sandbox.MenuChatChannel\" );\n\t\tlobbyList = RequireProperty( matchmaking, \"LobbyList\" );\n\t\tlobbyId = RequireProperty( lobby, \"Id\" );\n\t\tsteamIdValue = lobbyId.PropertyType.GetField( \"Value\", All )\n\t\t\t?? throw Incompatible( \"Steamworks.SteamId.Value\" );\n\t\tworldwide = RequireMethod( query, \"FilterDistanceWorldwide\" );\n\t\twithKeyValue = RequireMethod( query, \"WithKeyValue\", typeof( string ), typeof( string ) );\n\t\trequest = RequireMethod( query, \"RequestAsync\", typeof( CancellationToken ) );\n\t\tjoin = RequireMethod( channelType, \"JoinAsync\", typeof( ulong ) );\n\t\tcreate = RequireMethod( channelType, \"CreateAsync\", typeof( int ), typeof( Dictionary<string, string> ) );\n\t\tmenuScope = RequireMethod( RequireType( engine, \"Sandbox.Engine.GlobalContext\" ), \"MenuScope\" );\n\t\tfilters = new()\n\t\t{\n\t\t\t[\"menu_protocol\"] = \"1\",\n\t\t\t[\"api\"] = RequireProperty( protocol, \"Api\" ).GetValue( null ).ToString(),\n\t\t\t[\"protocol\"] = RequireProperty( protocol, \"Network\" ).GetValue( null ).ToString(),\n\t\t\t[\"lobby_type\"] = \"menu_chat\",\n\t\t\t[\"channel\"] = \"global\",\n\t\t\t// Existing normal menu clients still search for this metadata. Never create an editor-only room.\n\t\t\t[\"dev\"] = \"0\"\n\t\t};\n\t\t// Resolve the complete handle contract before joining Steam.\n\t\tforeach ( var name in new[] { \"Id\", \"Owner\", \"MemberCount\", \"Members\", \"IsJoined\" } ) RequireProperty( channelType, name );\n\t\tRequireMethod( channelType, \"GetData\", typeof( string ) );\n\t\tRequireMethod( channelType, \"SetData\", typeof( string ), typeof( string ) );\n\t\tRequireMethod( channelType, \"Send\", typeof( string ) );\n\t\tforeach ( var name in new[] { \"MessageReceived\", \"Changed\" } )\n\t\t\tif ( channelType.GetEvent( name, All ) is null ) throw Incompatible( $\"{channelType.FullName}.{name}\" );\n\t}\n\n\tpublic IDisposable EnterMenuScope() => (IDisposable)Invoke( menuScope, null );\n\n\tpublic async Task<ulong[]> FindAsync( CancellationToken cancellation )\n\t{\n\t\tusing var scope = EnterMenuScope();\n\t\tvar query = Invoke( worldwide, lobbyList.GetValue( null ) );\n\t\tforeach ( var pair in filters ) query = Invoke( withKeyValue, query, pair.Key, pair.Value );\n\t\tvar found = (IEnumerable)await AwaitResult( Invoke( request, query, cancellation ) );\n\t\treturn found?.Cast<object>().Select( x => (ulong)steamIdValue.GetValue( lobbyId.GetValue( x ) ) ).OrderBy( x => x ).ToArray() ?? [];\n\t}\n\n\tpublic async Task<ChatChannel> JoinAsync( ulong id )\n\t{\n\t\tusing var scope = EnterMenuScope();\n\t\treturn new ChatChannel( await AwaitResult( Invoke( join, null, id ) ), channelType );\n\t}\n\n\tpublic async Task<ChatChannel> CreateAsync()\n\t{\n\t\tusing var scope = EnterMenuScope();\n\t\treturn new ChatChannel( await AwaitResult( Invoke( create, null, 250, new Dictionary<string, string>( filters ) ) ), channelType );\n\t}\n\n\tpublic async Task JoinPartyAsync( ulong id, CancellationToken cancellation )\n\t{\n\t\tusing var scope = EnterMenuScope();\n\t\tif ( PartyRoom.Current is not null ) throw new InvalidOperationException( \"Leave your current party before joining another.\" );\n\t\tvar lobbyType = lobbyId.DeclaringType;\n\t\tvar lobbyConstructor = lobbyType.GetConstructor( All, null, [lobbyId.PropertyType], null ) ?? throw Incompatible( \"Lobby constructor\" );\n\t\tvar partyConstructor = typeof( PartyRoom ).GetConstructor( All, null, [lobbyType], null ) ?? throw Incompatible( \"PartyRoom constructor\" );\n\t\tvar current = RequireProperty( typeof( PartyRoom ), \"Current\" );\n\t\tvar setCurrent = current.GetSetMethod( true ) ?? throw Incompatible( \"PartyRoom.Current setter\" );\n\t\tvar joinLobby = RequireMethod( lobbyType, \"Join\" );\n\t\tvar leaveLobby = RequireMethod( lobbyType, \"Leave\" );\n\t\tvar waitForMembership = RequireMethod( channelType, \"WaitForMembership\", lobbyType );\n\t\tvar eventSystem = RequireProperty( RequireType( typeof( Scene ).Assembly, \"Sandbox.Event\" ), \"EventSystem\" ).GetValue( null );\n\t\tvar runInterface = eventSystem.GetType().GetMethods( All ).Single( x => x.Name == \"RunInterface\" && x.IsGenericMethodDefinition && x.GetParameters().Length == 1 )\n\t\t\t.MakeGenericMethod( typeof( PartyRoom.IEventListener ) );\n\n\t\tvar entry = await PartyRoom.GetEntryAsync( id );\n\t\tcancellation.ThrowIfCancellationRequested();\n\t\tif ( entry is not { } available || available.Members >= available.MaxMembers ) throw new InvalidOperationException( \"The party is unavailable or full.\" );\n\t\tif ( PartyRoom.Current is not null ) throw new InvalidOperationException( \"Your party changed while joining. Please try again.\" );\n\t\tvar steamId = Activator.CreateInstance( lobbyId.PropertyType );\n\t\tsteamIdValue.SetValue( steamId, id );\n\t\tvar lobby = lobbyConstructor.Invoke( [steamId] );\n\t\t// The public party join leaves the current party, then rejects all editor clients.\n\t\t// Use the same Steam join and real PartyRoom lifecycle without changing IsEditor.\n\t\tvar response = await AwaitResult( Invoke( joinLobby, lobby ) );\n\t\tif ( response.ToString() != \"Success\" ) throw new InvalidOperationException( $\"Could not join the party: {response}.\" );\n\t\tPartyRoom party = null;\n\t\ttry\n\t\t{\n\t\t\tawait (Task)Invoke( waitForMembership, null, lobby );\n\t\t\tcancellation.ThrowIfCancellationRequested();\n\t\t\tif ( PartyRoom.Current is not null ) throw new InvalidOperationException( \"Your party changed while joining. Please try again.\" );\n\t\t\tparty = (PartyRoom)partyConstructor.Invoke( [lobby] );\n\t\t\tInvoke( setCurrent, null, party );\n\t\t\tInvoke( runInterface, eventSystem, (Action<PartyRoom.IEventListener>)(listener => listener.OnJoinedParty( party )) );\n\t\t}\n\t\tcatch\n\t\t{\n\t\t\tif ( party is not null ) party.Leave();\n\t\t\telse Invoke( leaveLobby, lobby );\n\t\t\tthrow;\n\t\t}\n\t}\n\n\tinternal async Task<int> ValidatePrivatePartyAsync()\n\t{\n\t\tusing var scope = EnterMenuScope();\n\t\tif ( PartyRoom.Current is not null ) throw new InvalidOperationException( \"Leave your party before running the private party check.\" );\n\t\tvar matchmaking = lobbyList.DeclaringType;\n\t\tvar lobbyType = lobbyId.DeclaringType;\n\t\tvar accessType = RequireType( typeof( Scene ).Assembly, \"Steamworks.LobbyType\" );\n\t\tvar createLobby = RequireMethod( matchmaking, \"CreateLobbyAsync\", accessType, typeof( int ) );\n\t\tvar setData = RequireMethod( lobbyType, \"SetData\", typeof( string ), typeof( string ) );\n\t\tvar leave = RequireMethod( lobbyType, \"Leave\" );\n\t\tvar lobby = await AwaitResult( Invoke( createLobby, null, Enum.Parse( accessType, \"Private\" ), 2 ) )\n\t\t\t?? throw new InvalidOperationException( \"Steam could not create the private validation party.\" );\n\t\tvar id = (ulong)steamIdValue.GetValue( lobbyId.GetValue( lobby ) );\n\t\ttry\n\t\t{\n\t\t\tif ( !(bool)Invoke( setData, lobby, \"lobby_type\", \"party\" ) || !(bool)Invoke( setData, lobby, \"name\", \"Editor Global Chat validation\" ) )\n\t\t\t\tthrow new InvalidOperationException( \"Steam could not set up the private validation party.\" );\n\t\t\tawait JoinPartyAsync( id, CancellationToken.None );\n\t\t\tvar party = PartyRoom.Current;\n\t\t\tif ( party?.Id.ValueUnsigned != id || !party.Owner.IsMe || party.MemberCount != 1 )\n\t\t\t\tthrow new InvalidOperationException( \"The private validation party did not match the expected membership.\" );\n\t\t\treturn party.MemberCount;\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tif ( PartyRoom.Current?.Id.ValueUnsigned == id ) PartyRoom.Current.Leave();\n\t\t\telse Invoke( leave, lobby );\n\t\t}\n\t}\n\n\tinternal static Type RequireType( Assembly assembly, string name )\n\t{\n\t\treturn assembly.GetType( name, false ) ?? throw Incompatible( name );\n\t}\n\n\tinternal static PropertyInfo RequireProperty( Type type, string name )\n\t{\n\t\treturn type.GetProperty( name, All ) ?? throw Incompatible( $\"{type.FullName}.{name}\" );\n\t}\n\n\tinternal static MethodInfo RequireMethod( Type type, string name, params Type[] parameters )\n\t{\n\t\treturn type.GetMethod( name, All, null, parameters, null ) ?? throw Incompatible( $\"{type.FullName}.{name}\" );\n\t}\n\n\tinternal static object Invoke( MethodInfo method, object target, params object[] arguments )\n\t{\n\t\ttry { return method.Invoke( target, arguments ); }\n\t\tcatch ( TargetInvocationException e ) when ( e.InnerException is not null )\n\t\t{\n\t\t\tExceptionDispatchInfo.Capture( e.InnerException ).Throw();\n\t\t\tthrow;\n\t\t}\n\t}\n\n\tstatic async Task<object> AwaitResult( object value )\n\t{\n\t\tif ( value is not Task task ) throw Incompatible( \"an asynchronous engine method result\" );\n\t\tawait task;\n\t\treturn RequireProperty( task.GetType(), \"Result\" ).GetValue( task );\n\t}\n\n\tstatic InvalidOperationException Incompatible( string member )\n\t{\n\t\treturn new( $\"Editor Global Chat cannot use this engine version: missing {member}. Update the library's EngineChatBridge.\" );\n\t}\n}\n\ninternal sealed class ChatChannel : IDisposable\n{\n\treadonly object handle;\n\treadonly PropertyInfo id, owner, members, memberCount, isJoined;\n\treadonly MethodInfo getData, setData, send;\n\treadonly EventInfo received, changed;\n\treadonly Action<Friend, string> onReceived;\n\treadonly Action onChanged;\n\tbool disposed;\n\tpublic event Action<Friend, string> MessageReceived;\n\tpublic event Action Changed;\n\tpublic ulong Id => (ulong)id.GetValue( handle );\n\tpublic Friend Owner => (Friend)owner.GetValue( handle );\n\tpublic int MemberCount => (int)memberCount.GetValue( handle );\n\tpublic IEnumerable<Friend> Members => (IEnumerable<Friend>)members.GetValue( handle );\n\tpublic bool IsJoined => !disposed && (bool)isJoined.GetValue( handle );\n\n\tpublic ChatChannel( object handle, Type type )\n\t{\n\t\tthis.handle = handle;\n\t\tid = EngineChatBridge.RequireProperty( type, \"Id\" );\n\t\towner = EngineChatBridge.RequireProperty( type, \"Owner\" );\n\t\tmembers = EngineChatBridge.RequireProperty( type, \"Members\" );\n\t\tmemberCount = EngineChatBridge.RequireProperty( type, \"MemberCount\" );\n\t\tisJoined = EngineChatBridge.RequireProperty( type, \"IsJoined\" );\n\t\tgetData = EngineChatBridge.RequireMethod( type, \"GetData\", typeof( string ) );\n\t\tsetData = EngineChatBridge.RequireMethod( type, \"SetData\", typeof( string ), typeof( string ) );\n\t\tsend = EngineChatBridge.RequireMethod( type, \"Send\", typeof( string ) );\n\t\treceived = type.GetEvent( \"MessageReceived\" );\n\t\tchanged = type.GetEvent( \"Changed\" );\n\t\tonReceived = (friend, text) => MessageReceived?.Invoke( friend, text );\n\t\tonChanged = () => Changed?.Invoke();\n\t\ttry\n\t\t{\n\t\t\treceived.AddEventHandler( handle, onReceived );\n\t\t\tchanged.AddEventHandler( handle, onChanged );\n\t\t}\n\t\tcatch\n\t\t{\n\t\t\treceived.RemoveEventHandler( handle, onReceived );\n\t\t\t((IDisposable)handle).Dispose();\n\t\t\tthrow;\n\t\t}\n\t}\n\n\tpublic string GetData( string key ) => (string)EngineChatBridge.Invoke( getData, handle, key );\n\tpublic void SetData( string key, string value ) => EngineChatBridge.Invoke( setData, handle, key, value );\n\tpublic bool Send( string text ) => (bool)EngineChatBridge.Invoke( send, handle, text );\n\n\tpublic void Dispose()\n\t{\n\t\tif ( disposed ) return;\n\t\tdisposed = true;\n\t\treceived.RemoveEventHandler( handle, onReceived );\n\t\tchanged.RemoveEventHandler( handle, onChanged );\n\t\t((IDisposable)handle).Dispose();\n\t\tMessageReceived = null;\n\t\tChanged = null;\n\t}\n}\n"
},
{
"Ident": "thedimin.global_chat",
"Path": "Editor/GlobalChatWindow.cs",
"FileName": "GlobalChatWindow.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 422392,
"IsPrivate": false,
"Code": "using Sandbox;\nusing System;\nusing System.Net;\nusing System.Threading.Tasks;\n\nnamespace Editor.GlobalChat;\n\n/// <summary>Hosts the menu-style Razor chat in an editor panel window.</summary>\npublic sealed class GlobalChatWindow : PanelWindow\n{\n\tinternal static GlobalChatWindow Current { get; private set; }\n\tstatic GlobalChatSession sharedSession;\n\tinternal GlobalChatSession Session { get; }\n\tinternal GlobalChatView View { get; }\n\tstatic readonly ChatMentionGate mentionGate = new();\n\tinternal bool PartyBusy { get; private set; }\n\tinternal string PartyError { get; private set; }\n\tbool released;\n\tinternal int PopupCount { get; private set; }\n\tinternal int SoundCount { get; private set; }\n\tinternal string SoundError { get; private set; }\n\n\tpublic static bool MentionPopups\n\t{\n\t\tget => EditorCookie.Get( \"GlobalChat.MentionPopups\", true );\n\t\tset => EditorCookie.Set( \"GlobalChat.MentionPopups\", value );\n\t}\n\tpublic static bool MentionSounds\n\t{\n\t\tget => EditorCookie.Get( \"GlobalChat.MentionSounds\", true );\n\t\tset => EditorCookie.Set( \"GlobalChat.MentionSounds\", value );\n\t}\n\n\t[Menu( \"Editor\", \"Chat/Global Chat\", \"forum\" )]\n\tpublic static void Open()\n\t{\n\t\tGlobalChatDock.Open();\n\t}\n\n\tinternal static GlobalChatWindow CreateDocked()\n\t{\n\t\tCurrent?.CloseSurface();\n\t\treturn Current = new GlobalChatWindow();\n\t}\n\n\tpublic GlobalChatWindow() : base( \"Global Chat\", new Vector2( 920, 720 ), null, borderless: true )\n\t{\n\t\ttry\n\t\t{\n\t\t\tMinSize = new Vector2( 540, 420 );\n\t\t\tResizeBorder = 0;\n\t\t\tDropShadow = false;\n\t\t\tRoundedCorners = false;\n\t\t\tBackgroundColor = Color.Parse( \"#171a21\" ) ?? Color.Black;\n\t\t\tSession = sharedSession ??= new GlobalChatSession();\n\t\t\tSession.Draft = EditorCookie.Get( \"GlobalChat.Draft\", \"\" );\n\t\t\tusing var scope = Session.EnterMenuScope();\n\t\t\tView = new GlobalChatView { Session = Session, Host = this, Parent = Root };\n\t\t\tSession.LiveMessageReceived += NotifyMention;\n\t\t\tOnCloseRequested = HandleCloseRequested;\n\t\t\tSession.Open();\n\t\t}\n\t\tcatch { ReleaseWindow(); Dispose(); throw; }\n\t}\n\n\tbool HandleCloseRequested()\n\t{\n\t\tReleaseWindow();\n\t\treturn true;\n\t}\n\n\t[EditorEvent.Hotload]\n\tstatic void RebindCloseCallback()\n\t{\n\t\tif ( Current is { IsOpen: true } ) Current.OnCloseRequested = Current.HandleCloseRequested;\n\t}\n\n\t[EditorEvent.Frame]\n\tstatic void Frame()\n\t{\n\t\tif ( Current is null ) return;\n\t\tif ( !Current.IsOpen ) { Current.Close(); return; }\n\t\tusing var scope = Current.Session.EnterMenuScope();\n\t\tCurrent.Session.Tick();\n\t}\n\n\tinternal async Task JoinParty( ulong id )\n\t{\n\t\tif ( PartyBusy || Session.GetInvitation( id ) is not { Available: true, IsFull: false } ) return;\n\t\tPartyBusy = true;\n\t\tPartyError = null;\n\t\ttry\n\t\t{\n\t\t\tusing var scope = Session.EnterMenuScope();\n\t\t\tif ( PartyRoom.Current is not null ) throw new InvalidOperationException( \"Leave your current party before joining another.\" );\n\t\t\tawait Session.JoinPartyAsync( id );\n\t\t}\n\t\tcatch ( Exception e ) { PartyError = e.Message; }\n\t\tfinally { PartyBusy = false; }\n\t}\n\n\tinternal void NotifyMention( GlobalChatSession.ChatLine message )\n\t{\n\t\tvar me = new Friend( Game.SteamId );\n\t\tif ( !mentionGate.ShouldNotify( message.Id, message.Sender.Id, me.Id, me.Name, message.Text, true, message.Sender.IsBlocked, MentionPopups || MentionSounds ) ) return;\n\t\tif ( MentionPopups )\n\t\t{\n\t\t\tvar toast = new MentionToast { Title = \"Mention in global chat\", Subtitle = message.Sender.Name, Icon = \"alternate_email\", IsRunning = false };\n\t\t\tvar body = new Widget() { FixedWidth = 300 };\n\t\t\tbody.Layout = Layout.Column();\n\t\t\tbody.Layout.Add( new Label( Escape( message.Text ) ) { WordWrap = true } );\n\t\t\tvar open = body.Layout.Add( new Button( \"Open chat\", \"forum\" ) );\n\t\t\topen.Clicked = () => { Open(); Current.View.ScrollToLatest(); ToastManager.Dismiss( toast ); };\n\t\t\ttoast.SetBodyWidget( body );\n\t\t\tToastManager.Remove( toast, 8 );\n\t\t\tPopupCount++;\n\t\t}\n\t\tif ( MentionSounds )\n\t\t{\n\t\t\tif ( ChatMentionSound.Play( out var error ) ) SoundCount++;\n\t\t\telse Log.Warning( $\"Global chat mention sound failed: {error}\" );\n\t\t\tSoundError = error;\n\t\t}\n\t}\n\n\tinternal static string Escape( string text ) => \"<span>\" + WebUtility.HtmlEncode( text ?? \"\" ).Replace( \"\\n\", \"<br>\" ) + \"</span>\";\n\n\tvoid ReleaseWindow()\n\t{\n\t\tif ( released ) return;\n\t\treleased = true;\n\t\tif ( Session is not null ) Session.LiveMessageReceived -= NotifyMention;\n\t\tif ( Current is not null && Current != this ) return;\n\t\tSession?.Close();\n\t\tif ( Current == this ) Current = null;\n\t}\n\n\tpublic void Close()\n\t{\n\t\tGlobalChatDock.Hide( this );\n\t\tCloseSurface();\n\t}\n\n\tinternal void CloseSurface()\n\t{\n\t\tReleaseWindow();\n\t\tDispose();\n\t}\n\n\t[Event( \"app.exit\" )]\n\tstatic void Shutdown()\n\t{\n\t\tCurrent?.CloseSurface();\n\t\tsharedSession?.Dispose();\n\t\tsharedSession = null;\n\t}\n\n\tsealed class MentionToast : ToastWidget\n\t{\n\t\tpublic override bool WantsVisible => MentionPopups;\n\t}\n}\n"
},
{
"Ident": "thedimin.global_chat",
"Path": "Editor/ChatDockNativeHost.cs",
"FileName": "ChatDockNativeHost.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 422392,
"IsPrivate": false,
"Code": "using Sandbox;\nusing System;\nusing System.ComponentModel;\nusing System.Reflection;\nusing System.Collections.Generic;\nusing System.Runtime.InteropServices;\n\nnamespace Editor.GlobalChat;\n\n/// <summary>Embeds the existing SDL surface in a native editor widget on Windows.</summary>\ninternal sealed class ChatDockNativeHost : IDisposable, IHotloadManaged\n{\n\tconst int StyleIndex = -16;\n\tconst long ChildStyle = 0x40000000;\n\tconst long PopupStyle = 0x80000000;\n\tconst long CaptionStyle = 0x00C00000;\n\tconst long ThickFrameStyle = 0x00040000;\n\treadonly IntPtr child;\n\treadonly IntPtr parent;\n\treadonly Widget widget;\n\treadonly IntPtr sdlHandle;\n\treadonly MethodInfo mouseMove;\n\treadonly MethodInfo mouseButton;\n\treadonly object[] buttons;\n\treadonly GlobalChatWindow chat;\n\treadonly MethodInfo frame;\n\treadonly ChatDockKeyboardInput keyboard;\n\t[SkipHotload] IntPtr callbackRoot;\n\tbool inputHookInstalled;\n\tbool positioned;\n\n\tinternal ChatDockNativeHost( GlobalChatWindow window, Widget widget )\n\t{\n\t\tthis.widget = widget;\n\t\tchat = window;\n\t\tframe = EngineChatBridge.RequireMethod( typeof( PanelWindow ), \"Frame\" );\n\t\tif ( !OperatingSystem.IsWindows() ) throw new PlatformNotSupportedException( \"Docked Global Chat currently requires the Windows editor.\" );\n\t\tvar nativeWidget = typeof( Widget ).GetField( \"_widget\", BindingFlags.Instance | BindingFlags.NonPublic )?.GetValue( widget )\n\t\t\t?? throw new InvalidOperationException( \"The editor does not expose the native dock widget.\" );\n\t\tparent = (IntPtr)EngineChatBridge.Invoke( EngineChatBridge.RequireMethod( nativeWidget.GetType(), \"winId\" ), nativeWidget );\n\t\tvar sdl = EngineChatBridge.RequireType( typeof( Friend ).Assembly, \"NativeEngine.Sdl\" );\n\t\tvar handle = EngineChatBridge.RequireProperty( typeof( PanelWindow ), \"Handle\" ).GetValue( window );\n\t\tsdlHandle = (IntPtr)handle;\n\t\tvar input = EngineChatBridge.RequireType( typeof( Friend ).Assembly, \"Sandbox.UI.PanelWindowInput\" );\n\t\tmouseMove = EngineChatBridge.RequireMethod( input, \"OnMouseMove\", typeof( IntPtr ), typeof( float ), typeof( float ), typeof( float ), typeof( float ) );\n\t\tvar buttonType = EngineChatBridge.RequireType( typeof( Friend ).Assembly, \"NativeEngine.ButtonCode\" );\n\t\tmouseButton = EngineChatBridge.RequireMethod( input, \"OnMouseButton\", typeof( IntPtr ), buttonType, typeof( bool ), typeof( int ), typeof( KeyboardModifiers ) );\n\t\tbuttons = [Enum.Parse( buttonType, \"MouseLeft\" ), Enum.Parse( buttonType, \"MouseRight\" ), Enum.Parse( buttonType, \"MouseMiddle\" )];\n\t\tvar properties = EngineChatBridge.Invoke( EngineChatBridge.RequireMethod( sdl, \"GetWindowProperties\", typeof( IntPtr ) ), null, handle );\n\t\tchild = (IntPtr)EngineChatBridge.Invoke( EngineChatBridge.RequireMethod( sdl, \"GetPointerProperty\", typeof( uint ), typeof( string ), typeof( IntPtr ) ), null, properties, \"SDL.window.win32.hwnd\", IntPtr.Zero );\n\t\tif ( parent == IntPtr.Zero || child == IntPtr.Zero ) throw new InvalidOperationException( \"The editor did not create both native chat handles.\" );\n\t\t// SetParent does not change window styles. Set WS_CHILD and clear WS_POPUP first.\n\t\tvar style = GetWindowLongPtr( child, StyleIndex ).ToInt64();\n\t\tMarshal.SetLastPInvokeError( 0 );\n\t\tSetWindowLongPtr( child, StyleIndex, new IntPtr( (style | ChildStyle) & ~(PopupStyle | CaptionStyle | ThickFrameStyle) ) );\n\t\tif ( Marshal.GetLastPInvokeError() != 0 ) throw new Win32Exception( Marshal.GetLastPInvokeError() );\n\t\tMarshal.SetLastPInvokeError( 0 );\n\t\tSetParent( child, parent );\n\t\tif ( GetParent( child ) != parent ) throw new Win32Exception( Marshal.GetLastPInvokeError(), \"Could not attach chat to its editor dock.\" );\n\t\tUpdateBounds();\n\t\t// An SDL top-level window relies on activation for keyboard focus. After it\n\t\t// becomes a Qt child, mouse activation belongs to the editor's top-level window.\n\t\tInstallInputHook();\n\t\ttry { keyboard = new ChatDockKeyboardInput( child, sdlHandle, window ); }\n\t\tcatch { Dispose(); throw; }\n\t}\n\n\tvoid InstallInputHook()\n\t{\n\t\tSubclassProc inputCallback = HandleNativeInput;\n\t\tcallbackRoot = GCHandle.ToIntPtr( GCHandle.Alloc( inputCallback ) );\n\t\tif ( !SetWindowSubclass( child, inputCallback, 1, UIntPtr.Zero ) )\n\t\t{\n\t\t\tGCHandle.FromIntPtr( callbackRoot ).Free();\n\t\t\tcallbackRoot = IntPtr.Zero;\n\t\t\tthrow new InvalidOperationException( \"Could not install input handling for the chat dock.\" );\n\t\t}\n\t\tinputHookInstalled = true;\n\t}\n\n\tvoid IHotloadManaged.Destroyed( Dictionary<string, object> state )\n\t{\n\t\tstate[\"InputHookInstalled\"] = inputHookInstalled;\n\t\tRemoveInputHook();\n\t}\n\n\tvoid IHotloadManaged.Created( IReadOnlyDictionary<string, object> state )\n\t{\n\t\tinputHookInstalled = false;\n\t\tcallbackRoot = IntPtr.Zero;\n\t\tif ( state.TryGetValue( \"InputHookInstalled\", out var installed ) && installed is true ) InstallInputHook();\n\t}\n\n\tvoid IHotloadManaged.Persisted()\n\t{\n\t\tif ( !inputHookInstalled ) return;\n\t\tRemoveInputHook();\n\t\tInstallInputHook();\n\t}\n\n\tvoid IHotloadManaged.Failed()\n\t{\n\t\tDispose();\n\t}\n\n\tIntPtr HandleNativeInput( IntPtr window, uint message, UIntPtr wParam, IntPtr lParam, UIntPtr id, UIntPtr data )\n\t{\n\t\t// Focus only follows a deliberate click. The keyboard hook routes this\n\t\t// focused native child's keys before Qt can consume editor shortcuts.\n\t\ttry\n\t\t{\n\t\t\tif ( message is 0x0007 or 0x0008 ) keyboard?.FocusChanged( message == 0x0007 );\n\t\t\tif ( message is >= 0x0200 and <= 0x0209 )\n\t\t\t{\n\t\t\t\tusing var scope = chat.Session.EnterMenuScope();\n\t\t\t\tvar coordinates = lParam.ToInt64();\n\t\t\t\t// SDL retains mouse tracking, leave events and wheel routing.\n\t\t\t\tif ( message == 0x0200 )\n\t\t\t\t{\n\t\t\t\t\tEngineChatBridge.Invoke( mouseMove, null, sdlHandle, (float)(short)coordinates, (float)(short)(coordinates >> 16), 0f, 0f );\n\t\t\t\t\treturn DefSubclassProc( window, message, wParam, lParam );\n\t\t\t\t}\n\t\t\t\tvar down = message is 0x0201 or 0x0203 or 0x0204 or 0x0206 or 0x0207 or 0x0209;\n\t\t\t\tif ( down ) { widget.Focus(); SetFocus( window ); SetCapture( window ); }\n\t\t\t\telse ReleaseCapture();\n\t\t\t\t// Qt focus briefly leaves the SDL child and clears its mouse state.\n\t\t\t\t// Restore the click position after focus, before dispatching the button.\n\t\t\t\tEngineChatBridge.Invoke( mouseMove, null, sdlHandle, (float)(short)coordinates, (float)(short)(coordinates >> 16), 0f, 0f );\n\t\t\t\tvar button = buttons[message <= 0x0203 ? 0 : message <= 0x0206 ? 1 : 2];\n\t\t\t\tvar modifiers = KeyboardModifiers.None;\n\t\t\t\tif ( (GetKeyState( 0x10 ) & 0x8000) != 0 ) modifiers |= KeyboardModifiers.Shift;\n\t\t\t\tif ( (GetKeyState( 0x11 ) & 0x8000) != 0 ) modifiers |= KeyboardModifiers.Ctrl;\n\t\t\t\tif ( (GetKeyState( 0x12 ) & 0x8000) != 0 ) modifiers |= KeyboardModifiers.Alt;\n\t\t\t\tEngineChatBridge.Invoke( mouseButton, null, sdlHandle, button, down, message is 0x0203 or 0x0206 or 0x0209 ? 2 : 1, modifiers );\n\t\t\t\t// Process each edge before Windows dispatches the next one. Otherwise a\n\t\t\t\t// press and release in the same frame can disappear from the UI state.\n\t\t\t\tEngineChatBridge.Invoke( frame, chat );\n\t\t\t\treturn IntPtr.Zero;\n\t\t\t}\n\t\t}\n\t\tcatch ( Exception e ) { Log.Error( e, \"Global chat native mouse input failed.\" ); }\n\t\tif ( message == 0x0082 )\n\t\t{\n\t\t\tRemoveInputHook();\n\t\t}\n\t\treturn DefSubclassProc( window, message, wParam, lParam );\n\t}\n\n\tpublic void Dispose()\n\t{\n\t\tkeyboard?.Dispose();\n\t\tRemoveInputHook();\n\t}\n\n\tvoid RemoveInputHook()\n\t{\n\t\tif ( !inputHookInstalled ) return;\n\t\tvar registeredCallback = (SubclassProc)GCHandle.FromIntPtr( callbackRoot ).Target;\n\t\tif ( !RemoveWindowSubclass( child, registeredCallback, 1 ) ) throw new InvalidOperationException( \"Could not remove input handling for the chat dock.\" );\n\t\tinputHookInstalled = false;\n\t\tGCHandle.FromIntPtr( callbackRoot ).Free();\n\t\tcallbackRoot = IntPtr.Zero;\n\t}\n\n\tinternal bool IsAttached => GetParent( child ) == parent;\n\tinternal IntPtr Handle => child;\n\n\tinternal void UpdateBounds()\n\t{\n\t\tif ( !GetClientRect( parent, out var target ) ) throw new Win32Exception( Marshal.GetLastPInvokeError() );\n\t\tif ( !GetClientRect( child, out var current ) ) throw new Win32Exception( Marshal.GetLastPInvokeError() );\n\t\tif ( target.Right < 1 || target.Bottom < 1 || (positioned && target.Right == current.Right && target.Bottom == current.Bottom) ) return;\n\t\t// Native client sizes are already in device pixels. This also handles DPI changes.\n\t\tif ( !SetWindowPos( child, IntPtr.Zero, 0, 0, target.Right, target.Bottom, 0x0014 | 0x0020 ) ) throw new Win32Exception( Marshal.GetLastPInvokeError() );\n\t\tpositioned = true;\n\t}\n\n\t[StructLayout( LayoutKind.Sequential )]\n\tstruct NativeRect { internal int Left, Top, Right, Bottom; }\n\t[UnmanagedFunctionPointer( CallingConvention.Winapi )]\n\tdelegate IntPtr SubclassProc( IntPtr window, uint message, UIntPtr wParam, IntPtr lParam, UIntPtr id, UIntPtr data );\n\t[DllImport( \"comctl32.dll\" )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tstatic extern bool SetWindowSubclass( IntPtr window, SubclassProc callback, UIntPtr id, UIntPtr data );\n\t[DllImport( \"comctl32.dll\" )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tstatic extern bool RemoveWindowSubclass( IntPtr window, SubclassProc callback, UIntPtr id );\n\t[DllImport( \"comctl32.dll\" )]\n\tstatic extern IntPtr DefSubclassProc( IntPtr window, uint message, UIntPtr wParam, IntPtr lParam );\n\t[DllImport( \"user32.dll\" )]\n\tstatic extern IntPtr SetFocus( IntPtr window );\n\t[DllImport( \"user32.dll\" )]\n\tstatic extern IntPtr SetCapture( IntPtr window );\n\t[DllImport( \"user32.dll\" )]\n\tstatic extern bool ReleaseCapture();\n\t[DllImport( \"user32.dll\" )]\n\tstatic extern short GetKeyState( int key );\n\t[DllImport( \"user32.dll\", EntryPoint = \"GetWindowLongPtrW\", SetLastError = true )]\n\tstatic extern IntPtr GetWindowLongPtr( IntPtr window, int index );\n\t[DllImport( \"user32.dll\", EntryPoint = \"SetWindowLongPtrW\", SetLastError = true )]\n\tstatic extern IntPtr SetWindowLongPtr( IntPtr window, int index, IntPtr value );\n\t[DllImport( \"user32.dll\", SetLastError = true )]\n\tstatic extern IntPtr SetParent( IntPtr child, IntPtr parent );\n\t[DllImport( \"user32.dll\" )]\n\tstatic extern IntPtr GetParent( IntPtr child );\n\t[DllImport( \"user32.dll\", SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tstatic extern bool GetClientRect( IntPtr window, out NativeRect rect );\n\t[DllImport( \"user32.dll\", SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tstatic extern bool SetWindowPos( IntPtr window, IntPtr after, int x, int y, int width, int height, uint flags );\n}\n"
},
{
"Ident": "thedimin.global_chat",
"Path": "Editor/ChatNativeInputValidation.cs",
"FileName": "ChatNativeInputValidation.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 422392,
"IsPrivate": false,
"Code": "using Sandbox;\nusing Sandbox.UI;\nusing System;\nusing System.ComponentModel;\nusing System.Runtime.InteropServices;\nusing System.Threading.Tasks;\n\nnamespace Editor.GlobalChat;\n\n/// <summary>Checks the Windows mouse, focus, keyboard and SDL route without sending chat.</summary>\ninternal static class ChatNativeInputValidation\n{\n\tinternal static async Task<object> Run( GlobalChatWindow window )\n\t{\n\t\tif ( !OperatingSystem.IsWindows() ) throw new PlatformNotSupportedException( \"Native chat input validation requires Windows.\" );\n\t\tif ( (GetAsyncKeyState( 0x10 ) & 0x8000) != 0 || (GetAsyncKeyState( 0x11 ) & 0x8000) != 0 || (GetAsyncKeyState( 0x12 ) & 0x8000) != 0 )\n\t\t\tthrow new InvalidOperationException( \"Release Shift, Ctrl and Alt before checking native chat input.\" );\n\t\tusing var scope = window.Session.EnterMenuScope();\n\t\tvar host = (GlobalChatDock)GlobalChatDock.Dock.Widget;\n\t\tvar child = host.NativeHandle;\n\t\tvar input = (TextEntry)ChatViewValidation.Find( window.Root, \"chat-input\" );\n\t\tvar draft = window.Session.Draft;\n\t\tvar previousFocus = GetFocus();\n\t\tif ( !GetCursorPos( out var cursor ) ) throw new Win32Exception( Marshal.GetLastPInvokeError() );\n\t\ttry\n\t\t{\n\t\t\twindow.View.DismissUserActions();\n\t\t\tinput.Text = \"\";\n\t\t\twindow.Session.Draft = \"\";\n\t\t\tawait Task.Delay( 150 );\n\t\t\tvar root = GetAncestor( child, 2 );\n\t\t\thost.GetWindow().Raise();\n\t\t\thost.Focus();\n\t\t\tSetForegroundWindow( root );\n\t\t\tSetFocus( root );\n\t\t\tvar point = new NativePoint { X = (int)input.Box.Rect.Center.x, Y = (int)input.Box.Rect.Center.y };\n\t\t\tif ( !ClientToScreen( child, ref point ) || !SetCursorPos( point.X, point.Y ) ) throw new Win32Exception( Marshal.GetLastPInvokeError() );\n\t\t\tvar underCursor = WindowFromPoint( point );\n\t\t\tGetWindowRect( child, out var nativeRect );\n\t\t\tGetCursorPos( out var actualCursor );\n\t\t\tSend( [new() { Type = 0, Data = new() { Mouse = new() { Flags = 0x0002 } } }, new() { Type = 0, Data = new() { Mouse = new() { Flags = 0x0004 } } }] );\n\t\t\tawait Task.Delay( 250 );\n\t\t\tif ( GetFocus() != child || !input.HasFocus ) throw new InvalidOperationException( $\"Clicking the message field did not focus chat. Windows focus: {GetFocus() == child}; field focus: {input.HasFocus}; hit chat window: {underCursor == child}; target: {point.X},{point.Y}; cursor: {actualCursor.X},{actualCursor.Y}; window: {nativeRect.Left},{nativeRect.Top},{nativeRect.Right},{nativeRect.Bottom}; Qt: {host.ScreenPosition}; visible: {IsWindowVisible( child )}; root: {root}; hit: {underCursor}.\" );\n\t\t\tconst string sample = \"Native keyboard check \u2713 \ud83d\ude0a ~ `\";\n\t\t\tTypeUnicode( sample );\n\t\t\tawait Task.Delay( 300 );\n\t\t\tif ( input.Text != sample || window.Session.Draft != sample ) throw new InvalidOperationException( $\"Windows typing did not reach chat: expected '{sample}', received '{input.Text}'. Field focus: {input.HasFocus}; dock focus: {host.IsFocused}; editor focus: {Application.FocusWidget?.GetType().FullName}.\" );\n\t\t\tPressKey( 0x08 );\n\t\t\tawait Task.Delay( 200 );\n\t\t\tif ( input.Text != sample[..^1] || window.Session.Draft != input.Text ) throw new InvalidOperationException( \"Windows Backspace did not edit the chat draft.\" );\n\t\t\tPressKey( 0x24 ); // Home followed immediately by text must retain event order.\n\t\t\tTypeUnicode( \"#@\" );\n\t\t\tawait Task.Delay( 150 );\n\t\t\tif ( input.Text != \"#@\" + sample[..^1] ) throw new InvalidOperationException( \"Home followed by typing did not retain keyboard event order.\" );\n\t\t\tPressKey( 0x41, control: true );\n\t\t\tPressKey( 0x08 );\n\t\t\tawait Task.Delay( 150 );\n\t\t\tif ( input.Text.Length != 0 || window.Session.Draft.Length != 0 ) throw new InvalidOperationException( \"Ctrl+A and Backspace did not clear the draft.\" );\n\t\t\t// Test the current clipboard through the public paste policy. Do not replace\n\t\t\t// clipboard contents or expose them in the result.\n\t\t\tvar clipboardText = EditorUtility.Clipboard.Paste();\n\t\t\tinput.OnPaste( clipboardText );\n\t\t\tvar pasted = input.Text;\n\t\t\tinput.Text = \"Paste selection check\";\n\t\t\twindow.Session.Draft = input.Text;\n\t\t\t// The engine does not invoke paste when the clipboard has no text.\n\t\t\tif ( string.IsNullOrEmpty( clipboardText ) ) pasted = input.Text;\n\t\t\tPressKey( 0x41, control: true );\n\t\t\tPressKey( 0x56, control: true );\n\t\t\tawait Task.Delay( 150 );\n\t\t\tif ( input.Text != pasted || window.Session.Draft != pasted ) throw new InvalidOperationException( \"Ctrl+V did not reach the chat paste policy.\" );\n\t\t\tSetFocus( root );\n\t\t\tTypeUnicode( \"\u2713\" );\n\t\t\tawait Task.Delay( 150 );\n\t\t\tif ( input.Text != pasted ) throw new InvalidOperationException( \"Chat consumed keys after losing native focus.\" );\n\t\t\treturn new { NativeClickFocus = true, NativeUnicodeTyping = true, NativeBackspace = true, NativeHome = true, NativeSelectAll = true, NativePaste = true, FocusReleased = true, DraftBinding = true, SentMessages = 0 };\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tinput.Text = draft;\n\t\t\twindow.Session.Draft = draft;\n\t\t\tEditorCookie.Set( \"GlobalChat.Draft\", draft );\n\t\t\tSetCursorPos( cursor.X, cursor.Y );\n\t\t\tif ( previousFocus != IntPtr.Zero ) SetFocus( previousFocus );\n\t\t}\n\t}\n\n\tinternal static async Task Click( GlobalChatWindow window, Panel target )\n\t{\n\t\tif ( target is null ) throw new InvalidOperationException( \"The expected Razor control is absent.\" );\n\t\tvar host = (GlobalChatDock)GlobalChatDock.Dock.Widget;\n\t\tvar child = host.NativeHandle;\n\t\tif ( !GetCursorPos( out var cursor ) ) throw new Win32Exception( Marshal.GetLastPInvokeError() );\n\t\ttry\n\t\t{\n\t\t\thost.GetWindow().Raise();\n\t\t\thost.Focus();\n\t\t\tSetForegroundWindow( GetAncestor( child, 2 ) );\n\t\t\tawait Task.Delay( 100 );\n\t\t\tvar point = new NativePoint { X = (int)target.Box.Rect.Center.x, Y = (int)target.Box.Rect.Center.y };\n\t\t\tif ( !ClientToScreen( child, ref point ) || !SetCursorPos( point.X, point.Y ) ) throw new Win32Exception( Marshal.GetLastPInvokeError() );\n\t\t\tawait Task.Delay( 100 );\n\t\t\tif ( WindowFromPoint( point ) != child )\n\t\t\t{\n\t\t\t\tGetWindowRect( child, out var rect );\n\t\t\t\tthrow new InvalidOperationException( $\"The chat control is covered by another window. Target: {point.X},{point.Y}; child bounds: {rect.Left},{rect.Top},{rect.Right},{rect.Bottom}; hit: {WindowFromPoint( point )}; child: {child}; root: {GetAncestor( child, 2 )}.\" );\n\t\t\t}\n\t\t\tSend( [new() { Type = 0, Data = new() { Mouse = new() { Flags = 0x0002 } } }] );\n\t\t\tawait Task.Delay( 50 );\n\t\t\tSend( [new() { Type = 0, Data = new() { Mouse = new() { Flags = 0x0004 } } }] );\n\t\t\tawait Task.Delay( 150 );\n\t\t}\n\t\tfinally { SetCursorPos( cursor.X, cursor.Y ); }\n\t}\n\n\tinternal static void TypeUnicode( string text )\n\t{\n\t\tvar keys = new NativeInput[text.Length * 2];\n\t\tfor ( var i = 0; i < text.Length; i++ )\n\t\t{\n\t\t\tkeys[i * 2] = new() { Type = 1, Data = new() { Keyboard = new() { Scan = text[i], Flags = 0x0004 } } };\n\t\t\tkeys[i * 2 + 1] = new() { Type = 1, Data = new() { Keyboard = new() { Scan = text[i], Flags = 0x0006 } } };\n\t\t}\n\t\tSend( keys );\n\t}\n\n\tinternal static void PressKey( ushort key, bool control = false )\n\t{\n\t\tvar press = new NativeInput { Type = 1, Data = new() { Keyboard = new() { Key = key } } };\n\t\tvar release = new NativeInput { Type = 1, Data = new() { Keyboard = new() { Key = key, Flags = 0x0002 } } };\n\t\tif ( control ) Send( [new() { Type = 1, Data = new() { Keyboard = new() { Key = 0x11 } } }, press, release, new() { Type = 1, Data = new() { Keyboard = new() { Key = 0x11, Flags = 0x0002 } } }] );\n\t\telse Send( [press, release] );\n\t}\n\n\tstatic void Send( NativeInput[] events )\n\t{\n\t\tif ( SendInput( (uint)events.Length, events, Marshal.SizeOf<NativeInput>() ) != events.Length ) throw new Win32Exception( Marshal.GetLastPInvokeError(), \"Windows did not accept the native input check.\" );\n\t}\n\n\t[StructLayout( LayoutKind.Sequential )]\n\tstruct NativeInput { internal uint Type; internal InputData Data; }\n\t[StructLayout( LayoutKind.Explicit )]\n\tstruct InputData { [FieldOffset( 0 )] internal MouseInput Mouse; [FieldOffset( 0 )] internal KeyboardInput Keyboard; }\n\t[StructLayout( LayoutKind.Sequential )]\n\tstruct MouseInput { internal int X, Y; internal uint Data, Flags, Time; internal UIntPtr Extra; }\n\t[StructLayout( LayoutKind.Sequential )]\n\tstruct KeyboardInput { internal ushort Key, Scan; internal uint Flags, Time; internal UIntPtr Extra; }\n\t[StructLayout( LayoutKind.Sequential )]\n\tstruct NativePoint { internal int X, Y; }\n\t[StructLayout( LayoutKind.Sequential )]\n\tstruct NativeRect { internal int Left, Top, Right, Bottom; }\n\t[DllImport( \"user32.dll\", SetLastError = true )]\n\tstatic extern uint SendInput( uint count, NativeInput[] events, int size );\n\t[DllImport( \"user32.dll\" )] static extern IntPtr GetFocus();\n\t[DllImport( \"user32.dll\" )] static extern IntPtr WindowFromPoint( NativePoint point );\n\t[DllImport( \"user32.dll\" )] static extern bool GetWindowRect( IntPtr window, out NativeRect rect );\n\t[DllImport( \"user32.dll\" )] static extern bool IsWindowVisible( IntPtr window );\n\t[DllImport( \"user32.dll\" )] static extern IntPtr SetFocus( IntPtr window );\n\t[DllImport( \"user32.dll\" )] static extern IntPtr GetAncestor( IntPtr window, uint flags );\n\t[DllImport( \"user32.dll\" )] static extern bool SetForegroundWindow( IntPtr window );\n\t[DllImport( \"user32.dll\" )] static extern short GetAsyncKeyState( int key );\n\t[DllImport( \"user32.dll\", SetLastError = true )] static extern bool GetCursorPos( out NativePoint point );\n\t[DllImport( \"user32.dll\", SetLastError = true )] static extern bool SetCursorPos( int x, int y );\n\t[DllImport( \"user32.dll\", SetLastError = true )] static extern bool ClientToScreen( IntPtr window, ref NativePoint point );\n}\n"
},
{
"Ident": "thedimin.global_chat",
"Path": "Editor/GlobalChatTheme.cs",
"FileName": "GlobalChatTheme.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 422392,
"IsPrivate": false,
"Code": "namespace Editor.GlobalChat;\n\n// Menu chat SCSS, with class selectors for the editor Razor view. Tokens and grain stay engine-owned.\ninternal static class GlobalChatTheme\n{\n\tinternal const string Styles = \"\"\"\n@import \"/styles/_front.scss\";\n.editor-global-chat\n{\r\n\t@include front-card;\r\n\tflex-direction: row;\r\n\tflex-shrink: 1;\r\n\tmin-width: 500px;\r\n\tflex-grow: 1;\r\n\tmin-height: 0;\r\n\tfont-family: $front-font;\r\n\tcolor: $front-body;\r\n\tgap: 0;\r\n\tborder-radius: $front-radius;\r\n\toverflow: hidden;\r\n\t.global-conversation\r\n\t{\r\n\t\tmin-height: 0;\r\n\t\twidth: 0;\r\n\t\tflex-shrink: 1;\r\n\t\tflex-grow: 1;\r\n\t\tmin-width: 0;\r\n\t\tflex-direction: column;\r\n\t}\r\n\r\n\t.conversation-heading\r\n\t{\r\n\t\talign-items: center;\r\n\t\tgap: 8px;\r\n\t\tpadding: 6px 16px;\n\t\tmin-height: 40px;\n\t\tborder-bottom: 1px solid $front-border;\r\n\t\tflex-shrink: 0;\r\n\r\n\t\t> label\n\t\t{\r\n\t\t\tflex-grow: 1;\r\n\t\t\tflex-shrink: 1;\n\t\t\tmin-width: 0;\n\t\t\twidth: 0;\n\t\t\tfont-size: 11px;\n\t\t\tcolor: $front-secondary;\n\t\t\twhite-space: nowrap;\n\t\t\toverflow: hidden;\n\t\t\ttext-overflow: ellipsis;\n\t\t}\r\n\r\n\t\tbutton\r\n\t\t{\r\n\t\t\t@include front-icon-button;\r\n\t\t\twidth: 28px;\n\t\t\theight: 28px;\n\t\t\tpadding: 4px;\n\t\t\ticon { font-size: 18px; }\n\t\t\t&.active { color: $front-heading; }\n\t\t}\r\n\t}\r\n\r\n\t.chat-notice\r\n\t{\r\n\t\tmargin: 12px 24px 0;\r\n\t\tpadding: 12px;\r\n\t\tbackground-color: rgba( $front-primary, 0.12 );\r\n\t\tcolor: $front-body;\r\n\t\tflex-shrink: 0;\r\n\t}\r\n\r\n\t.chat-notice.error\r\n\t{\r\n\t\tbackground-color: rgba( $front-danger, 0.1 );\r\n\t\tcolor: $front-danger;\r\n\t}\r\n\r\n\t.chat-messages\r\n\t{\r\n\t\tflex-grow: 1;\r\n\t\tmin-height: 0;\r\n\t\tflex-direction: column;\r\n\t\toverflow-y: scroll;\r\n\t\toverflow-x: hidden;\r\n\t\tpadding: $front-inset;\r\n\t\tgap: 18px;\r\n\t}\r\n\r\n\t.chat-empty\r\n\t{\r\n\t\tflex-grow: 1;\r\n\t\talign-items: center;\r\n\t\tjustify-content: center;\r\n\t\tflex-direction: column;\r\n\t\tgap: 12px;\r\n\t\tcolor: $front-secondary;\r\n\t}\r\n\r\n\t.chat-empty icon\r\n\t{\r\n\t\tfont-size: 40px;\r\n\t}\r\n}\r\n\n\r\n.chat-message\n{\r\n\tgap: 12px;\r\n\tflex-shrink: 0;\r\n\r\n\t.chat-avatar\r\n\t{\r\n\t\t@include front-row-media;\r\n\t\tcursor: pointer;\r\n\t\tborder-radius: 50%;\r\n\t}\r\n\r\n\t.chat-message-body\r\n\t{\r\n\t\tflex-direction: column;\r\n\t\tmin-width: 0;\r\n\t\tflex-grow: 1;\r\n\t\tgap: 6px;\r\n\t\tword-break: normal;\r\n\t}\r\n\r\n\t.chat-name\r\n\t{\r\n\t\talign-self: flex-start;\r\n\t\tcursor: pointer;\r\n\t\tfont-weight: 600;\r\n\t\tcolor: $front-heading;\r\n\t\tfont-size: 13px;\r\n\r\n\t\t&:hover\r\n\t\t{\r\n\t\t\tcolor: $front-link;\r\n\t\t}\r\n\t}\r\n}\r\n\n\r\n.chat-members\n{\r\n\twidth: 200px;\r\n\tmin-width: 200px;\r\n\tmax-width: 200px;\r\n\tmin-height: 0;\r\n\tborder-left: 1px solid $front-border;\r\n\tflex-shrink: 0;\r\n\tflex-direction: column;\r\n\tpadding: $front-inset;\r\n\tgap: 16px;\r\n\r\n\t.people-heading\r\n\t{\r\n\t\t@include front-section-label;\r\n\t\tjustify-content: space-between;\r\n\t\tflex-shrink: 0;\r\n\t}\r\n\r\n\t.chat-people\r\n\t{\r\n\t\t// Fill the side column and let the virtual list own its scrolling.\r\n\t\theight: 0;\r\n\t\twidth: 100%;\r\n\t\tflex-grow: 1;\r\n\t\tmin-height: 0;\r\n\t\tgap: 0;\r\n\t}\r\n\r\n\t.chat-person\r\n\t{\r\n\t\twidth: 100%;\r\n\t\theight: 44px;\r\n\t\talign-items: center;\r\n\t\tgap: 10px;\r\n\t\tpadding: 6px;\r\n\t\tflex-shrink: 0;\r\n\t\tfont-size: 13px;\r\n\t\tborder-radius: 6px;\r\n\t\tcursor: pointer;\r\n\r\n\t\t.chat-avatar\r\n\t\t{\r\n\t\t\t@include front-row-media;\r\n\t\t\twidth: 28px;\r\n\t\t\theight: 28px;\r\n\t\t\tcursor: pointer;\r\n\t\t\tborder-radius: 50%;\r\n\t\t}\r\n\r\n\t\t> label\r\n\t\t{\r\n\t\t\twidth: 0;\r\n\t\t\tflex-grow: 1;\r\n\t\t\tmin-width: 0;\r\n\t\t\twhite-space: nowrap;\r\n\t\t\toverflow: hidden;\r\n\t\t\ttext-overflow: ellipsis;\r\n\t\t}\r\n\r\n\t\t&:hover\r\n\t\t{\r\n\t\t\tbackground-color: rgba( 255, 255, 255, 0.05 );\r\n\t\t\tsound-in: \"ui.button.over\";\r\n\t\t}\r\n\r\n\t\t&:active\r\n\t\t{\r\n\t\t\tsound-in: \"ui.button.press\";\r\n\t\t}\r\n\t}\r\n}\r\n\n\r\n.chat-composer-container\n{\r\n\tflex-direction: column;\r\n\tflex-shrink: 0;\r\n\r\n\t.chat-composer\r\n\t{\r\n\t\tmargin: 0 $front-inset;\r\n\t\tgap: 8px;\r\n\t\tflex-shrink: 0;\r\n\t}\r\n\r\n\t.chat-composer textentry\r\n\t{\r\n\t\twidth: 0;\r\n\t\tmin-width: 0;\r\n\t\tflex-grow: 1;\r\n\t\tpadding: 14px;\r\n\t\tbackground-color: $front-card-deep;\r\n\t\tborder: 1px solid $front-border;\r\n\t\tborder-radius: $front-radius;\r\n\r\n\t\t&:focus\r\n\t\t{\r\n\t\t\tborder-color: $front-primary;\r\n\t\t}\r\n\t}\r\n\r\n\t.chat-actions\r\n\t{\r\n\t\tflex-wrap: wrap;\r\n\t\tpadding: $front-inset;\r\n\t\tgap: 12px;\r\n\t\talign-items: center;\r\n\t\tflex-shrink: 0;\r\n\t\tfont-size: 13px;\r\n\t\tcolor: $front-secondary;\r\n\t}\r\n\r\n\tbutton\r\n\t{\r\n\t\tcursor: pointer;\r\n\r\n\t\t&:hover\r\n\t\t{\r\n\t\t\tsound-in: \"ui.button.over\";\r\n\t\t}\r\n\r\n\t\t&:active\r\n\t\t{\r\n\t\t\tsound-in: \"ui.button.press\";\r\n\t\t}\r\n\t}\r\n\r\n\t.chat-actions button\r\n\t{\r\n\t\t@include front-pill;\r\n\r\n\t\ticonpanel\r\n\t\t{\r\n\t\t\tfont-size: 14px;\r\n\t\t}\r\n\t}\r\n\r\n\t.chat-composer button\r\n\t{\r\n\t\t@include front-icon-button;\r\n\t\talign-self: center;\r\n\t}\r\n\r\n\tbutton.disabled\r\n\t{\r\n\t\topacity: 0.3;\r\n\t\tpointer-events: none;\r\n\t}\r\n}\r\n\n\r\n.party-invitation\n{\r\n\talign-self: flex-start;\r\n\tmax-width: 100%;\r\n\tflex-direction: row;\r\n\tflex-wrap: wrap;\r\n\talign-items: center;\r\n\tgap: 16px;\r\n\tpadding: 12px;\r\n\tborder: 1px solid $front-border;\r\n\tborder-radius: $front-radius;\r\n\tbackground-color: $front-card-deep;\r\n\tfont-family: $front-font;\r\n\tword-break: normal;\r\n\r\n\t.invitation-summary\r\n\t{\r\n\t\tflex-direction: column;\r\n\t\tflex-shrink: 1;\r\n\t\tmin-width: 0;\r\n\t\tmax-width: 100%;\r\n\t\tgap: 4px;\r\n\t}\r\n\r\n\t.invitation-name\r\n\t{\r\n\t\tcolor: $front-heading;\r\n\t\tfont-weight: 600;\r\n\t\twhite-space: nowrap;\r\n\t\toverflow: hidden;\r\n\t\ttext-overflow: ellipsis;\r\n\t}\r\n\r\n\t.invitation-details\r\n\t{\r\n\t\tcolor: $front-secondary;\r\n\t\tfont-size: 12px;\r\n\t}\r\n\r\n\tbutton\r\n\t{\r\n\t\t@include front-pill;\r\n\t\tcursor: pointer;\r\n\r\n\t\t&:hover\r\n\t\t{\r\n\t\t\tsound-in: \"ui.button.over\";\r\n\t\t}\r\n\r\n\t\t&:active\r\n\t\t{\r\n\t\t\tsound-in: \"ui.button.press\";\r\n\t\t}\r\n\t}\r\n\r\n\tbutton.primary\r\n\t{\r\n\t\tbackground-color: $front-primary;\r\n\t\tborder-color: $front-primary;\r\n\r\n\t\tlabel\r\n\t\t{\r\n\t\t\tcolor: $front-heading;\r\n\t\t}\r\n\r\n\t\t&:hover\r\n\t\t{\r\n\t\t\tbackground-color: lighten( $front-primary, 8% );\r\n\t\t}\r\n\t}\r\n\r\n\tbutton.disabled\r\n\t{\r\n\t\topacity: 0.3;\r\n\t\tpointer-events: none;\r\n\t}\r\n}\r\n.editor-global-chat { width: 100%; height: 100%; min-width: 0; border-radius: 0; font-size: 13px; pointer-events: all; }\n.chat-members { width: 184px; min-width: 184px; max-width: 184px; }\n.chat-members .chat-people { flex-direction: column; overflow-y: scroll; overflow-x: hidden; }\n.chat-settings { padding: 12px 16px; gap: 16px; flex-wrap: wrap; flex-shrink: 0; border-bottom: 1px solid $front-border; font-size: 12px; }\n.chat-settings checkbox { gap: 8px; align-items: center; cursor: pointer; }\n.chat-settings checkbox .checkmark { width: 16px; height: 16px; border: 1px solid $front-secondary; border-radius: 3px; margin-right: 6px; }\n.chat-settings checkbox.checked .checkmark { background-color: $front-primary; border-color: $front-primary; }\n.editor-global-chat::after { display: none; background-image: none; }\n.editor-global-chat .chat-grain { position: absolute; left: 0; top: 0; right: 0; bottom: 0; background-repeat: repeat; background-size: 512px 512px; opacity: $front-grain-opacity; pointer-events: none; }\n.editor-global-chat .global-conversation, .editor-global-chat .chat-members { z-index: 1; }\n.editor-global-chat icon { font-family: \"Material Icons\"; }\n.chat-settings checkbox .checkmark { font-family: \"Material Icons\"; font-size: 14px; color: transparent; }\n.chat-settings checkbox.checked .checkmark { color: $front-heading; }\n.chat-user-dismiss { position: absolute; left: 0; top: 0; right: 0; bottom: 0; z-index: 10; pointer-events: all; }\n.chat-user-actions\n{\n\tposition: absolute; left: 8px; top: 8px; z-index: 11;\n\twidth: 224px; padding: 8px; flex-direction: column; gap: 2px;\n\tbackground-color: $front-card-deep; border: 1px solid $front-border; border-radius: $front-radius;\n\tbox-shadow: 0px 8px 24px rgba( black, 0.45 );\n\t.chat-user-heading { align-items: center; padding: 4px 6px 8px; border-bottom: 1px solid $front-border; margin-bottom: 4px; }\n\t.chat-user-heading > label { flex-grow: 1; flex-shrink: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; color: $front-heading; }\n\tbutton { padding: 9px 6px; gap: 10px; align-items: center; border-radius: 4px; cursor: pointer; font-size: 12px; }\n\tbutton:hover { background-color: rgba( $front-primary, 0.16 ); color: $front-heading; }\n\tbutton.disabled { opacity: 0.35; pointer-events: none; }\n\tbutton icon { font-size: 16px; color: $front-secondary; }\n\t.chat-user-heading button { padding: 2px; }\n}\n\"\"\";\n}\r\n"
},
{
"Ident": "thedimin.global_chat",
"Path": "Editor/GlobalChatView.razor",
"FileName": "GlobalChatView.razor",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 422392,
"IsPrivate": false,
"Code": "@using Microsoft.AspNetCore.Components\n@using Microsoft.AspNetCore.Components.Rendering\n@using Sandbox\n@using Sandbox.UI\n@using System\n@using System.Linq\n@namespace Editor.GlobalChat\n@inherits Panel\n\n<root class=\"editor-global-chat\">\n\t<div class=\"chat-grain\" @ref=\"grain\"></div>\n\t<div class=\"global-conversation\">\n\t\t<div id=\"chat-conversation-heading\" class=\"conversation-heading\">\n\t\t\t<label>@(Session.Channel is null ? \"Connecting to global chat\u2026\" : $\"{Session.Channel.MemberCount} here \u00b7 say hello or share your party\")</label>\n\t\t\t<Sandbox.UI.Button Icon=\"refresh\" onclick=@Reconnect tooltip=\"Reconnect to global chat\" />\n\t\t\t<Sandbox.UI.Button id=\"chat-settings-toggle\" Icon=\"settings\" onclick=@ToggleSettings tooltip=\"Mention settings\" />\n\t\t\t<Sandbox.UI.Button id=\"chat-members-toggle\" class=@(MembersVisible ? \"active\" : \"\") Icon=\"group\" onclick=@(() => MembersVisible = !MembersVisible) tooltip=@(MembersVisible ? \"Hide users in chat\" : \"Show users in chat\") />\n\t\t</div>\n\t\t@if ( settingsOpen )\n\t\t{\n\t\t\t<div class=\"chat-settings\">\n\t\t\t\t<Sandbox.UI.Checkbox id=\"chat-popups\" [email protected] ValueChanged=@((bool value) => { GlobalChatWindow.MentionPopups = value; })>Mention popups</Sandbox.UI.Checkbox>\n\t\t\t\t<Sandbox.UI.Checkbox id=\"chat-sounds\" [email protected] ValueChanged=@((bool value) => { GlobalChatWindow.MentionSounds = value; })>Mention sound</Sandbox.UI.Checkbox>\n\t\t\t</div>\n\t\t}\n\t\t@if ( Session.Connecting ) { <div class=\"chat-notice\">Joining global chat\u2026</div> }\n\t\t@if ( !string.IsNullOrEmpty( Session.Error ?? Host.PartyError ) ) { <div class=\"chat-notice error\">@(Session.Error ?? Host.PartyError)</div> }\n\t\t<div class=\"chat-messages\" @ref=\"messageList\">\n\t\t\t@if ( DisplayMessages.Count == 0 )\n\t\t\t{\n\t\t\t\t<div class=\"chat-empty\"><icon>forum</icon><label>Say hello. Find your next group.</label></div>\n\t\t\t}\n\t\t\t@foreach ( var message in DisplayMessages.Where( x => !x.Sender.IsBlocked ) )\n\t\t\t{\n\t\t\t\t<div class=\"chat-message\" @key=message.Id>\n\t\t\t\t\t<div class=\"chat-avatar\" style=\"background-image: url(avatar:@message.Sender.Id)\" onclick=@((PanelEvent e) => ShowUserActions( message.Sender, e.This )) onrightclick=@(() => InsertMention( message.Sender ))></div>\n\t\t\t\t\t<div class=\"chat-message-body\">\n\t\t\t\t\t\t<label id=@($\"chat-name-{message.Id}\") class=\"chat-name\" onclick=@((PanelEvent e) => ShowUserActions( message.Sender, e.This )) onrightclick=@(() => InsertMention( message.Sender ))>@message.Sender.Name</label>\n\t\t\t\t\t\t@if ( message.PartyId == 0 ) { <label>@message.Text</label> }\n\t\t\t\t\t\telse\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tvar entry = Session.GetInvitation( message.PartyId );\n\t\t\t\t\t\t\t<div class=\"party-invitation\">\n\t\t\t\t\t\t\t\t<div class=\"invitation-summary\">\n\t\t\t\t\t\t\t\t\t<label class=\"invitation-name\">@(entry?.Name ?? \"Party invitation\")</label>\n\t\t\t\t\t\t\t\t\t<label class=\"invitation-details\">@(string.IsNullOrWhiteSpace( entry?.GameTitle ) ? \"Deciding together\" : entry.GameTitle)</label>\n\t\t\t\t\t\t\t\t\t<label class=\"invitation-details\">@(entry is null ? \"Checking party\u2026\" : !entry.Available ? \"Party unavailable \u00b7 ended or could not be reached\" : $\"{entry.Members}/{entry.Capacity} players \u00b7 {(entry.IsFull ? \"Party full\" : entry.IsPlaying ? \"In game\" : \"Looking for players\")}\")</label>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<Sandbox.UI.Button class=\"primary\" disabled=@(!CanJoin( message.PartyId )) onclick=@(() => JoinParty( message.PartyId )) tooltip=\"Leave your current party before joining another.\">@(PartyRoom.Current?.Id.ValueUnsigned == message.PartyId ? \"Your party\" : \"Join party\")</Sandbox.UI.Button>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t}\n\t\t</div>\n\t\t<div class=\"chat-composer-container\">\n\t\t\t<div class=\"chat-composer\">\n\t\t\t\t<Sandbox.UI.TextEntry id=\"chat-input\" @ref=\"input\" [email protected] OnTextEdited=@SaveDraft MaxLength=@(400) Placeholder=\"Say hello\u2026\" onsubmit=@Submit />\n\t\t\t\t<Sandbox.UI.Button Icon=\"send\" disabled=@(!Session.CanSend) onclick=@Submit tooltip=\"Send message\" />\n\t\t\t</div>\n\t\t\t<div class=\"chat-actions\">\n\t\t\t\t@if ( PartyRoom.Current is { } party )\n\t\t\t\t{\n\t\t\t\t\t<label>@party.MemberCount in your party</label>\n\t\t\t\t\t@if ( party.Owner.IsMe ) { <Sandbox.UI.Button Icon=\"campaign\" disabled=@(!Session.CanInviteParty) onclick=@InviteParty tooltip=\"Post an invitation so anyone in global chat can join\">Post party invite</Sandbox.UI.Button> }\n\t\t\t\t}\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\t@if ( MembersVisible )\n\t{\n\t\t<div id=\"chat-member-list\" class=\"chat-members\">\n\t\t\t<div class=\"people-heading\"><label>In chat</label><label>@(Session.Channel?.MemberCount ?? 0)</label></div>\n\t\t\t<div class=\"chat-people\">\n\t\t\t\t@foreach ( var member in Members )\n\t\t\t\t{\n\t\t\t\t\t<button id=@($\"chat-member-{member.Id}\") class=\"chat-person\" @key=member.Id onclick=@((PanelEvent e) => ShowUserActions( member, e.This )) onrightclick=@(() => InsertMention( member )) tooltip=\"User actions. Right-click to add a mention.\">\n\t\t\t\t\t\t<div class=\"chat-avatar\" style=\"background-image: url(avatar:@member.Id)\"></div><label>@member.Name</label>\n\t\t\t\t\t</button>\n\t\t\t\t}\n\t\t\t</div>\n\t\t</div>\n\t\t}\n\t@if ( actionUser is { } user )\n\t{\n\t\t<div id=\"chat-user-dismiss\" class=\"chat-user-dismiss\" onclick=@DismissUserActions onrightclick=@DismissUserActions></div>\n\t\t<div id=\"chat-user-actions\" class=\"chat-user-actions\" @ref=\"userActions\">\n\t\t\t<div class=\"chat-user-heading\"><label>@user.Name</label><Sandbox.UI.Button Icon=\"close\" onclick=@DismissUserActions tooltip=\"Close user actions\" /></div>\n\t\t\t<Sandbox.UI.Button id=\"chat-user-steam\" Icon=\"open_in_new\" onclick=@(() => OpenProfile( $\"https://steamcommunity.com/profiles/{user.Id}\" ))>Steam profile</Sandbox.UI.Button>\n\t\t\t<Sandbox.UI.Button id=\"chat-user-website\" Icon=\"language\" onclick=@(() => OpenProfile( $\"https://sbox.game/u/{user.Id}\" ))>s&box website profile</Sandbox.UI.Button>\n\t\t\t<Sandbox.UI.Button id=\"chat-user-mention\" Icon=\"alternate_email\" disabled=@(input.Text.Length + GetMentionSuffix( user ).Length > 400) tooltip=\"Add a mention to the draft. Make space first if the message is full.\" onclick=@(() => InsertMention( user ))>Mention in chat</Sandbox.UI.Button>\n\t\t</div>\n\t}\n</root>\n\n@code\n{\n\tinternal GlobalChatSession Session { get; set; }\n\tinternal GlobalChatWindow Host { get; set; }\n\tinternal System.Collections.Generic.List<GlobalChatSession.ChatLine> PreviewMessages { get; set; }\n\tSystem.Collections.Generic.List<GlobalChatSession.ChatLine> DisplayMessages => PreviewMessages ?? Session.Messages;\n\tPanel messageList;\n\tPanel grain;\n\tPanel userActions;\n\tFriend? actionUser;\n\tVector2 actionAnchor;\n\tSandbox.UI.TextEntry input;\n\tbool settingsOpen, scrollAfterRender;\n\tbool membersVisible = EditorCookie.Get( \"GlobalChat.MembersVisible\", true );\n\tinternal bool MembersVisible\n\t{\n\t\tget => membersVisible;\n\t\tset\n\t\t{\n\t\t\tif ( membersVisible == value ) return;\n\t\t\tmembersVisible = value;\n\t\t\tEditorCookie.Set( \"GlobalChat.MembersVisible\", value );\n\t\t\tDismissUserActions();\n\t\t}\n\t}\n\tinternal bool SettingsOpen\n\t{\n\t\tget => settingsOpen;\n\t\tset { settingsOpen = value; StateHasChanged(); }\n\t}\n\tint renderedRevision = -1;\n\tFriend[] Members => Session.Channel?.Members.Where( x => !x.IsBlocked ).OrderBy( x => x.Name, StringComparer.OrdinalIgnoreCase ).ToArray() ?? [];\n\n\tpublic GlobalChatView()\n\t{\n\t\tAcceptsFocus = true;\n\t\tStyleSheet.AddInline( GlobalChatTheme.Styles, \"editor-global-chat-theme\" );\n\t}\n\n\tpublic override void OnHotloaded()\n\t{\n\t\tusing var scope = Session.EnterMenuScope();\n\t\t// Recreate Razor references and event closures after a template change.\n\t\tEngineChatBridge.Invoke( EngineChatBridge.RequireMethod( typeof( Panel ), \"ClearRenderTree\" ), this );\n\t\tbase.OnHotloaded();\n\t\tStyleSheet.AddInline( GlobalChatTheme.Styles, \"editor-global-chat-theme\" );\n\t\trenderedRevision = -1;\n\t\tStateHasChanged();\n\t}\n\n\tvoid SaveDraft( string text )\n\t{\n\t\tSession.Draft = text;\n\t\tEditorCookie.Set( \"GlobalChat.Draft\", text );\n\t}\n\n\tvoid Submit()\n\t{\n\t\tusing var scope = Session.EnterMenuScope();\n\t\tif ( Session.Send( input.Text ) )\n\t\t{\n\t\t\tinput.Text = \"\";\n\t\t\tSaveDraft( \"\" );\n\t\t\tScrollToLatest();\n\t\t}\n\t\tinput.Focus();\n\t}\n\n\tinternal void ScrollToLatest()\n\t{\n\t\tscrollAfterRender = true;\n\t\tStateHasChanged();\n\t}\n\n\tinternal void ShowUserActions( Friend friend, Panel source = null )\n\t{\n\t\tactionUser = friend;\n\t\tactionAnchor = source is null ? new Vector2( Box.Rect.Width - 232, 64 ) * ScaleFromScreen\n\t\t\t: (source.Box.Rect.BottomLeft - Box.Rect.Position) * ScaleFromScreen;\n\t\tFocus();\n\t\tStateHasChanged();\n\t}\n\n\tinternal void DismissUserActions()\n\t{\n\t\tactionUser = null;\n\t\tStateHasChanged();\n\t}\n\n\tvoid OpenProfile( string url )\n\t{\n\t\tSystem.Diagnostics.Process.Start( new System.Diagnostics.ProcessStartInfo( url ) { UseShellExecute = true } );\n\t\tDismissUserActions();\n\t}\n\n\tpublic override void OnButtonTyped( ButtonEvent e )\n\t{\n\t\tif ( e.Button != \"escape\" || actionUser is null ) { base.OnButtonTyped( e ); return; }\n\t\tDismissUserActions();\n\t\tinput.Focus();\n\t\te.StopPropagation = true;\n\t}\n\tvoid ToggleSettings() { settingsOpen = !settingsOpen; StateHasChanged(); }\n\tvoid Reconnect() { Session.Close(); Session.Open(); }\n\n\tvoid InviteParty()\n\t{\n\t\tusing var scope = Session.EnterMenuScope();\n\t\tSession.InviteParty();\n\t}\n\n\tbool CanJoin( ulong id ) => !Host.PartyBusy && PartyRoom.Current is null && Session.GetInvitation( id ) is { Available: true, IsFull: false };\n\tvoid JoinParty( ulong id ) { _ = Host.JoinParty( id ); }\n\n\tvoid InsertMention( Friend member )\n\t{\n\t\tvar mention = GetMentionSuffix( member );\n\t\t// Keep the draft intact when the full mention cannot fit.\n\t\tif ( input.Text.Length + mention.Length > 400 ) return;\n\t\tinput.Text += mention;\n\t\tSaveDraft( input.Text );\n\t\tDismissUserActions();\n\t\tinput.Focus();\n\t}\n\n\tstring GetMentionSuffix( Friend member )\n\t{\n\t\tvar separator = input.Text.Length == 0 || char.IsWhiteSpace( input.Text[^1] ) ? \"\" : \" \";\n\t\treturn separator + \"@\" + member.Name + \" \";\n\t}\n\n\tpublic override void Tick()\n\t{\n\t\tbase.Tick();\n\t\tif ( actionUser is null || !userActions.IsValid() ) return;\n\t\tvar bounds = Box.Rect.Size * ScaleFromScreen;\n\t\tvar size = userActions.Box.Rect.Size * ScaleFromScreen;\n\t\tuserActions.Style.Left = Math.Clamp( actionAnchor.x, 8, Math.Max( 8, bounds.x - size.x - 8 ) );\n\t\tuserActions.Style.Top = Math.Clamp( actionAnchor.y + 4, 8, Math.Max( 8, bounds.y - size.y - 8 ) );\n\t}\n\n\tprotected override void OnAfterTreeRender( bool firstTime )\n\t{\n\t\tbase.OnAfterTreeRender( firstTime );\n\t\t// SCSS url() textures load lazily in the drawing context. Resolve this menu asset\n\t\t// explicitly so editor and play-mode frames keep the same background texture.\n\t\tusing var scope = Session.EnterMenuScope();\n\t\tgrain.Style.BackgroundImage = Texture.Load( \"/ui/panel-paper.png\" );\n\t\tmessageList.PreferScrollToBottom = true;\n\t\tif ( firstTime || scrollAfterRender || (renderedRevision != Session.Revision && messageList.IsScrollAtBottom) ) messageList.TryScrollToBottom();\n\t\trenderedRevision = Session.Revision;\n\t\tscrollAfterRender = false;\n\t}\n\n\tprotected override int BuildHash()\n\t{\n\t\tvar members = new HashCode();\n\t\tforeach ( var member in Members ) { members.Add( member.Id ); members.Add( member.Name ); }\n\t\treturn HashCode.Combine( HashCode.Combine( Session.Revision, PreviewMessages ), Session.InvitationRevision, Session.Connecting, Session.Error, Host.PartyError,\n\t\t\tHashCode.Combine( Session.CanSend, Session.CanInviteParty, PartyRoom.Current?.Id, PartyRoom.Current?.MemberCount, PartyRoom.Current?.Owner.Id, Host.PartyBusy ),\n\t\t\tmembers.ToHashCode(), HashCode.Combine( settingsOpen, MembersVisible, GlobalChatWindow.MentionPopups, GlobalChatWindow.MentionSounds ) );\n\t}\n}\n"
},
{
"Ident": "thedimin.global_chat",
"Path": "Editor/ChatDockKeyboardInput.cs",
"FileName": "ChatDockKeyboardInput.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 422392,
"IsPrivate": false,
"Code": "using Sandbox;\nusing System;\nusing System.Collections.Generic;\nusing System.ComponentModel;\nusing System.Reflection;\nusing System.Runtime.InteropServices;\n\nnamespace Editor.GlobalChat;\n\n/// <summary>Routes keys from the focused child before Qt applies editor shortcuts.</summary>\ninternal sealed class ChatDockKeyboardInput : IDisposable, IHotloadManaged\n{\n\tconst int GetMessageHook = 3;\n\treadonly IntPtr window;\n\treadonly IntPtr sdlHandle;\n\treadonly GlobalChatWindow chat;\n\treadonly MethodInfo translateKey;\n\treadonly MethodInfo keyInput;\n\treadonly MethodInfo textInput;\n\treadonly MethodInfo focusInput;\n\treadonly object surface;\n\treadonly object uiSystem;\n\treadonly MethodInfo tickInput;\n\treadonly PropertyInfo mouseInside;\n\t// Windows retains a function pointer, so the CLR must retain its delegate too.\n\t[SkipHotload] IntPtr callbackRoot;\n\tIntPtr hook;\n\n\tinternal ChatDockKeyboardInput( IntPtr window, IntPtr sdlHandle, GlobalChatWindow chat )\n\t{\n\t\tthis.window = window;\n\t\tthis.sdlHandle = sdlHandle;\n\t\tthis.chat = chat;\n\t\tvar assembly = typeof( Friend ).Assembly;\n\t\tvar input = EngineChatBridge.RequireType( assembly, \"Sandbox.UI.PanelWindowInput\" );\n\t\tvar buttons = EngineChatBridge.RequireType( assembly, \"NativeEngine.ButtonCode\" );\n\t\ttranslateKey = EngineChatBridge.RequireMethod( EngineChatBridge.RequireType( assembly, \"Sandbox.Engine.KeyTranslation\" ), \"VirtualKeyToButtonCode\", typeof( int ) );\n\t\tkeyInput = EngineChatBridge.RequireMethod( input, \"OnKey\", typeof( IntPtr ), buttons, typeof( bool ), typeof( KeyboardModifiers ) );\n\t\ttextInput = EngineChatBridge.RequireMethod( input, \"OnText\", typeof( IntPtr ), typeof( string ) );\n\t\tfocusInput = EngineChatBridge.RequireMethod( input, \"OnFocus\", typeof( IntPtr ), typeof( bool ) );\n\t\tsurface = EngineChatBridge.RequireProperty( typeof( PanelWindow ), \"Surface\" ).GetValue( chat );\n\t\tuiSystem = EngineChatBridge.RequireProperty( surface.GetType(), \"System\" ).GetValue( surface );\n\t\ttickInput = EngineChatBridge.RequireMethod( uiSystem.GetType(), \"TickSurfaceInput\", typeof( bool ) );\n\t\tmouseInside = EngineChatBridge.RequireProperty( surface.GetType(), \"MouseInside\" );\n\t\tInstall();\n\t}\n\n\tinternal void FocusChanged( bool focused )\n\t{\n\t\tusing var scope = chat.Session.EnterMenuScope();\n\t\tEngineChatBridge.Invoke( focusInput, null, sdlHandle, focused );\n\t}\n\n\tvoid Install()\n\t{\n\t\tMessageHook callback = OnMessage;\n\t\tcallbackRoot = GCHandle.ToIntPtr( GCHandle.Alloc( callback ) );\n\t\thook = SetWindowsHookEx( GetMessageHook, callback, IntPtr.Zero, GetWindowThreadProcessId( window, out _ ) );\n\t\tif ( hook == IntPtr.Zero )\n\t\t{\n\t\t\tvar error = Marshal.GetLastPInvokeError();\n\t\t\tGCHandle.FromIntPtr( callbackRoot ).Free();\n\t\t\tcallbackRoot = IntPtr.Zero;\n\t\t\tthrow new Win32Exception( error, \"Could not install chat keyboard input.\" );\n\t\t}\n\t}\n\n\tIntPtr OnMessage( int code, UIntPtr removed, IntPtr pointer )\n\t{\n\t\t// Observe only removed messages for this focused child. Other editor controls\n\t\t// and other applications retain their normal keyboard handling.\n\t\tif ( code >= 0 && removed == (UIntPtr)1 && GetFocus() == window )\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar message = Marshal.PtrToStructure<NativeMessage>( pointer );\n\t\t\t\tif ( message.Window == window && message.Message is 0x0100 or 0x0101 or 0x0102 or 0x0104 or 0x0105 or 0x0106 )\n\t\t\t\t{\n\t\t\t\t\tusing var scope = chat.Session.EnterMenuScope();\n\t\t\t\t\tif ( message.Message is 0x0102 or 0x0106 )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar character = (char)message.Key.ToUInt64();\n\t\t\t\t\t\tif ( !char.IsControl( character ) ) EngineChatBridge.Invoke( textInput, null, sdlHandle, character.ToString() );\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tvar key = (int)message.Key.ToUInt64();\n\t\t\t\t\t\tvar modifiers = KeyboardModifiers.None;\n\t\t\t\t\t\tif ( (GetKeyState( 0x10 ) & 0x8000) != 0 ) modifiers |= KeyboardModifiers.Shift;\n\t\t\t\t\t\tif ( (GetKeyState( 0x11 ) & 0x8000) != 0 ) modifiers |= KeyboardModifiers.Ctrl;\n\t\t\t\t\t\tif ( (GetKeyState( 0x12 ) & 0x8000) != 0 ) modifiers |= KeyboardModifiers.Alt;\n\t\t\t\t\t\t// Leave the operating system's close command intact.\n\t\t\t\t\t\tif ( key == 0x73 && modifiers.HasFlag( KeyboardModifiers.Alt ) ) return CallNextHookEx( hook, code, removed, pointer );\n\t\t\t\t\t\tvar down = message.Message is 0x0100 or 0x0104;\n\t\t\t\t\t\tif ( down ) TranslateMessage( ref message );\n\t\t\t\t\t\t// VK_PACKET carries Unicode text, which arrives in WM_CHAR.\n\t\t\t\t\t\tif ( key != 0xE7 ) EngineChatBridge.Invoke( keyInput, null, sdlHandle, EngineChatBridge.Invoke( translateKey, null, key ), down, modifiers );\n\t\t\t\t\t}\n\t\t\t\t\t// Preserve native event order for arrows, selection and typing in one\n\t\t\t\t\t// frame. Use the engine's input tick without another render or layout.\n\t\t\t\t\tEngineChatBridge.Invoke( tickInput, uiSystem, (bool)mouseInside.GetValue( surface ) );\n\t\t\t\t\tmessage.Message = 0; // WM_NULL prevents duplicate SDL or Qt delivery.\n\t\t\t\t\tMarshal.StructureToPtr( message, pointer, false );\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch ( Exception e ) { Log.Error( e, \"Global chat native keyboard input failed.\" ); }\n\t\t}\n\t\treturn CallNextHookEx( hook, code, removed, pointer );\n\t}\n\n\tpublic void Dispose()\n\t{\n\t\tif ( hook == IntPtr.Zero ) return;\n\t\tif ( !UnhookWindowsHookEx( hook ) ) throw new Win32Exception( Marshal.GetLastPInvokeError(), \"Could not remove chat keyboard input.\" );\n\t\thook = IntPtr.Zero;\n\t\tGCHandle.FromIntPtr( callbackRoot ).Free();\n\t\tcallbackRoot = IntPtr.Zero;\n\t}\n\n\tvoid IHotloadManaged.Destroyed( Dictionary<string, object> state )\n\t{\n\t\tstate[\"Installed\"] = hook != IntPtr.Zero;\n\t\tDispose();\n\t}\n\n\tvoid IHotloadManaged.Created( IReadOnlyDictionary<string, object> state )\n\t{\n\t\t// Fields were copied before Destroyed removed the old native registration.\n\t\thook = IntPtr.Zero;\n\t\tcallbackRoot = IntPtr.Zero;\n\t\tif ( state.TryGetValue( \"Installed\", out var installed ) && installed is true ) Install();\n\t}\n\n\tvoid IHotloadManaged.Persisted()\n\t{\n\t\tif ( hook == IntPtr.Zero ) return;\n\t\tDispose();\n\t\tInstall();\n\t}\n\n\tvoid IHotloadManaged.Failed()\n\t{\n\t\tDispose();\n\t}\n\n\t[StructLayout( LayoutKind.Sequential )]\n\tstruct NativeMessage\n\t{\n\t\tinternal IntPtr Window;\n\t\tinternal uint Message;\n\t\tinternal UIntPtr Key;\n\t\tinternal IntPtr Parameter;\n\t\tinternal uint Time;\n\t\tinternal int X, Y;\n\t\tinternal uint Private;\n\t}\n\t[UnmanagedFunctionPointer( CallingConvention.Winapi )]\n\tdelegate IntPtr MessageHook( int code, UIntPtr removed, IntPtr pointer );\n\t[DllImport( \"user32.dll\", EntryPoint = \"SetWindowsHookExW\", SetLastError = true )]\n\tstatic extern IntPtr SetWindowsHookEx( int type, MessageHook callback, IntPtr module, uint thread );\n\t[DllImport( \"user32.dll\", SetLastError = true )] static extern bool UnhookWindowsHookEx( IntPtr hook );\n\t[DllImport( \"user32.dll\" )] static extern IntPtr CallNextHookEx( IntPtr hook, int code, UIntPtr removed, IntPtr pointer );\n\t[DllImport( \"user32.dll\" )] static extern uint GetWindowThreadProcessId( IntPtr window, out uint process );\n\t[DllImport( \"user32.dll\" )] static extern IntPtr GetFocus();\n\t[DllImport( \"user32.dll\" )] static extern short GetKeyState( int key );\n\t[DllImport( \"user32.dll\" )] static extern bool TranslateMessage( ref NativeMessage message );\n}\n"
},
{
"Ident": "thedimin.global_chat",
"Path": "Editor/ChatViewValidation.cs",
"FileName": "ChatViewValidation.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 422392,
"IsPrivate": false,
"Code": "using Sandbox;\nusing Sandbox.UI;\nusing System;\nusing System.Reflection;\nusing System.Threading.Tasks;\n\nnamespace Editor.GlobalChat;\n\n/// <summary>Local checks through Windows input. Never activates Send or a party action.</summary>\ninternal static class ChatViewValidation\n{\n\tinternal static async Task<object> Run( GlobalChatWindow window )\n\t{\n\t\tusing var scope = window.Session.EnterMenuScope();\n\t\tvar popups = GlobalChatWindow.MentionPopups;\n\t\tvar sounds = GlobalChatWindow.MentionSounds;\n\t\tvar draft = window.Session.Draft;\n\t\tvar frame = EngineChatBridge.RequireMethod( typeof( PanelWindow ), \"Frame\" );\n\t\twindow.View.DismissUserActions();\n\t\tEngineChatBridge.Invoke( frame, window );\n\t\tvar input = (TextEntry)Find( window.Root, \"chat-input\" );\n\t\tvar settingsWereOpen = Find( window.Root, \"chat-popups\" ) is not null;\n\t\tvar previewMessages = window.View.PreviewMessages;\n\t\tvar membersWereVisible = window.View.MembersVisible;\n\t\ttry\n\t\t{\n\t\t\twindow.View.MembersVisible = true;\n\t\t\tEngineChatBridge.Invoke( frame, window );\n\t\t\tawait Task.Delay( 150 );\n\t\t\tvar heading = Find( window.Root, \"chat-conversation-heading\" );\n\t\t\tvar shownWidth = heading.Box.Rect.Width;\n\t\t\tawait Click( window, Find( window.Root, \"chat-members-toggle\" ), frame );\n\t\t\tif ( window.View.MembersVisible || Find( window.Root, \"chat-member-list\" ) is not null || heading.Box.Rect.Width <= shownWidth )\n\t\t\t\tthrow new InvalidOperationException( $\"The user list button did not hide the list and expand chat. Visible: {window.View.MembersVisible}; list present: {Find( window.Root, \"chat-member-list\" ) is not null}; width: {heading.Box.Rect.Width}; prior width: {shownWidth}; mouse: {window.MousePosition}; target: {Find( window.Root, \"chat-members-toggle\" ).Box.Rect}.\" );\n\t\t\tawait Click( window, Find( window.Root, \"chat-members-toggle\" ), frame );\n\t\t\tif ( !window.View.MembersVisible || Find( window.Root, \"chat-member-list\" ) is null )\n\t\t\t\tthrow new InvalidOperationException( \"The user list button did not show the list.\" );\n\t\t\tif ( !settingsWereOpen ) await Click( window, Find( window.Root, \"chat-settings-toggle\" ), frame );\n\t\t\tawait Click( window, Find( window.Root, \"chat-popups\" ), frame );\n\t\t\tif ( GlobalChatWindow.MentionPopups == popups || GlobalChatWindow.MentionSounds != sounds ) throw new InvalidOperationException( \"Popup checkbox did not change independently.\" );\n\t\t\tawait Click( window, Find( window.Root, \"chat-sounds\" ), frame );\n\t\t\tif ( GlobalChatWindow.MentionSounds == sounds ) throw new InvalidOperationException( \"Sound checkbox did not change.\" );\n\t\t\tinput.Text = \"\";\n\t\t\twindow.Session.Draft = \"\";\n\t\t\tawait Click( window, input, frame );\n\t\t\tChatNativeInputValidation.TypeUnicode( \"Local input check\" );\n\t\t\tEngineChatBridge.Invoke( frame, window );\n\t\t\tawait Task.Delay( 100 );\n\t\t\tif ( input.Text != \"Local input check\" || window.Session.Draft != input.Text ) throw new InvalidOperationException( \"Typing did not reach the Razor draft binding.\" );\n\t\t\tvar me = new Friend( Game.SteamId );\n\t\t\tawait Click( window, Find( window.Root, $\"chat-member-{me.Id}\" ), frame );\n\t\t\tvar menu = Find( window.Root, \"chat-user-actions\" );\n\t\t\tif ( menu is null || Find( menu, \"chat-user-steam\" ) is null || Find( menu, \"chat-user-website\" ) is null )\n\t\t\t\tthrow new InvalidOperationException( \"The user menu does not contain both profile actions.\" );\n\t\t\tvar bounds = window.View.Box.Rect;\n\t\t\tif ( menu.Box.Rect.Left < bounds.Left || menu.Box.Rect.Right > bounds.Right || menu.Box.Rect.Top < bounds.Top || menu.Box.Rect.Bottom > bounds.Bottom )\n\t\t\t\tthrow new InvalidOperationException( \"The user menu extends outside chat.\" );\n\t\t\tawait Click( window, Find( menu, \"chat-user-mention\" ), frame );\n\t\t\tif ( input.Text != $\"Local input check @{me.Name} \" || window.Session.Draft != input.Text || Find( window.Root, \"chat-user-actions\" ) is not null )\n\t\t\t\tthrow new InvalidOperationException( \"The mention action did not update the draft and close the menu.\" );\n\t\t\tawait Click( window, Find( window.Root, $\"chat-member-{me.Id}\" ), frame );\n\t\t\tawait Click( window, Find( window.Root, \"chat-user-dismiss\" ), frame );\n\t\t\tif ( Find( window.Root, \"chat-user-actions\" ) is not null ) throw new InvalidOperationException( \"Clicking outside did not close the user menu.\" );\n\t\t\tvar message = new GlobalChatSession.ChatLine( me, \"Local user action check\", 0, Guid.NewGuid() );\n\t\t\twindow.View.PreviewMessages = [message];\n\t\t\tEngineChatBridge.Invoke( frame, window );\n\t\t\tawait Click( window, Find( window.Root, $\"chat-name-{message.Id}\" ), frame );\n\t\t\tif ( Find( window.Root, \"chat-user-actions\" ) is null ) throw new InvalidOperationException( \"Clicking a message name did not open the menu.\" );\n\t\t\twindow.View.DismissUserActions();\n\t\t\tinput.Text = new string( 'x', 400 );\n\t\t\twindow.Session.Draft = input.Text;\n\t\t\tEngineChatBridge.Invoke( frame, window );\n\t\t\tawait Task.Delay( 100 );\n\t\t\tEngineChatBridge.Invoke( frame, window );\n\t\t\tawait Click( window, Find( window.Root, $\"chat-name-{message.Id}\" ), frame );\n\t\t\tvar mentionButton = Find( window.Root, \"chat-user-mention\" )\n\t\t\t\t?? throw new InvalidOperationException( \"The message name did not reopen the user menu.\" );\n\t\t\tif ( !mentionButton.HasClass( \"disabled\" ) || input.Text.Length != 400 )\n\t\t\t\tthrow new InvalidOperationException( \"The full draft does not disable mention insertion.\" );\n\t\t\tChatNativeInputValidation.PressKey( 0x1B );\n\t\t\tEngineChatBridge.Invoke( frame, window );\n\t\t\tawait Task.Delay( 100 );\n\t\t\tEngineChatBridge.Invoke( frame, window );\n\t\t\tif ( Find( window.Root, \"chat-user-actions\" ) is not null ) throw new InvalidOperationException( \"Escape did not close the user menu.\" );\n\t\t\tvar font = input.ComputedStyle.FontFamily;\n\t\t\treturn new { MemberListToggle = true, PopupToggle = true, SoundToggle = true, TypedDraft = true, UserMenu = true, MenuInBounds = true, MessageNameClick = true, MentionDraft = true, FullDraftPreserved = true, OutsideDismiss = true, EscapeDismiss = true, Font = font, SentMessages = 0, OpenedExternalProfiles = 0 };\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\twindow.View.DismissUserActions();\n\t\t\twindow.View.PreviewMessages = previewMessages;\n\t\t\twindow.View.MembersVisible = membersWereVisible;\n\t\t\tGlobalChatWindow.MentionPopups = popups;\n\t\t\tGlobalChatWindow.MentionSounds = sounds;\n\t\t\tinput.Text = draft;\n\t\t\twindow.Session.Draft = draft;\n\t\t\tEditorCookie.Set( \"GlobalChat.Draft\", draft );\n\t\t\tEngineChatBridge.Invoke( frame, window );\n\t\t\tif ( !settingsWereOpen && Find( window.Root, \"chat-popups\" ) is not null ) await Click( window, Find( window.Root, \"chat-settings-toggle\" ), frame );\n\t\t}\n\t}\n\n\tinternal static Panel Find( Panel root, string id )\n\t{\n\t\tif ( root.Id == id ) return root;\n\t\tforeach ( var child in root.Children )\n\t\t{\n\t\t\tvar found = Find( child, id );\n\t\t\tif ( found is not null ) return found;\n\t\t}\n\t\treturn null;\n\t}\n\n\tstatic async Task Click( GlobalChatWindow window, Panel target, MethodInfo frame )\n\t{\n\t\tawait ChatNativeInputValidation.Click( window, target );\n\t\tEngineChatBridge.Invoke( frame, window );\n\t}\n}\n"
},
{
"Ident": "thedimin.global_chat",
"Path": "Editor/GlobalChatMcpTools.cs",
"FileName": "GlobalChatMcpTools.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 422392,
"IsPrivate": false,
"Code": "using Sandbox;\nusing System;\nusing System.Linq;\nusing System.Threading.Tasks;\n\nnamespace Editor.Mcp;\n\n[McpToolset( \"editor_global_chat\", \"Editor global chat connection and local validation. Never sends chat messages.\" )]\npublic static class GlobalChatMcpTools\n{\n\t/// <summary>Checks a real Windows click, focus, Unicode typing, Backspace, Home, Ctrl+A, Ctrl+V and focus release. Uses existing clipboard text without changing the clipboard or returning its contents. Brings the editor forward, then restores draft, cursor and prior editor focus. Release modifiers first. Never presses Enter or sends chat. Use read_console afterwards.</summary>\n\t[McpTool( \"editor_global_chat_validate_keyboard\" )]\n\tpublic static async Task<object> ValidateKeyboard()\n\t{\n\t\tGlobalChat.GlobalChatWindow.Open();\n\t\tawait Task.Delay( 150 );\n\t\treturn await GlobalChat.ChatNativeInputValidation.Run( GlobalChat.GlobalChatWindow.Current );\n\t}\n\t/// <summary>Opens chat, then floats its editor dock or places it at the bottom. Keeps the same chat session and draft. Use editor_global_chat_status and editor_global_chat_capture next. Does not send a message.</summary>\n\t[McpTool( \"editor_global_chat_dock\" )]\n\tpublic static ChatStatus SetDock( bool floating = false )\n\t{\n\t\tGlobalChat.GlobalChatWindow.Open();\n\t\tvar dock = GlobalChat.GlobalChatDock.Dock;\n\t\tif ( floating ) dock.Float();\n\t\telse EditorWindow.DockManager.OpenDock( \"Global Chat\", DockArea.Bottom );\n\t\t((GlobalChat.GlobalChatDock)dock.Widget).Synchronize();\n\t\treturn Status();\n\t}\n\n\t/// <summary>Checks floating and docking with the same surface, connection and draft, then restores the editor layout in all cases. Requires joined chat. Forces C# memory collection to check native callback lifetime, then runs local Windows input checks in both states. Does not send chat or open profiles. Use read_console afterwards.</summary>\n\t[McpTool( \"editor_global_chat_validate_docking\" )]\n\tpublic static async Task<object> ValidateDocking()\n\t{\n\t\tGlobalChat.GlobalChatWindow.Open();\n\t\tvar window = GlobalChat.GlobalChatWindow.Current;\n\t\tif ( window.Session.Channel is not { IsJoined: true } ) throw new InvalidOperationException( \"Wait for global chat to join before checking docking.\" );\n\t\tvar layout = EditorWindow.DockManager.State;\n\t\tvar channel = window.Session.Channel.Id;\n\t\tvar draft = window.Session.Draft;\n\t\ttry\n\t\t{\n\t\t\tSetDock( true );\n\t\t\t// Keep the native input check on-screen, then restore the saved layout below.\n\t\t\tGlobalChat.GlobalChatDock.ResizeFloating( new Vector2( 720, 520 ) );\n\t\t\tvar floatingWindow = ((GlobalChat.GlobalChatDock)GlobalChat.GlobalChatDock.Dock.Widget).GetWindow();\n\t\t\tfloatingWindow.Position = floatingWindow.ScreenGeometry.Position + (floatingWindow.ScreenGeometry.Size - floatingWindow.Size) * 0.5f;\n\t\t\tawait Task.Delay( 250 );\n\t\t\tvar floating = Status();\n\t\t\tif ( !floating.Floating || !floating.SurfaceAttached ) throw new InvalidOperationException( \"The chat surface did not stay attached when floating.\" );\n\t\t\t// Native callbacks must remain rooted even when the CLR collects delegates.\n\t\t\tGC.Collect();\n\t\t\tGC.WaitForPendingFinalizers();\n\t\t\tGC.Collect();\n\t\t\tawait GlobalChat.ChatViewValidation.Run( window );\n\t\t\tawait GlobalChat.ChatNativeInputValidation.Run( window );\n\t\t\tSetDock( false );\n\t\t\tawait Task.Delay( 250 );\n\t\t\tvar docked = Status();\n\t\t\tif ( !docked.Docked || !docked.SurfaceAttached ) throw new InvalidOperationException( \"The chat surface did not stay attached when docking.\" );\n\t\t\tawait GlobalChat.ChatViewValidation.Run( window );\n\t\t\tawait GlobalChat.ChatNativeInputValidation.Run( window );\n\t\t\tif ( GlobalChat.GlobalChatWindow.Current != window || window.Session.Channel?.Id != channel || window.Session.Draft != draft )\n\t\t\t\tthrow new InvalidOperationException( \"Docking changed the chat session or draft.\" );\n\t\t\treturn new { Floating = true, Docked = true, SurfaceAttached = true, SameSession = true, DraftPreserved = true, InputInBothStates = true, SentMessages = 0 };\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tif ( !EditorWindow.DockManager.RestoreState( layout ) ) throw new InvalidOperationException( \"The editor could not restore its dock layout.\" );\n\t\t\t((GlobalChat.GlobalChatDock)GlobalChat.GlobalChatDock.Dock.Widget).Synchronize();\n\t\t}\n\t}\n\n\t/// <summary>Checks settings, draft typing, member menu, mention insertion and outside dismissal through window input. Restores draft and preferences. Requires joined chat. Never activates Send, external profiles or party actions. Use editor_global_chat_capture to review the layout next.</summary>\n\t[McpTool( \"editor_global_chat_validate_view\" )]\n\tpublic static async Task<object> ValidateView()\n\t{\n\t\tGlobalChat.GlobalChatWindow.Open();\n\t\treturn await GlobalChat.ChatViewValidation.Run( GlobalChat.GlobalChatWindow.Current );\n\t}\n\t/// <summary>Captures the actual Razor chat surface. Optional size (540\u20131800 wide, 420\u20131400 high) requires a floating dock; omit both dimensions when docked. previewMessages and showSettings apply only to this capture. Does not change history or send chat. Open chat first; use read_console after capture.</summary>\n\t[McpTool( \"editor_global_chat_capture\" )]\n\tpublic static McpResult Capture( int width = 0, int height = 0, bool previewMessages = false, bool showSettings = false, bool? showMembers = null )\n\t{\n\t\tvar window = GlobalChat.GlobalChatWindow.Current;\n\t\tif ( window is not { IsOpen: true } ) throw new InvalidOperationException( \"Open Global Chat before capturing it.\" );\n\t\tif ( width != 0 || height != 0 )\n\t\t{\n\t\t\tif ( width < 540 || width > 1800 || height < 420 || height > 1400 ) throw new ArgumentOutOfRangeException( nameof( width ), \"Use a width of 540\u20131800 and a height of 420\u20131400, or leave both zero.\" );\n\t\t\tGlobalChat.GlobalChatDock.ResizeFloating( new Vector2( width, height ) );\n\t\t}\n\t\tvar settingsWereOpen = window.View.SettingsOpen;\n\t\tvar membersWereVisible = window.View.MembersVisible;\n\t\ttry\n\t\t{\n\t\t\tif ( showSettings ) window.View.SettingsOpen = true;\n\t\t\tif ( showMembers.HasValue ) window.View.MembersVisible = showMembers.Value;\n\t\t\tif ( previewMessages )\n\t\t\t{\n\t\t\t\tvar me = new Friend( Game.SteamId );\n\t\t\t\twindow.View.PreviewMessages = [\n\t\t\t\t\tnew( me, \"Local layout preview. This message is not sent to global chat.\", 0, Guid.NewGuid() ),\n\t\t\t\t\tnew( me, \"A second sample checks the shared message style and text wrapping when the editor window is narrow. The avatar, name, and message must stay aligned.\", 0, Guid.NewGuid() ) ];\n\t\t\t}\n\t\t\tusing var bitmap = GlobalChat.ChatWindowCapture.Capture( window, window.Session );\n\t\t\treturn McpResult.Image( bitmap );\n\t\t}\n\t\tfinally { window.View.PreviewMessages = null; window.View.SettingsOpen = settingsWereOpen; window.View.MembersVisible = membersWereVisible; }\n\t}\n\t/// <summary>Creates a temporary private Steam party, joins it through the editor reflection path, verifies one local member, then leaves in all cases. Refuses when already in a party. Sends no chat message or invitation.</summary>\n\t[McpTool( \"editor_global_chat_validate_private_party\" )]\n\tpublic static async Task<object> ValidatePrivateParty()\n\t{\n\t\tvar members = await new GlobalChat.EngineChatBridge().ValidatePrivatePartyAsync();\n\t\treturn new { Joined = true, Members = members, Left = PartyRoom.Current is null };\n\t}\n\t/// <summary>Captures chat with the inline user action menu. Use editor_global_chat_preview_profile first. Does not open a browser or send a message.</summary>\n\t[McpTool( \"editor_global_chat_capture_profile\" )]\n\tpublic static McpResult CaptureProfile()\n\t{\n\t\tvar window = GlobalChat.GlobalChatWindow.Current;\n\t\tif ( window is not { IsOpen: true } ) throw new InvalidOperationException( \"Open Global Chat before capturing a profile.\" );\n\t\tusing var bitmap = GlobalChat.ChatWindowCapture.Capture( window, window.Session );\n\t\treturn McpResult.Image( bitmap );\n\t}\n\n\t/// <summary>Opens the inline user action menu for the local user. Does not open a browser, send a message, or change party state. Use editor_global_chat_capture_profile to review it.</summary>\n\t[McpTool( \"editor_global_chat_preview_profile\" )]\n\tpublic static ChatStatus PreviewProfile()\n\t{\n\t\tGlobalChat.GlobalChatWindow.Open();\n\t\tGlobalChat.GlobalChatWindow.Current.View.ShowUserActions( new Friend( Game.SteamId ) );\n\t\treturn Status();\n\t}\n\n\t/// <summary>Opens the editor global chat window and connects to the normal menu channel. Does not send a message. Call editor_global_chat_status after connection.</summary>\n\t[McpTool( \"editor_global_chat_open\" )]\n\tpublic static ChatStatus Open()\n\t{\n\t\tGlobalChat.GlobalChatWindow.Open();\n\t\treturn Status();\n\t}\n\n\t/// <summary>Returns dock/floating state, native surface attachment, connection, channel metadata, member count and alert settings. Contains no message text. Use editor_global_chat_open to connect.</summary>\n\t[McpTool.ReadOnly( \"editor_global_chat_status\" )]\n\tpublic static ChatStatus Status()\n\t{\n\t\tvar window = GlobalChat.GlobalChatWindow.Current;\n\t\tvar session = window is { IsOpen: true } ? window.Session : null;\n\t\tvar channel = session?.Channel;\n\t\tvar dock = GlobalChat.GlobalChatDock.Dock;\n\t\treturn new()\n\t\t{\n\t\t\tOpen = window is { IsOpen: true }, Connecting = session?.Connecting ?? false,\n\t\t\tDocked = dock is not null && !dock.IsClosed && !dock.IsFloating,\n\t\t\tFloating = dock is not null && !dock.IsClosed && dock.IsFloating,\n\t\t\tSurfaceAttached = dock?.Widget is GlobalChat.GlobalChatDock host && host.SurfaceAttached,\n\t\t\tJoined = channel?.IsJoined ?? false, ChannelId = channel?.Id.ToString(),\n\t\t\tChannel = channel?.GetData( \"channel\" ), LobbyType = channel?.GetData( \"lobby_type\" ),\n\t\t\tDev = channel?.GetData( \"dev\" ), Api = channel?.GetData( \"api\" ), Protocol = channel?.GetData( \"protocol\" ),\n\t\t\tMembers = channel?.MemberCount ?? 0, Messages = session?.Messages.Count ?? 0, Error = session?.Error,\n\t\t\tMentionPopups = GlobalChat.GlobalChatWindow.MentionPopups,\n\t\t\tMentionSounds = GlobalChat.GlobalChatWindow.MentionSounds,\n\t\t\tPopupsShown = window is { IsOpen: true } ? window.PopupCount : 0,\n\t\t\tSoundsPlayed = window is { IsOpen: true } ? window.SoundCount : 0,\n\t\t\tSoundError = window is { IsOpen: true } ? window.SoundError : null\n\t\t};\n\t}\n\n\t/// <summary>Changes saved editor mention preferences. Does not affect compiler notifications or send chat. Call editor_global_chat_status to read back.</summary>\n\t[McpTool( \"editor_global_chat_alert_settings\" )]\n\tpublic static ChatStatus AlertSettings( bool popups, bool sounds )\n\t{\n\t\tGlobalChat.GlobalChatWindow.MentionPopups = popups;\n\t\tGlobalChat.GlobalChatWindow.MentionSounds = sounds;\n\t\treturn Status();\n\t}\n\n\t/// <summary>Exercises the real local mention notification path with one synthetic message. Does not send to Steam or insert into chat history. Opens chat first. Tests the current saved popup and sound settings.</summary>\n\t[McpTool( \"editor_global_chat_preview_mention\" )]\n\tpublic static ChatStatus PreviewMention()\n\t{\n\t\tGlobalChat.GlobalChatWindow.Open();\n\t\tvar window = GlobalChat.GlobalChatWindow.Current;\n\t\tvar me = new Friend( Game.SteamId );\n\t\twindow.NotifyMention( new( new Friend( 76561197960265729 ), $\"@{me.Name} Mention alert preview.\", 0, Guid.NewGuid() ) );\n\t\treturn Status();\n\t}\n\n\t/// <summary>Closes the chat window and leaves its channel. Does not send a message. Draft and alert preferences stay saved.</summary>\n\t[McpTool( \"editor_global_chat_close\" )]\n\tpublic static ChatStatus Close()\n\t{\n\t\tGlobalChat.GlobalChatWindow.Current?.Close();\n\t\treturn Status();\n\t}\n}\n\npublic sealed class ChatStatus\n{\n\tpublic bool Open { get; set; }\n\tpublic bool Docked { get; set; }\n\tpublic bool Floating { get; set; }\n\tpublic bool SurfaceAttached { get; set; }\n\tpublic bool Connecting { get; set; }\n\tpublic bool Joined { get; set; }\n\tpublic string ChannelId { get; set; }\n\tpublic string Channel { get; set; }\n\tpublic string LobbyType { get; set; }\n\tpublic string Dev { get; set; }\n\tpublic string Api { get; set; }\n\tpublic string Protocol { get; set; }\n\tpublic int Members { get; set; }\n\tpublic int Messages { get; set; }\n\tpublic string Error { get; set; }\n\tpublic bool MentionPopups { get; set; }\n\tpublic bool MentionSounds { get; set; }\n\tpublic int PopupsShown { get; set; }\n\t/// <summary>Number of sound requests accepted by Windows. This does not verify speaker output.</summary>\n\tpublic int SoundsPlayed { get; set; }\n\t/// <summary>Failure from the last sound request, or null when Windows accepted playback. Acceptance does not verify speaker output.</summary>\n\tpublic string SoundError { get; set; }\n}\n"
},
{
"Ident": "thedimin.global_chat",
"Path": "Tests/ChatMentionPolicyTests.cs",
"FileName": "ChatMentionPolicyTests.cs",
"PackageType": "library",
"CodeKind": "UnitTest",
"AssetVersionId": 422392,
"IsPrivate": false,
"Code": "using Editor.GlobalChat;\nusing Microsoft.VisualStudio.TestTools.UnitTesting;\n\n[TestClass]\npublic sealed class ChatMentionPolicyTests\n{\n\t[TestMethod]\n\t[DataRow( \"Hello @Alice\", \"Alice\", true )]\n\t[DataRow( \"Hello @Alice.\", \"Alice\", true )]\n\t[DataRow( \"@Alice. Next sentence\", \"Alice\", true )]\n\t[DataRow( \"@Alice.com\", \"Alice\", false )]\n\t[DataRow( \"@aLiCe, hello\", \"Alice\", true )]\n\t[DataRow( \"Hello @Alice Smith!\", \"Alice Smith\", true )]\n\t[DataRow( \"@AliceOther\", \"Alice\", false )]\n\t[DataRow( \"mail@Alice\", \"Alice\", false )]\n\t[DataRow( \"@@Alice\", \"Alice\", false )]\n\t[DataRow( \"No mention\", \"Alice\", false )]\n\t[DataRow( \"@42 hello\", \"Alice\", true )]\n\t[DataRow( \"<@42> hello\", \"Alice\", true )]\n\t[DataRow( \"@420 hello\", \"Alice\", false )]\n\tpublic void MatchesCompleteMentions( string text, string name, bool expected )\n\t{\n\t\tAssert.AreEqual( expected, ChatMentionPolicy.ContainsMention( text, 42, name ) );\n\t}\n\n\t[TestMethod]\n\tpublic void HistorySelfBlockedAndDisabledMessagesDoNotNotify()\n\t{\n\t\tvar gate = new ChatMentionGate();\n\t\tAssert.IsFalse( gate.ShouldNotify( Guid.NewGuid(), 7, 42, \"Alice\", \"@Alice\", false, false, true ) );\n\t\tAssert.IsFalse( gate.ShouldNotify( Guid.NewGuid(), 42, 42, \"Alice\", \"@Alice\", true, false, true ) );\n\t\tAssert.IsFalse( gate.ShouldNotify( Guid.NewGuid(), 7, 42, \"Alice\", \"@Alice\", true, true, true ) );\n\t\tvar disabled = Guid.NewGuid();\n\t\tAssert.IsFalse( gate.ShouldNotify( disabled, 7, 42, \"Alice\", \"@Alice\", true, false, false ) );\n\t\tAssert.IsFalse( gate.ShouldNotify( disabled, 7, 42, \"Alice\", \"@Alice\", true, false, true ) );\n\t\tAssert.IsTrue( gate.ShouldNotify( Guid.NewGuid(), 7, 42, \"Alice\", \"@Alice\", true, false, true ) );\n\t}\n\n\t[TestMethod]\n\tpublic void EachAuthenticatedSenderAndMessagePairNotifiesOnce()\n\t{\n\t\tvar gate = new ChatMentionGate();\n\t\tvar id = Guid.NewGuid();\n\t\tAssert.IsTrue( gate.ShouldNotify( id, 7, 42, \"Alice\", \"@Alice\", true, false, true ) );\n\t\tAssert.IsFalse( gate.ShouldNotify( id, 7, 42, \"Alice\", \"@Alice\", true, false, true ) );\n\t\tAssert.IsTrue( gate.ShouldNotify( id, 8, 42, \"Alice\", \"@Alice\", true, false, true ) );\n\t}\n}\n"
},
{
"Ident": "thedimin.global_chat",
"Path": "Editor/ChatMentionSound.cs",
"FileName": "ChatMentionSound.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 422392,
"IsPrivate": false,
"Code": "using System;\nusing System.IO;\nusing System.Runtime.InteropServices;\n\nnamespace Editor.GlobalChat;\n\n/// <summary>Plays mention alerts outside the game audio mixer, including when the editor is in the background.</summary>\ninternal static class ChatMentionSound\n{\n\t// Use the longer editor chime: the compile-success tick is easy to miss.\n\tinternal const string SoundFile = \"core/sounds/editor/published.wav\";\n\tconst uint Async = 0x0001;\n\tconst uint NoDefault = 0x0002;\n\tconst uint FileName = 0x00020000;\n\n\tinternal static bool Play( out string error )\n\t{\n\t\terror = null;\n\t\tif ( !OperatingSystem.IsWindows() )\n\t\t{\n\t\t\terror = \"Editor chat sound requires Windows.\";\n\t\t\treturn false;\n\t\t}\n\t\tvar path = FileSystem.Root.GetFullPath( SoundFile );\n\t\tif ( string.IsNullOrWhiteSpace( path ) || !File.Exists( path ) )\n\t\t{\n\t\t\terror = $\"Editor chat sound file is missing: {SoundFile}\";\n\t\t\treturn false;\n\t\t}\n\t\t// EditorUtility.PlayRawSound only confirms path resolution. Call Windows directly\n\t\t// so a failed playback request is reported, rather than counted as a played sound.\n\t\tif ( PlaySound( path, IntPtr.Zero, Async | NoDefault | FileName ) ) return true;\n\t\terror = \"Windows could not start the editor chat sound. Check the audio output device.\";\n\t\treturn false;\n\t}\n\n\t[DllImport( \"winmm.dll\", EntryPoint = \"PlaySoundW\", CharSet = CharSet.Unicode, ExactSpelling = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tstatic extern bool PlaySound( string file, IntPtr module, uint flags );\n}\n"
}
]
}