{"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/// \u003Csummary\u003EMatches complete @names, @SteamIds and \u0026lt;@SteamId\u0026gt; mentions, without matching email addresses.\u003C/summary\u003E\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 \u0026\u0026 (ContainsToken( text, \u0022@\u0022 \u002B id ) || ContainsToken( text, \u0022\u003C@\u0022 \u002B id \u002B \u0022\u003E\u0022 )))\n\t\t\t|| (!string.IsNullOrWhiteSpace( displayName ) \u0026\u0026 ContainsToken( text, \u0022@\u0022 \u002B displayName.Trim() ));\n\t}\n\n\tstatic bool ContainsToken( string text, string token )\n\t{\n\t\tfor ( int start = 0; start \u003C= text.Length - token.Length; )\n\t\t{\n\t\t\tvar index = text.IndexOf( token, start, StringComparison.OrdinalIgnoreCase );\n\t\t\tif ( index \u003C 0 ) return false;\n\t\t\tvar end = index \u002B token.Length;\n\t\t\tif ( (index == 0 || !IsNameCharacter( text[index - 1] ))\n\t\t\t\t\u0026\u0026 (end == text.Length || !IsNameCharacter( text[end] )\n\t\t\t\t\t|| (text[end] == \u0027.\u0027 \u0026\u0026 (end \u002B 1 == text.Length || char.IsWhiteSpace( text[end \u002B 1] )))) ) return true;\n\t\t\tstart = index \u002B 1;\n\t\t}\n\t\treturn false;\n\t}\n\n\tstatic bool IsNameCharacter( char value ) =\u003E char.IsLetterOrDigit( value ) || value is \u0027_\u0027 or \u0027@\u0027 or \u0027-\u0027 or \u0027.\u0027;\n}\n\n/// \u003Csummary\u003EConsumes message identities even while alerts are disabled, so enabling alerts does not replay old messages.\u003C/summary\u003E\npublic sealed class ChatMentionGate\n{\n\treadonly HashSet\u003C(ulong, Guid)\u003E seen = new();\n\treadonly Queue\u003C(ulong, Guid)\u003E 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 \u003E 256 ) seen.Remove( order.Dequeue() );\n\t\treturn live \u0026\u0026 !blocked \u0026\u0026 enabled \u0026\u0026 sender != localUser \u0026\u0026 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/// \u003Csummary\u003EPlaces the Razor chat surface in the editor\u0027s normal dock layout.\u003C/summary\u003E\n[Dock( \u0022Editor\u0022, \u0022Global Chat\u0022, \u0022forum\u0022, DockArea.Bottom )]\npublic sealed class GlobalChatDock : Widget\n{\n\tinternal GlobalChatWindow ChatWindow { get; private set; }\n\tChatDockNativeHost nativeHost;\n\tinternal bool SurfaceAttached =\u003E ChatWindow is { IsOpen: true } \u0026\u0026 nativeHost is not null \u0026\u0026 nativeHost.IsAttached;\n\tinternal IntPtr NativeHandle =\u003E nativeHost?.Handle ?? IntPtr.Zero;\n\tinternal static DockWidget Dock =\u003E EditorWindow.IsValid() \u0026\u0026 EditorWindow.DockManager.IsValid()\n\t\t? EditorWindow.DockManager.FindDockWidget( \u0022Global Chat\u0022 ) : 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 =\u003E info.Title == \u0022Global Chat\u0022 ) )\n\t\t\tEditorWindow.DockManager.RegisterDockType( new DockManager.DockInfo { Title = \u0022Global Chat\u0022, Icon = \u0022forum\u0022, Area = DockArea.Bottom, CreateAction = CreateDock } );\n\t\tEditorWindow.DockManager.SetDockState( \u0022Global Chat\u0022, true );\n\t\tvar dock = Dock ?? throw new InvalidOperationException( \u0022The editor has not registered the Global Chat dock.\u0022 );\n\t\tdock.MinimumSizeFromContent = true;\n\t\tdock.SetAsCurrentTab();\n\t\tvar host = dock.Widget as GlobalChatDock ?? throw new InvalidOperationException( \u0022Global Chat has an unexpected dock host.\u0022 );\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 \u0026\u0026 dock.Widget is GlobalChatDock host \u0026\u0026 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( \u0022Float Global Chat before requesting a capture size, or omit width and height to capture the dock area.\u0022 );\n\t\tvar host = (GlobalChatDock)dock.Widget;\n\t\tvar floatingWindow = host.GetWindow();\n\t\tfloatingWindow.Size \u002B= 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\u0026box 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/// \u003Csummary\u003E\r\n/// Global chat membership exists while its view is open, with recent history shared through the lobby.\r\n/// \u003C/summary\u003E\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\u003CChatLine\u003E LiveMessageReceived;\n\tinternal IDisposable EnterMenuScope() =\u003E bridge.EnterMenuScope();\n\tinternal Task JoinPartyAsync( ulong id ) =\u003E bridge.JoinPartyAsync( id, cancellation.Token );\n\tinternal ChatChannel Channel { get; private set; }\r\n\tinternal List\u003CChatLine\u003E 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; } = \u0022\u0022;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Whether chat is connected and the shared message cooldown has expired.\r\n\t/// \u003C/summary\u003E\r\n\tinternal bool CanSend =\u003E Channel is not null \u0026\u0026 RealTime.Now - lastSent \u003E= 1;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Whether the party leader can post another invitation, including the shared message cooldown.\r\n\t/// \u003C/summary\u003E\r\n\tinternal bool CanInviteParty =\u003E CanSend \u0026\u0026 PartyRoom.Current is { Owner.IsMe: true } \u0026\u0026 RealTime.Now - lastInvite \u003E= 10;\r\n\r\n\tinternal record ChatLine( Friend Sender, string Text, ulong PartyId, Guid Id );\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// A snapshot of a shared party. Unavailable also covers failed refreshes, so stale invites cannot be joined.\r\n\t/// \u003C/summary\u003E\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 =\u003E Members \u003E= Capacity;\r\n\t}\r\n\r\n\treadonly Dictionary\u003Culong, PartyInvitation\u003E invitations = new();\r\n\tbool refreshingInvitations;\r\n\tdouble nextInvitationRefresh;\r\n\r\n\treadonly Dictionary\u003Culong, double\u003E lastMessages = new();\r\n\tconst string HistoryKey = \u0022chat_history\u0022;\r\n\tconst int HistoryLimit = 20;\r\n\tconst int HistoryByteLimit = 8191;\r\n\treadonly List\u003CMessage\u003E 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/// \u003Csummary\u003E\r\n\t\t/// Identifies a message across live delivery and lobby history.\r\n\t\t/// \u003C/summary\u003E\r\n\t\tpublic Guid Id { get; set; }\r\n\r\n\t\t/// \u003Csummary\u003E\r\n\t\t/// Steam sender recorded by the receiver, never trusted from a live payload.\r\n\t\t/// \u003C/summary\u003E\r\n\t\tpublic ulong SenderId { get; set; }\r\n\r\n\t\t/// \u003Csummary\u003E\r\n\t\t/// Plain text sent to everyone in global chat.\r\n\t\t/// \u003C/summary\u003E\r\n\t\tpublic string Text { get; set; }\r\n\r\n\t\t/// \u003Csummary\u003E\r\n\t\t/// Optional invitation to an existing party.\r\n\t\t/// \u003C/summary\u003E\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 \u0026\u0026 Channel is null ) return;\r\n\t\tVisible = false;\r\n\t\tgeneration\u002B\u002B;\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\u002B\u002B;\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 \u0026\u0026 RealTime.Now \u003E= nextJoin ) _ = Join();\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tif ( !Connecting \u0026\u0026 !refreshing \u0026\u0026 RealTime.Now \u003E= nextRefresh ) _ = Refresh();\r\n\t\tif ( !refreshingInvitations \u0026\u0026 RealTime.Now \u003E= 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 =\u003E 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 \u0026\u0026 Visible )\r\n\t\t\t{\r\n\t\t\t\tError = e.Message;\r\n\t\t\t\tnextJoin = RealTime.Now \u002B 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\u002B\u002B; }\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 \u002B= Receive;\r\n\t\tchannel.Changed \u002B= Changed;\r\n\t\tChanged();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// All messages sharing the same party use one periodically refreshed snapshot.\r\n\t/// \u003C/summary\u003E\r\n\tinternal PartyInvitation GetInvitation( ulong id ) =\u003E 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 \u002B 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 =\u003E x.PartyId != 0 ).Select( x =\u003E 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 =\u003E\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\u002B\u002B;\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 \u002B 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 =\u003E x \u003C current.Id ).OrderBy( x =\u003E 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 \u0026\u0026 Visible ) Error = e.Message;\r\n\t\t}\r\n\t\tfinally { if ( version == generation ) { refreshing = false; Revision\u002B\u002B; } }\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( \u0022\u0022, 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 ) =\u003E SendMessage( text, 0 );\r\n\r\n\tbool SendMessage( string text, ulong partyId )\r\n\t{\r\n\t\ttext = text?.Trim() ?? \u0022\u0022;\r\n\t\tif ( !CanSend || (text.Length == 0 \u0026\u0026 partyId == 0) ) return false;\r\n\r\n\t\tif ( text.Length \u003E 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 = \u0022The message could not be sent.\u0022;\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 ) \u0026\u0026 RealTime.Now - last \u003C 0.5 ) return;\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar message = JsonSerializer.Deserialize\u003CMessage\u003E( 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 ) =\u003E message is not null \u0026\u0026 message.Text?.Length is not \u003E 400 \u0026\u0026 (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 =\u003E x.Id == message.Id \u0026\u0026 x.SenderId == message.SenderId ) ) return;\r\n\r\n\t\thistory.Add( message );\r\n\t\tif ( history.Count \u003E 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 =\u003E x.Id == message.Id \u0026\u0026 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 \u0026\u0026 !invitations.ContainsKey( message.PartyId ) ) nextInvitationRefresh = 0;\r\n\t\tif ( Messages.Count \u003E 100 ) Messages.RemoveAt( 0 );\r\n\t\tRevision\u002B\u002B;\r\n\t\tif ( live )\n\t\t{\n\t\t\ttry { LiveMessageReceived?.Invoke( line ); }\n\t\t\tcatch ( Exception e ) { Log.Warning( e, \u0022Global chat mention alert failed.\u0022 ); }\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 ) \u003E HistoryByteLimit ) return;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar messages = JsonSerializer.Deserialize\u003CList\u003CMessage\u003E\u003E( 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 ) \u003E HistoryByteLimit \u0026\u0026 history.Count \u003E 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\u002B\u002B;\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\u002B\u002B;\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/// \u003Csummary\u003ECaptures the chat panel through the editor window camera.\u003C/summary\u003E\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 ), \u0022Frame\u0022 ), window );\n\t\tvar camera = typeof( PanelWindow ).GetField( \u0022_camera\u0022, BindingFlags.Instance | BindingFlags.NonPublic )?.GetValue( window ) as SceneCamera\n\t\t\t?? throw new InvalidOperationException( \u0022The engine does not expose the chat window camera.\u0022 );\n\t\tvar render = typeof( SceneCamera ).GetMethods( BindingFlags.Instance | BindingFlags.NonPublic )\n\t\t\t.Single( x =\u003E x.Name == \u0022RenderToBitmap\u0022 \u0026\u0026 x.GetParameters().Length == 2 );\n\t\tvar config = Activator.CreateInstance( render.GetParameters()[1].ParameterType.GetElementType() );\n\t\tvar size = (Vector2)EngineChatBridge.RequireProperty( typeof( PanelWindow ), \u0022PixelSize\u0022 ).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/// \u003Csummary\u003E\n/// The only reflection boundary. Uses the engine\u0027s Steam query semaphore, membership handles,\n/// authenticated callbacks and wire protocol. Does not change engine methods or global settings.\n/// \u003C/summary\u003E\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\u003Cstring, string\u003E filters;\n\n\tpublic EngineChatBridge()\n\t{\n\t\tvar engine = typeof( Scene ).Assembly;\n\t\tvar matchmaking = RequireType( engine, \u0022Steamworks.SteamMatchmaking\u0022 );\n\t\tvar query = RequireType( engine, \u0022Steamworks.Data.LobbyQuery\u0022 );\n\t\tvar lobby = RequireType( engine, \u0022Steamworks.Data.Lobby\u0022 );\n\t\tvar protocol = RequireType( engine, \u0022Sandbox.Engine.Protocol\u0022 );\n\t\tvar menu = AppDomain.CurrentDomain.GetAssemblies().SingleOrDefault( x =\u003E x.GetName().Name == \u0022Sandbox.Menu\u0022 )\n\t\t\t?? Assembly.Load( \u0022Sandbox.Menu\u0022 );\n\t\tchannelType = RequireType( menu, \u0022Sandbox.MenuChatChannel\u0022 );\n\t\tlobbyList = RequireProperty( matchmaking, \u0022LobbyList\u0022 );\n\t\tlobbyId = RequireProperty( lobby, \u0022Id\u0022 );\n\t\tsteamIdValue = lobbyId.PropertyType.GetField( \u0022Value\u0022, All )\n\t\t\t?? throw Incompatible( \u0022Steamworks.SteamId.Value\u0022 );\n\t\tworldwide = RequireMethod( query, \u0022FilterDistanceWorldwide\u0022 );\n\t\twithKeyValue = RequireMethod( query, \u0022WithKeyValue\u0022, typeof( string ), typeof( string ) );\n\t\trequest = RequireMethod( query, \u0022RequestAsync\u0022, typeof( CancellationToken ) );\n\t\tjoin = RequireMethod( channelType, \u0022JoinAsync\u0022, typeof( ulong ) );\n\t\tcreate = RequireMethod( channelType, \u0022CreateAsync\u0022, typeof( int ), typeof( Dictionary\u003Cstring, string\u003E ) );\n\t\tmenuScope = RequireMethod( RequireType( engine, \u0022Sandbox.Engine.GlobalContext\u0022 ), \u0022MenuScope\u0022 );\n\t\tfilters = new()\n\t\t{\n\t\t\t[\u0022menu_protocol\u0022] = \u00221\u0022,\n\t\t\t[\u0022api\u0022] = RequireProperty( protocol, \u0022Api\u0022 ).GetValue( null ).ToString(),\n\t\t\t[\u0022protocol\u0022] = RequireProperty( protocol, \u0022Network\u0022 ).GetValue( null ).ToString(),\n\t\t\t[\u0022lobby_type\u0022] = \u0022menu_chat\u0022,\n\t\t\t[\u0022channel\u0022] = \u0022global\u0022,\n\t\t\t// Existing normal menu clients still search for this metadata. Never create an editor-only room.\n\t\t\t[\u0022dev\u0022] = \u00220\u0022\n\t\t};\n\t\t// Resolve the complete handle contract before joining Steam.\n\t\tforeach ( var name in new[] { \u0022Id\u0022, \u0022Owner\u0022, \u0022MemberCount\u0022, \u0022Members\u0022, \u0022IsJoined\u0022 } ) RequireProperty( channelType, name );\n\t\tRequireMethod( channelType, \u0022GetData\u0022, typeof( string ) );\n\t\tRequireMethod( channelType, \u0022SetData\u0022, typeof( string ), typeof( string ) );\n\t\tRequireMethod( channelType, \u0022Send\u0022, typeof( string ) );\n\t\tforeach ( var name in new[] { \u0022MessageReceived\u0022, \u0022Changed\u0022 } )\n\t\t\tif ( channelType.GetEvent( name, All ) is null ) throw Incompatible( $\u0022{channelType.FullName}.{name}\u0022 );\n\t}\n\n\tpublic IDisposable EnterMenuScope() =\u003E (IDisposable)Invoke( menuScope, null );\n\n\tpublic async Task\u003Culong[]\u003E 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\u003Cobject\u003E().Select( x =\u003E (ulong)steamIdValue.GetValue( lobbyId.GetValue( x ) ) ).OrderBy( x =\u003E x ).ToArray() ?? [];\n\t}\n\n\tpublic async Task\u003CChatChannel\u003E 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\u003CChatChannel\u003E CreateAsync()\n\t{\n\t\tusing var scope = EnterMenuScope();\n\t\treturn new ChatChannel( await AwaitResult( Invoke( create, null, 250, new Dictionary\u003Cstring, string\u003E( 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( \u0022Leave your current party before joining another.\u0022 );\n\t\tvar lobbyType = lobbyId.DeclaringType;\n\t\tvar lobbyConstructor = lobbyType.GetConstructor( All, null, [lobbyId.PropertyType], null ) ?? throw Incompatible( \u0022Lobby constructor\u0022 );\n\t\tvar partyConstructor = typeof( PartyRoom ).GetConstructor( All, null, [lobbyType], null ) ?? throw Incompatible( \u0022PartyRoom constructor\u0022 );\n\t\tvar current = RequireProperty( typeof( PartyRoom ), \u0022Current\u0022 );\n\t\tvar setCurrent = current.GetSetMethod( true ) ?? throw Incompatible( \u0022PartyRoom.Current setter\u0022 );\n\t\tvar joinLobby = RequireMethod( lobbyType, \u0022Join\u0022 );\n\t\tvar leaveLobby = RequireMethod( lobbyType, \u0022Leave\u0022 );\n\t\tvar waitForMembership = RequireMethod( channelType, \u0022WaitForMembership\u0022, lobbyType );\n\t\tvar eventSystem = RequireProperty( RequireType( typeof( Scene ).Assembly, \u0022Sandbox.Event\u0022 ), \u0022EventSystem\u0022 ).GetValue( null );\n\t\tvar runInterface = eventSystem.GetType().GetMethods( All ).Single( x =\u003E x.Name == \u0022RunInterface\u0022 \u0026\u0026 x.IsGenericMethodDefinition \u0026\u0026 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 \u003E= available.MaxMembers ) throw new InvalidOperationException( \u0022The party is unavailable or full.\u0022 );\n\t\tif ( PartyRoom.Current is not null ) throw new InvalidOperationException( \u0022Your party changed while joining. Please try again.\u0022 );\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() != \u0022Success\u0022 ) throw new InvalidOperationException( $\u0022Could not join the party: {response}.\u0022 );\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( \u0022Your party changed while joining. Please try again.\u0022 );\n\t\t\tparty = (PartyRoom)partyConstructor.Invoke( [lobby] );\n\t\t\tInvoke( setCurrent, null, party );\n\t\t\tInvoke( runInterface, eventSystem, (Action\u003CPartyRoom.IEventListener\u003E)(listener =\u003E 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\u003Cint\u003E ValidatePrivatePartyAsync()\n\t{\n\t\tusing var scope = EnterMenuScope();\n\t\tif ( PartyRoom.Current is not null ) throw new InvalidOperationException( \u0022Leave your party before running the private party check.\u0022 );\n\t\tvar matchmaking = lobbyList.DeclaringType;\n\t\tvar lobbyType = lobbyId.DeclaringType;\n\t\tvar accessType = RequireType( typeof( Scene ).Assembly, \u0022Steamworks.LobbyType\u0022 );\n\t\tvar createLobby = RequireMethod( matchmaking, \u0022CreateLobbyAsync\u0022, accessType, typeof( int ) );\n\t\tvar setData = RequireMethod( lobbyType, \u0022SetData\u0022, typeof( string ), typeof( string ) );\n\t\tvar leave = RequireMethod( lobbyType, \u0022Leave\u0022 );\n\t\tvar lobby = await AwaitResult( Invoke( createLobby, null, Enum.Parse( accessType, \u0022Private\u0022 ), 2 ) )\n\t\t\t?? throw new InvalidOperationException( \u0022Steam could not create the private validation party.\u0022 );\n\t\tvar id = (ulong)steamIdValue.GetValue( lobbyId.GetValue( lobby ) );\n\t\ttry\n\t\t{\n\t\t\tif ( !(bool)Invoke( setData, lobby, \u0022lobby_type\u0022, \u0022party\u0022 ) || !(bool)Invoke( setData, lobby, \u0022name\u0022, \u0022Editor Global Chat validation\u0022 ) )\n\t\t\t\tthrow new InvalidOperationException( \u0022Steam could not set up the private validation party.\u0022 );\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( \u0022The private validation party did not match the expected membership.\u0022 );\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( $\u0022{type.FullName}.{name}\u0022 );\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( $\u0022{type.FullName}.{name}\u0022 );\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\u003Cobject\u003E AwaitResult( object value )\n\t{\n\t\tif ( value is not Task task ) throw Incompatible( \u0022an asynchronous engine method result\u0022 );\n\t\tawait task;\n\t\treturn RequireProperty( task.GetType(), \u0022Result\u0022 ).GetValue( task );\n\t}\n\n\tstatic InvalidOperationException Incompatible( string member )\n\t{\n\t\treturn new( $\u0022Editor Global Chat cannot use this engine version: missing {member}. Update the library\u0027s EngineChatBridge.\u0022 );\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\u003CFriend, string\u003E onReceived;\n\treadonly Action onChanged;\n\tbool disposed;\n\tpublic event Action\u003CFriend, string\u003E MessageReceived;\n\tpublic event Action Changed;\n\tpublic ulong Id =\u003E (ulong)id.GetValue( handle );\n\tpublic Friend Owner =\u003E (Friend)owner.GetValue( handle );\n\tpublic int MemberCount =\u003E (int)memberCount.GetValue( handle );\n\tpublic IEnumerable\u003CFriend\u003E Members =\u003E (IEnumerable\u003CFriend\u003E)members.GetValue( handle );\n\tpublic bool IsJoined =\u003E !disposed \u0026\u0026 (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, \u0022Id\u0022 );\n\t\towner = EngineChatBridge.RequireProperty( type, \u0022Owner\u0022 );\n\t\tmembers = EngineChatBridge.RequireProperty( type, \u0022Members\u0022 );\n\t\tmemberCount = EngineChatBridge.RequireProperty( type, \u0022MemberCount\u0022 );\n\t\tisJoined = EngineChatBridge.RequireProperty( type, \u0022IsJoined\u0022 );\n\t\tgetData = EngineChatBridge.RequireMethod( type, \u0022GetData\u0022, typeof( string ) );\n\t\tsetData = EngineChatBridge.RequireMethod( type, \u0022SetData\u0022, typeof( string ), typeof( string ) );\n\t\tsend = EngineChatBridge.RequireMethod( type, \u0022Send\u0022, typeof( string ) );\n\t\treceived = type.GetEvent( \u0022MessageReceived\u0022 );\n\t\tchanged = type.GetEvent( \u0022Changed\u0022 );\n\t\tonReceived = (friend, text) =\u003E MessageReceived?.Invoke( friend, text );\n\t\tonChanged = () =\u003E 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 ) =\u003E (string)EngineChatBridge.Invoke( getData, handle, key );\n\tpublic void SetData( string key, string value ) =\u003E EngineChatBridge.Invoke( setData, handle, key, value );\n\tpublic bool Send( string text ) =\u003E (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/// \u003Csummary\u003EHosts the menu-style Razor chat in an editor panel window.\u003C/summary\u003E\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 =\u003E EditorCookie.Get( \u0022GlobalChat.MentionPopups\u0022, true );\n\t\tset =\u003E EditorCookie.Set( \u0022GlobalChat.MentionPopups\u0022, value );\n\t}\n\tpublic static bool MentionSounds\n\t{\n\t\tget =\u003E EditorCookie.Get( \u0022GlobalChat.MentionSounds\u0022, true );\n\t\tset =\u003E EditorCookie.Set( \u0022GlobalChat.MentionSounds\u0022, value );\n\t}\n\n\t[Menu( \u0022Editor\u0022, \u0022Chat/Global Chat\u0022, \u0022forum\u0022 )]\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( \u0022Global Chat\u0022, 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( \u0022#171a21\u0022 ) ?? Color.Black;\n\t\t\tSession = sharedSession ??= new GlobalChatSession();\n\t\t\tSession.Draft = EditorCookie.Get( \u0022GlobalChat.Draft\u0022, \u0022\u0022 );\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 \u002B= 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( \u0022Leave your current party before joining another.\u0022 );\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 = \u0022Mention in global chat\u0022, Subtitle = message.Sender.Name, Icon = \u0022alternate_email\u0022, 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( \u0022Open chat\u0022, \u0022forum\u0022 ) );\n\t\t\topen.Clicked = () =\u003E { Open(); Current.View.ScrollToLatest(); ToastManager.Dismiss( toast ); };\n\t\t\ttoast.SetBodyWidget( body );\n\t\t\tToastManager.Remove( toast, 8 );\n\t\t\tPopupCount\u002B\u002B;\n\t\t}\n\t\tif ( MentionSounds )\n\t\t{\n\t\t\tif ( ChatMentionSound.Play( out var error ) ) SoundCount\u002B\u002B;\n\t\t\telse Log.Warning( $\u0022Global chat mention sound failed: {error}\u0022 );\n\t\t\tSoundError = error;\n\t\t}\n\t}\n\n\tinternal static string Escape( string text ) =\u003E \u0022\u003Cspan\u003E\u0022 \u002B WebUtility.HtmlEncode( text ?? \u0022\u0022 ).Replace( \u0022\\n\u0022, \u0022\u003Cbr\u003E\u0022 ) \u002B \u0022\u003C/span\u003E\u0022;\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 \u0026\u0026 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( \u0022app.exit\u0022 )]\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 =\u003E 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/// \u003Csummary\u003EEmbeds the existing SDL surface in a native editor widget on Windows.\u003C/summary\u003E\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 ), \u0022Frame\u0022 );\n\t\tif ( !OperatingSystem.IsWindows() ) throw new PlatformNotSupportedException( \u0022Docked Global Chat currently requires the Windows editor.\u0022 );\n\t\tvar nativeWidget = typeof( Widget ).GetField( \u0022_widget\u0022, BindingFlags.Instance | BindingFlags.NonPublic )?.GetValue( widget )\n\t\t\t?? throw new InvalidOperationException( \u0022The editor does not expose the native dock widget.\u0022 );\n\t\tparent = (IntPtr)EngineChatBridge.Invoke( EngineChatBridge.RequireMethod( nativeWidget.GetType(), \u0022winId\u0022 ), nativeWidget );\n\t\tvar sdl = EngineChatBridge.RequireType( typeof( Friend ).Assembly, \u0022NativeEngine.Sdl\u0022 );\n\t\tvar handle = EngineChatBridge.RequireProperty( typeof( PanelWindow ), \u0022Handle\u0022 ).GetValue( window );\n\t\tsdlHandle = (IntPtr)handle;\n\t\tvar input = EngineChatBridge.RequireType( typeof( Friend ).Assembly, \u0022Sandbox.UI.PanelWindowInput\u0022 );\n\t\tmouseMove = EngineChatBridge.RequireMethod( input, \u0022OnMouseMove\u0022, typeof( IntPtr ), typeof( float ), typeof( float ), typeof( float ), typeof( float ) );\n\t\tvar buttonType = EngineChatBridge.RequireType( typeof( Friend ).Assembly, \u0022NativeEngine.ButtonCode\u0022 );\n\t\tmouseButton = EngineChatBridge.RequireMethod( input, \u0022OnMouseButton\u0022, typeof( IntPtr ), buttonType, typeof( bool ), typeof( int ), typeof( KeyboardModifiers ) );\n\t\tbuttons = [Enum.Parse( buttonType, \u0022MouseLeft\u0022 ), Enum.Parse( buttonType, \u0022MouseRight\u0022 ), Enum.Parse( buttonType, \u0022MouseMiddle\u0022 )];\n\t\tvar properties = EngineChatBridge.Invoke( EngineChatBridge.RequireMethod( sdl, \u0022GetWindowProperties\u0022, typeof( IntPtr ) ), null, handle );\n\t\tchild = (IntPtr)EngineChatBridge.Invoke( EngineChatBridge.RequireMethod( sdl, \u0022GetPointerProperty\u0022, typeof( uint ), typeof( string ), typeof( IntPtr ) ), null, properties, \u0022SDL.window.win32.hwnd\u0022, IntPtr.Zero );\n\t\tif ( parent == IntPtr.Zero || child == IntPtr.Zero ) throw new InvalidOperationException( \u0022The editor did not create both native chat handles.\u0022 );\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) \u0026 ~(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(), \u0022Could not attach chat to its editor dock.\u0022 );\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\u0027s 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( \u0022Could not install input handling for the chat dock.\u0022 );\n\t\t}\n\t\tinputHookInstalled = true;\n\t}\n\n\tvoid IHotloadManaged.Destroyed( Dictionary\u003Cstring, object\u003E state )\n\t{\n\t\tstate[\u0022InputHookInstalled\u0022] = inputHookInstalled;\n\t\tRemoveInputHook();\n\t}\n\n\tvoid IHotloadManaged.Created( IReadOnlyDictionary\u003Cstring, object\u003E state )\n\t{\n\t\tinputHookInstalled = false;\n\t\tcallbackRoot = IntPtr.Zero;\n\t\tif ( state.TryGetValue( \u0022InputHookInstalled\u0022, out var installed ) \u0026\u0026 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\u0027s 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 \u003E= 0x0200 and \u003C= 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 \u003E\u003E 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 \u003E\u003E 16), 0f, 0f );\n\t\t\t\tvar button = buttons[message \u003C= 0x0203 ? 0 : message \u003C= 0x0206 ? 1 : 2];\n\t\t\t\tvar modifiers = KeyboardModifiers.None;\n\t\t\t\tif ( (GetKeyState( 0x10 ) \u0026 0x8000) != 0 ) modifiers |= KeyboardModifiers.Shift;\n\t\t\t\tif ( (GetKeyState( 0x11 ) \u0026 0x8000) != 0 ) modifiers |= KeyboardModifiers.Ctrl;\n\t\t\t\tif ( (GetKeyState( 0x12 ) \u0026 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, \u0022Global chat native mouse input failed.\u0022 ); }\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( \u0022Could not remove input handling for the chat dock.\u0022 );\n\t\tinputHookInstalled = false;\n\t\tGCHandle.FromIntPtr( callbackRoot ).Free();\n\t\tcallbackRoot = IntPtr.Zero;\n\t}\n\n\tinternal bool IsAttached =\u003E GetParent( child ) == parent;\n\tinternal IntPtr Handle =\u003E 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 \u003C 1 || target.Bottom \u003C 1 || (positioned \u0026\u0026 target.Right == current.Right \u0026\u0026 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( \u0022comctl32.dll\u0022 )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tstatic extern bool SetWindowSubclass( IntPtr window, SubclassProc callback, UIntPtr id, UIntPtr data );\n\t[DllImport( \u0022comctl32.dll\u0022 )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tstatic extern bool RemoveWindowSubclass( IntPtr window, SubclassProc callback, UIntPtr id );\n\t[DllImport( \u0022comctl32.dll\u0022 )]\n\tstatic extern IntPtr DefSubclassProc( IntPtr window, uint message, UIntPtr wParam, IntPtr lParam );\n\t[DllImport( \u0022user32.dll\u0022 )]\n\tstatic extern IntPtr SetFocus( IntPtr window );\n\t[DllImport( \u0022user32.dll\u0022 )]\n\tstatic extern IntPtr SetCapture( IntPtr window );\n\t[DllImport( \u0022user32.dll\u0022 )]\n\tstatic extern bool ReleaseCapture();\n\t[DllImport( \u0022user32.dll\u0022 )]\n\tstatic extern short GetKeyState( int key );\n\t[DllImport( \u0022user32.dll\u0022, EntryPoint = \u0022GetWindowLongPtrW\u0022, SetLastError = true )]\n\tstatic extern IntPtr GetWindowLongPtr( IntPtr window, int index );\n\t[DllImport( \u0022user32.dll\u0022, EntryPoint = \u0022SetWindowLongPtrW\u0022, SetLastError = true )]\n\tstatic extern IntPtr SetWindowLongPtr( IntPtr window, int index, IntPtr value );\n\t[DllImport( \u0022user32.dll\u0022, SetLastError = true )]\n\tstatic extern IntPtr SetParent( IntPtr child, IntPtr parent );\n\t[DllImport( \u0022user32.dll\u0022 )]\n\tstatic extern IntPtr GetParent( IntPtr child );\n\t[DllImport( \u0022user32.dll\u0022, SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tstatic extern bool GetClientRect( IntPtr window, out NativeRect rect );\n\t[DllImport( \u0022user32.dll\u0022, 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/// \u003Csummary\u003EChecks the Windows mouse, focus, keyboard and SDL route without sending chat.\u003C/summary\u003E\ninternal static class ChatNativeInputValidation\n{\n\tinternal static async Task\u003Cobject\u003E Run( GlobalChatWindow window )\n\t{\n\t\tif ( !OperatingSystem.IsWindows() ) throw new PlatformNotSupportedException( \u0022Native chat input validation requires Windows.\u0022 );\n\t\tif ( (GetAsyncKeyState( 0x10 ) \u0026 0x8000) != 0 || (GetAsyncKeyState( 0x11 ) \u0026 0x8000) != 0 || (GetAsyncKeyState( 0x12 ) \u0026 0x8000) != 0 )\n\t\t\tthrow new InvalidOperationException( \u0022Release Shift, Ctrl and Alt before checking native chat input.\u0022 );\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, \u0022chat-input\u0022 );\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 = \u0022\u0022;\n\t\t\twindow.Session.Draft = \u0022\u0022;\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( $\u0022Clicking 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}.\u0022 );\n\t\t\tconst string sample = \u0022Native keyboard check \u2713 \uD83D\uDE0A ~ \u0060\u0022;\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( $\u0022Windows typing did not reach chat: expected \u0027{sample}\u0027, received \u0027{input.Text}\u0027. Field focus: {input.HasFocus}; dock focus: {host.IsFocused}; editor focus: {Application.FocusWidget?.GetType().FullName}.\u0022 );\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( \u0022Windows Backspace did not edit the chat draft.\u0022 );\n\t\t\tPressKey( 0x24 ); // Home followed immediately by text must retain event order.\n\t\t\tTypeUnicode( \u0022#@\u0022 );\n\t\t\tawait Task.Delay( 150 );\n\t\t\tif ( input.Text != \u0022#@\u0022 \u002B sample[..^1] ) throw new InvalidOperationException( \u0022Home followed by typing did not retain keyboard event order.\u0022 );\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( \u0022Ctrl\u002BA and Backspace did not clear the draft.\u0022 );\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 = \u0022Paste selection check\u0022;\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( \u0022Ctrl\u002BV did not reach the chat paste policy.\u0022 );\n\t\t\tSetFocus( root );\n\t\t\tTypeUnicode( \u0022\u2713\u0022 );\n\t\t\tawait Task.Delay( 150 );\n\t\t\tif ( input.Text != pasted ) throw new InvalidOperationException( \u0022Chat consumed keys after losing native focus.\u0022 );\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( \u0022GlobalChat.Draft\u0022, 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( \u0022The expected Razor control is absent.\u0022 );\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( $\u0022The 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 )}.\u0022 );\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 \u003C text.Length; i\u002B\u002B )\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 \u002B 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\u003CNativeInput\u003E() ) != events.Length ) throw new Win32Exception( Marshal.GetLastPInvokeError(), \u0022Windows did not accept the native input check.\u0022 );\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( \u0022user32.dll\u0022, SetLastError = true )]\n\tstatic extern uint SendInput( uint count, NativeInput[] events, int size );\n\t[DllImport( \u0022user32.dll\u0022 )] static extern IntPtr GetFocus();\n\t[DllImport( \u0022user32.dll\u0022 )] static extern IntPtr WindowFromPoint( NativePoint point );\n\t[DllImport( \u0022user32.dll\u0022 )] static extern bool GetWindowRect( IntPtr window, out NativeRect rect );\n\t[DllImport( \u0022user32.dll\u0022 )] static extern bool IsWindowVisible( IntPtr window );\n\t[DllImport( \u0022user32.dll\u0022 )] static extern IntPtr SetFocus( IntPtr window );\n\t[DllImport( \u0022user32.dll\u0022 )] static extern IntPtr GetAncestor( IntPtr window, uint flags );\n\t[DllImport( \u0022user32.dll\u0022 )] static extern bool SetForegroundWindow( IntPtr window );\n\t[DllImport( \u0022user32.dll\u0022 )] static extern short GetAsyncKeyState( int key );\n\t[DllImport( \u0022user32.dll\u0022, SetLastError = true )] static extern bool GetCursorPos( out NativePoint point );\n\t[DllImport( \u0022user32.dll\u0022, SetLastError = true )] static extern bool SetCursorPos( int x, int y );\n\t[DllImport( \u0022user32.dll\u0022, 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 = \u0022\u0022\u0022\n@import \u0022/styles/_front.scss\u0022;\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\u003E 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\u0026.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\u0026: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\u003E 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\u0026:hover\r\n\t\t{\r\n\t\t\tbackground-color: rgba( 255, 255, 255, 0.05 );\r\n\t\t\tsound-in: \u0022ui.button.over\u0022;\r\n\t\t}\r\n\r\n\t\t\u0026:active\r\n\t\t{\r\n\t\t\tsound-in: \u0022ui.button.press\u0022;\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\u0026: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\u0026:hover\r\n\t\t{\r\n\t\t\tsound-in: \u0022ui.button.over\u0022;\r\n\t\t}\r\n\r\n\t\t\u0026:active\r\n\t\t{\r\n\t\t\tsound-in: \u0022ui.button.press\u0022;\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\u0026:hover\r\n\t\t{\r\n\t\t\tsound-in: \u0022ui.button.over\u0022;\r\n\t\t}\r\n\r\n\t\t\u0026:active\r\n\t\t{\r\n\t\t\tsound-in: \u0022ui.button.press\u0022;\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\u0026: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: \u0022Material Icons\u0022; }\n.chat-settings checkbox .checkmark { font-family: \u0022Material Icons\u0022; 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 \u003E 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\u0022\u0022\u0022;\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\u003Croot class=\u0022editor-global-chat\u0022\u003E\n\t\u003Cdiv class=\u0022chat-grain\u0022 @ref=\u0022grain\u0022\u003E\u003C/div\u003E\n\t\u003Cdiv class=\u0022global-conversation\u0022\u003E\n\t\t\u003Cdiv id=\u0022chat-conversation-heading\u0022 class=\u0022conversation-heading\u0022\u003E\n\t\t\t\u003Clabel\u003E@(Session.Channel is null ? \u0022Connecting to global chat\u2026\u0022 : $\u0022{Session.Channel.MemberCount} here \u00B7 say hello or share your party\u0022)\u003C/label\u003E\n\t\t\t\u003CSandbox.UI.Button Icon=\u0022refresh\u0022 onclick=@Reconnect tooltip=\u0022Reconnect to global chat\u0022 /\u003E\n\t\t\t\u003CSandbox.UI.Button id=\u0022chat-settings-toggle\u0022 Icon=\u0022settings\u0022 onclick=@ToggleSettings tooltip=\u0022Mention settings\u0022 /\u003E\n\t\t\t\u003CSandbox.UI.Button id=\u0022chat-members-toggle\u0022 class=@(MembersVisible ? \u0022active\u0022 : \u0022\u0022) Icon=\u0022group\u0022 onclick=@(() =\u003E MembersVisible = !MembersVisible) tooltip=@(MembersVisible ? \u0022Hide users in chat\u0022 : \u0022Show users in chat\u0022) /\u003E\n\t\t\u003C/div\u003E\n\t\t@if ( settingsOpen )\n\t\t{\n\t\t\t\u003Cdiv class=\u0022chat-settings\u0022\u003E\n\t\t\t\t\u003CSandbox.UI.Checkbox id=\u0022chat-popups\u0022 Value=@GlobalChatWindow.MentionPopups ValueChanged=@((bool value) =\u003E { GlobalChatWindow.MentionPopups = value; })\u003EMention popups\u003C/Sandbox.UI.Checkbox\u003E\n\t\t\t\t\u003CSandbox.UI.Checkbox id=\u0022chat-sounds\u0022 Value=@GlobalChatWindow.MentionSounds ValueChanged=@((bool value) =\u003E { GlobalChatWindow.MentionSounds = value; })\u003EMention sound\u003C/Sandbox.UI.Checkbox\u003E\n\t\t\t\u003C/div\u003E\n\t\t}\n\t\t@if ( Session.Connecting ) { \u003Cdiv class=\u0022chat-notice\u0022\u003EJoining global chat\u2026\u003C/div\u003E }\n\t\t@if ( !string.IsNullOrEmpty( Session.Error ?? Host.PartyError ) ) { \u003Cdiv class=\u0022chat-notice error\u0022\u003E@(Session.Error ?? Host.PartyError)\u003C/div\u003E }\n\t\t\u003Cdiv class=\u0022chat-messages\u0022 @ref=\u0022messageList\u0022\u003E\n\t\t\t@if ( DisplayMessages.Count == 0 )\n\t\t\t{\n\t\t\t\t\u003Cdiv class=\u0022chat-empty\u0022\u003E\u003Cicon\u003Eforum\u003C/icon\u003E\u003Clabel\u003ESay hello. Find your next group.\u003C/label\u003E\u003C/div\u003E\n\t\t\t}\n\t\t\t@foreach ( var message in DisplayMessages.Where( x =\u003E !x.Sender.IsBlocked ) )\n\t\t\t{\n\t\t\t\t\u003Cdiv class=\u0022chat-message\u0022 @key=message.Id\u003E\n\t\t\t\t\t\u003Cdiv class=\u0022chat-avatar\u0022 style=\u0022background-image: url(avatar:@message.Sender.Id)\u0022 onclick=@((PanelEvent e) =\u003E ShowUserActions( message.Sender, e.This )) onrightclick=@(() =\u003E InsertMention( message.Sender ))\u003E\u003C/div\u003E\n\t\t\t\t\t\u003Cdiv class=\u0022chat-message-body\u0022\u003E\n\t\t\t\t\t\t\u003Clabel id=@($\u0022chat-name-{message.Id}\u0022) class=\u0022chat-name\u0022 onclick=@((PanelEvent e) =\u003E ShowUserActions( message.Sender, e.This )) onrightclick=@(() =\u003E InsertMention( message.Sender ))\u003E@message.Sender.Name\u003C/label\u003E\n\t\t\t\t\t\t@if ( message.PartyId == 0 ) { \u003Clabel\u003E@message.Text\u003C/label\u003E }\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\u003Cdiv class=\u0022party-invitation\u0022\u003E\n\t\t\t\t\t\t\t\t\u003Cdiv class=\u0022invitation-summary\u0022\u003E\n\t\t\t\t\t\t\t\t\t\u003Clabel class=\u0022invitation-name\u0022\u003E@(entry?.Name ?? \u0022Party invitation\u0022)\u003C/label\u003E\n\t\t\t\t\t\t\t\t\t\u003Clabel class=\u0022invitation-details\u0022\u003E@(string.IsNullOrWhiteSpace( entry?.GameTitle ) ? \u0022Deciding together\u0022 : entry.GameTitle)\u003C/label\u003E\n\t\t\t\t\t\t\t\t\t\u003Clabel class=\u0022invitation-details\u0022\u003E@(entry is null ? \u0022Checking party\u2026\u0022 : !entry.Available ? \u0022Party unavailable \u00B7 ended or could not be reached\u0022 : $\u0022{entry.Members}/{entry.Capacity} players \u00B7 {(entry.IsFull ? \u0022Party full\u0022 : entry.IsPlaying ? \u0022In game\u0022 : \u0022Looking for players\u0022)}\u0022)\u003C/label\u003E\n\t\t\t\t\t\t\t\t\u003C/div\u003E\n\t\t\t\t\t\t\t\t\u003CSandbox.UI.Button class=\u0022primary\u0022 disabled=@(!CanJoin( message.PartyId )) onclick=@(() =\u003E JoinParty( message.PartyId )) tooltip=\u0022Leave your current party before joining another.\u0022\u003E@(PartyRoom.Current?.Id.ValueUnsigned == message.PartyId ? \u0022Your party\u0022 : \u0022Join party\u0022)\u003C/Sandbox.UI.Button\u003E\n\t\t\t\t\t\t\t\u003C/div\u003E\n\t\t\t\t\t\t}\n\t\t\t\t\t\u003C/div\u003E\n\t\t\t\t\u003C/div\u003E\n\t\t\t}\n\t\t\u003C/div\u003E\n\t\t\u003Cdiv class=\u0022chat-composer-container\u0022\u003E\n\t\t\t\u003Cdiv class=\u0022chat-composer\u0022\u003E\n\t\t\t\t\u003CSandbox.UI.TextEntry id=\u0022chat-input\u0022 @ref=\u0022input\u0022 Text=@Session.Draft OnTextEdited=@SaveDraft MaxLength=@(400) Placeholder=\u0022Say hello\u2026\u0022 onsubmit=@Submit /\u003E\n\t\t\t\t\u003CSandbox.UI.Button Icon=\u0022send\u0022 disabled=@(!Session.CanSend) onclick=@Submit tooltip=\u0022Send message\u0022 /\u003E\n\t\t\t\u003C/div\u003E\n\t\t\t\u003Cdiv class=\u0022chat-actions\u0022\u003E\n\t\t\t\t@if ( PartyRoom.Current is { } party )\n\t\t\t\t{\n\t\t\t\t\t\u003Clabel\u003E@party.MemberCount in your party\u003C/label\u003E\n\t\t\t\t\t@if ( party.Owner.IsMe ) { \u003CSandbox.UI.Button Icon=\u0022campaign\u0022 disabled=@(!Session.CanInviteParty) onclick=@InviteParty tooltip=\u0022Post an invitation so anyone in global chat can join\u0022\u003EPost party invite\u003C/Sandbox.UI.Button\u003E }\n\t\t\t\t}\n\t\t\t\u003C/div\u003E\n\t\t\u003C/div\u003E\n\t\u003C/div\u003E\n\t@if ( MembersVisible )\n\t{\n\t\t\u003Cdiv id=\u0022chat-member-list\u0022 class=\u0022chat-members\u0022\u003E\n\t\t\t\u003Cdiv class=\u0022people-heading\u0022\u003E\u003Clabel\u003EIn chat\u003C/label\u003E\u003Clabel\u003E@(Session.Channel?.MemberCount ?? 0)\u003C/label\u003E\u003C/div\u003E\n\t\t\t\u003Cdiv class=\u0022chat-people\u0022\u003E\n\t\t\t\t@foreach ( var member in Members )\n\t\t\t\t{\n\t\t\t\t\t\u003Cbutton id=@($\u0022chat-member-{member.Id}\u0022) class=\u0022chat-person\u0022 @key=member.Id onclick=@((PanelEvent e) =\u003E ShowUserActions( member, e.This )) onrightclick=@(() =\u003E InsertMention( member )) tooltip=\u0022User actions. Right-click to add a mention.\u0022\u003E\n\t\t\t\t\t\t\u003Cdiv class=\u0022chat-avatar\u0022 style=\u0022background-image: url(avatar:@member.Id)\u0022\u003E\u003C/div\u003E\u003Clabel\u003E@member.Name\u003C/label\u003E\n\t\t\t\t\t\u003C/button\u003E\n\t\t\t\t}\n\t\t\t\u003C/div\u003E\n\t\t\u003C/div\u003E\n\t\t}\n\t@if ( actionUser is { } user )\n\t{\n\t\t\u003Cdiv id=\u0022chat-user-dismiss\u0022 class=\u0022chat-user-dismiss\u0022 onclick=@DismissUserActions onrightclick=@DismissUserActions\u003E\u003C/div\u003E\n\t\t\u003Cdiv id=\u0022chat-user-actions\u0022 class=\u0022chat-user-actions\u0022 @ref=\u0022userActions\u0022\u003E\n\t\t\t\u003Cdiv class=\u0022chat-user-heading\u0022\u003E\u003Clabel\u003E@user.Name\u003C/label\u003E\u003CSandbox.UI.Button Icon=\u0022close\u0022 onclick=@DismissUserActions tooltip=\u0022Close user actions\u0022 /\u003E\u003C/div\u003E\n\t\t\t\u003CSandbox.UI.Button id=\u0022chat-user-steam\u0022 Icon=\u0022open_in_new\u0022 onclick=@(() =\u003E OpenProfile( $\u0022https://steamcommunity.com/profiles/{user.Id}\u0022 ))\u003ESteam profile\u003C/Sandbox.UI.Button\u003E\n\t\t\t\u003CSandbox.UI.Button id=\u0022chat-user-website\u0022 Icon=\u0022language\u0022 onclick=@(() =\u003E OpenProfile( $\u0022https://sbox.game/u/{user.Id}\u0022 ))\u003Es\u0026amp;box website profile\u003C/Sandbox.UI.Button\u003E\n\t\t\t\u003CSandbox.UI.Button id=\u0022chat-user-mention\u0022 Icon=\u0022alternate_email\u0022 disabled=@(input.Text.Length \u002B GetMentionSuffix( user ).Length \u003E 400) tooltip=\u0022Add a mention to the draft. Make space first if the message is full.\u0022 onclick=@(() =\u003E InsertMention( user ))\u003EMention in chat\u003C/Sandbox.UI.Button\u003E\n\t\t\u003C/div\u003E\n\t}\n\u003C/root\u003E\n\n@code\n{\n\tinternal GlobalChatSession Session { get; set; }\n\tinternal GlobalChatWindow Host { get; set; }\n\tinternal System.Collections.Generic.List\u003CGlobalChatSession.ChatLine\u003E PreviewMessages { get; set; }\n\tSystem.Collections.Generic.List\u003CGlobalChatSession.ChatLine\u003E DisplayMessages =\u003E 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( \u0022GlobalChat.MembersVisible\u0022, true );\n\tinternal bool MembersVisible\n\t{\n\t\tget =\u003E membersVisible;\n\t\tset\n\t\t{\n\t\t\tif ( membersVisible == value ) return;\n\t\t\tmembersVisible = value;\n\t\t\tEditorCookie.Set( \u0022GlobalChat.MembersVisible\u0022, value );\n\t\t\tDismissUserActions();\n\t\t}\n\t}\n\tinternal bool SettingsOpen\n\t{\n\t\tget =\u003E settingsOpen;\n\t\tset { settingsOpen = value; StateHasChanged(); }\n\t}\n\tint renderedRevision = -1;\n\tFriend[] Members =\u003E Session.Channel?.Members.Where( x =\u003E !x.IsBlocked ).OrderBy( x =\u003E x.Name, StringComparer.OrdinalIgnoreCase ).ToArray() ?? [];\n\n\tpublic GlobalChatView()\n\t{\n\t\tAcceptsFocus = true;\n\t\tStyleSheet.AddInline( GlobalChatTheme.Styles, \u0022editor-global-chat-theme\u0022 );\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 ), \u0022ClearRenderTree\u0022 ), this );\n\t\tbase.OnHotloaded();\n\t\tStyleSheet.AddInline( GlobalChatTheme.Styles, \u0022editor-global-chat-theme\u0022 );\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( \u0022GlobalChat.Draft\u0022, 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 = \u0022\u0022;\n\t\t\tSaveDraft( \u0022\u0022 );\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 != \u0022escape\u0022 || 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 ) =\u003E !Host.PartyBusy \u0026\u0026 PartyRoom.Current is null \u0026\u0026 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 \u002B mention.Length \u003E 400 ) return;\n\t\tinput.Text \u002B= 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] ) ? \u0022\u0022 : \u0022 \u0022;\n\t\treturn separator \u002B \u0022@\u0022 \u002B member.Name \u002B \u0022 \u0022;\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 \u002B 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( \u0022/ui/panel-paper.png\u0022 );\n\t\tmessageList.PreferScrollToBottom = true;\n\t\tif ( firstTime || scrollAfterRender || (renderedRevision != Session.Revision \u0026\u0026 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/// \u003Csummary\u003ERoutes keys from the focused child before Qt applies editor shortcuts.\u003C/summary\u003E\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, \u0022Sandbox.UI.PanelWindowInput\u0022 );\n\t\tvar buttons = EngineChatBridge.RequireType( assembly, \u0022NativeEngine.ButtonCode\u0022 );\n\t\ttranslateKey = EngineChatBridge.RequireMethod( EngineChatBridge.RequireType( assembly, \u0022Sandbox.Engine.KeyTranslation\u0022 ), \u0022VirtualKeyToButtonCode\u0022, typeof( int ) );\n\t\tkeyInput = EngineChatBridge.RequireMethod( input, \u0022OnKey\u0022, typeof( IntPtr ), buttons, typeof( bool ), typeof( KeyboardModifiers ) );\n\t\ttextInput = EngineChatBridge.RequireMethod( input, \u0022OnText\u0022, typeof( IntPtr ), typeof( string ) );\n\t\tfocusInput = EngineChatBridge.RequireMethod( input, \u0022OnFocus\u0022, typeof( IntPtr ), typeof( bool ) );\n\t\tsurface = EngineChatBridge.RequireProperty( typeof( PanelWindow ), \u0022Surface\u0022 ).GetValue( chat );\n\t\tuiSystem = EngineChatBridge.RequireProperty( surface.GetType(), \u0022System\u0022 ).GetValue( surface );\n\t\ttickInput = EngineChatBridge.RequireMethod( uiSystem.GetType(), \u0022TickSurfaceInput\u0022, typeof( bool ) );\n\t\tmouseInside = EngineChatBridge.RequireProperty( surface.GetType(), \u0022MouseInside\u0022 );\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, \u0022Could not install chat keyboard input.\u0022 );\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 \u003E= 0 \u0026\u0026 removed == (UIntPtr)1 \u0026\u0026 GetFocus() == window )\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar message = Marshal.PtrToStructure\u003CNativeMessage\u003E( pointer );\n\t\t\t\tif ( message.Window == window \u0026\u0026 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 ) \u0026 0x8000) != 0 ) modifiers |= KeyboardModifiers.Shift;\n\t\t\t\t\t\tif ( (GetKeyState( 0x11 ) \u0026 0x8000) != 0 ) modifiers |= KeyboardModifiers.Ctrl;\n\t\t\t\t\t\tif ( (GetKeyState( 0x12 ) \u0026 0x8000) != 0 ) modifiers |= KeyboardModifiers.Alt;\n\t\t\t\t\t\t// Leave the operating system\u0027s close command intact.\n\t\t\t\t\t\tif ( key == 0x73 \u0026\u0026 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\u0027s 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, \u0022Global chat native keyboard input failed.\u0022 ); }\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(), \u0022Could not remove chat keyboard input.\u0022 );\n\t\thook = IntPtr.Zero;\n\t\tGCHandle.FromIntPtr( callbackRoot ).Free();\n\t\tcallbackRoot = IntPtr.Zero;\n\t}\n\n\tvoid IHotloadManaged.Destroyed( Dictionary\u003Cstring, object\u003E state )\n\t{\n\t\tstate[\u0022Installed\u0022] = hook != IntPtr.Zero;\n\t\tDispose();\n\t}\n\n\tvoid IHotloadManaged.Created( IReadOnlyDictionary\u003Cstring, object\u003E 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( \u0022Installed\u0022, out var installed ) \u0026\u0026 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( \u0022user32.dll\u0022, EntryPoint = \u0022SetWindowsHookExW\u0022, SetLastError = true )]\n\tstatic extern IntPtr SetWindowsHookEx( int type, MessageHook callback, IntPtr module, uint thread );\n\t[DllImport( \u0022user32.dll\u0022, SetLastError = true )] static extern bool UnhookWindowsHookEx( IntPtr hook );\n\t[DllImport( \u0022user32.dll\u0022 )] static extern IntPtr CallNextHookEx( IntPtr hook, int code, UIntPtr removed, IntPtr pointer );\n\t[DllImport( \u0022user32.dll\u0022 )] static extern uint GetWindowThreadProcessId( IntPtr window, out uint process );\n\t[DllImport( \u0022user32.dll\u0022 )] static extern IntPtr GetFocus();\n\t[DllImport( \u0022user32.dll\u0022 )] static extern short GetKeyState( int key );\n\t[DllImport( \u0022user32.dll\u0022 )] 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/// \u003Csummary\u003ELocal checks through Windows input. Never activates Send or a party action.\u003C/summary\u003E\ninternal static class ChatViewValidation\n{\n\tinternal static async Task\u003Cobject\u003E 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 ), \u0022Frame\u0022 );\n\t\twindow.View.DismissUserActions();\n\t\tEngineChatBridge.Invoke( frame, window );\n\t\tvar input = (TextEntry)Find( window.Root, \u0022chat-input\u0022 );\n\t\tvar settingsWereOpen = Find( window.Root, \u0022chat-popups\u0022 ) 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, \u0022chat-conversation-heading\u0022 );\n\t\t\tvar shownWidth = heading.Box.Rect.Width;\n\t\t\tawait Click( window, Find( window.Root, \u0022chat-members-toggle\u0022 ), frame );\n\t\t\tif ( window.View.MembersVisible || Find( window.Root, \u0022chat-member-list\u0022 ) is not null || heading.Box.Rect.Width \u003C= shownWidth )\n\t\t\t\tthrow new InvalidOperationException( $\u0022The user list button did not hide the list and expand chat. Visible: {window.View.MembersVisible}; list present: {Find( window.Root, \u0022chat-member-list\u0022 ) is not null}; width: {heading.Box.Rect.Width}; prior width: {shownWidth}; mouse: {window.MousePosition}; target: {Find( window.Root, \u0022chat-members-toggle\u0022 ).Box.Rect}.\u0022 );\n\t\t\tawait Click( window, Find( window.Root, \u0022chat-members-toggle\u0022 ), frame );\n\t\t\tif ( !window.View.MembersVisible || Find( window.Root, \u0022chat-member-list\u0022 ) is null )\n\t\t\t\tthrow new InvalidOperationException( \u0022The user list button did not show the list.\u0022 );\n\t\t\tif ( !settingsWereOpen ) await Click( window, Find( window.Root, \u0022chat-settings-toggle\u0022 ), frame );\n\t\t\tawait Click( window, Find( window.Root, \u0022chat-popups\u0022 ), frame );\n\t\t\tif ( GlobalChatWindow.MentionPopups == popups || GlobalChatWindow.MentionSounds != sounds ) throw new InvalidOperationException( \u0022Popup checkbox did not change independently.\u0022 );\n\t\t\tawait Click( window, Find( window.Root, \u0022chat-sounds\u0022 ), frame );\n\t\t\tif ( GlobalChatWindow.MentionSounds == sounds ) throw new InvalidOperationException( \u0022Sound checkbox did not change.\u0022 );\n\t\t\tinput.Text = \u0022\u0022;\n\t\t\twindow.Session.Draft = \u0022\u0022;\n\t\t\tawait Click( window, input, frame );\n\t\t\tChatNativeInputValidation.TypeUnicode( \u0022Local input check\u0022 );\n\t\t\tEngineChatBridge.Invoke( frame, window );\n\t\t\tawait Task.Delay( 100 );\n\t\t\tif ( input.Text != \u0022Local input check\u0022 || window.Session.Draft != input.Text ) throw new InvalidOperationException( \u0022Typing did not reach the Razor draft binding.\u0022 );\n\t\t\tvar me = new Friend( Game.SteamId );\n\t\t\tawait Click( window, Find( window.Root, $\u0022chat-member-{me.Id}\u0022 ), frame );\n\t\t\tvar menu = Find( window.Root, \u0022chat-user-actions\u0022 );\n\t\t\tif ( menu is null || Find( menu, \u0022chat-user-steam\u0022 ) is null || Find( menu, \u0022chat-user-website\u0022 ) is null )\n\t\t\t\tthrow new InvalidOperationException( \u0022The user menu does not contain both profile actions.\u0022 );\n\t\t\tvar bounds = window.View.Box.Rect;\n\t\t\tif ( menu.Box.Rect.Left \u003C bounds.Left || menu.Box.Rect.Right \u003E bounds.Right || menu.Box.Rect.Top \u003C bounds.Top || menu.Box.Rect.Bottom \u003E bounds.Bottom )\n\t\t\t\tthrow new InvalidOperationException( \u0022The user menu extends outside chat.\u0022 );\n\t\t\tawait Click( window, Find( menu, \u0022chat-user-mention\u0022 ), frame );\n\t\t\tif ( input.Text != $\u0022Local input check @{me.Name} \u0022 || window.Session.Draft != input.Text || Find( window.Root, \u0022chat-user-actions\u0022 ) is not null )\n\t\t\t\tthrow new InvalidOperationException( \u0022The mention action did not update the draft and close the menu.\u0022 );\n\t\t\tawait Click( window, Find( window.Root, $\u0022chat-member-{me.Id}\u0022 ), frame );\n\t\t\tawait Click( window, Find( window.Root, \u0022chat-user-dismiss\u0022 ), frame );\n\t\t\tif ( Find( window.Root, \u0022chat-user-actions\u0022 ) is not null ) throw new InvalidOperationException( \u0022Clicking outside did not close the user menu.\u0022 );\n\t\t\tvar message = new GlobalChatSession.ChatLine( me, \u0022Local user action check\u0022, 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, $\u0022chat-name-{message.Id}\u0022 ), frame );\n\t\t\tif ( Find( window.Root, \u0022chat-user-actions\u0022 ) is null ) throw new InvalidOperationException( \u0022Clicking a message name did not open the menu.\u0022 );\n\t\t\twindow.View.DismissUserActions();\n\t\t\tinput.Text = new string( \u0027x\u0027, 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, $\u0022chat-name-{message.Id}\u0022 ), frame );\n\t\t\tvar mentionButton = Find( window.Root, \u0022chat-user-mention\u0022 )\n\t\t\t\t?? throw new InvalidOperationException( \u0022The message name did not reopen the user menu.\u0022 );\n\t\t\tif ( !mentionButton.HasClass( \u0022disabled\u0022 ) || input.Text.Length != 400 )\n\t\t\t\tthrow new InvalidOperationException( \u0022The full draft does not disable mention insertion.\u0022 );\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, \u0022chat-user-actions\u0022 ) is not null ) throw new InvalidOperationException( \u0022Escape did not close the user menu.\u0022 );\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( \u0022GlobalChat.Draft\u0022, draft );\n\t\t\tEngineChatBridge.Invoke( frame, window );\n\t\t\tif ( !settingsWereOpen \u0026\u0026 Find( window.Root, \u0022chat-popups\u0022 ) is not null ) await Click( window, Find( window.Root, \u0022chat-settings-toggle\u0022 ), 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( \u0022editor_global_chat\u0022, \u0022Editor global chat connection and local validation. Never sends chat messages.\u0022 )]\npublic static class GlobalChatMcpTools\n{\n\t/// \u003Csummary\u003EChecks a real Windows click, focus, Unicode typing, Backspace, Home, Ctrl\u002BA, Ctrl\u002BV 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.\u003C/summary\u003E\n\t[McpTool( \u0022editor_global_chat_validate_keyboard\u0022 )]\n\tpublic static async Task\u003Cobject\u003E 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/// \u003Csummary\u003EOpens 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.\u003C/summary\u003E\n\t[McpTool( \u0022editor_global_chat_dock\u0022 )]\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( \u0022Global Chat\u0022, DockArea.Bottom );\n\t\t((GlobalChat.GlobalChatDock)dock.Widget).Synchronize();\n\t\treturn Status();\n\t}\n\n\t/// \u003Csummary\u003EChecks 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.\u003C/summary\u003E\n\t[McpTool( \u0022editor_global_chat_validate_docking\u0022 )]\n\tpublic static async Task\u003Cobject\u003E 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( \u0022Wait for global chat to join before checking docking.\u0022 );\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 \u002B (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( \u0022The chat surface did not stay attached when floating.\u0022 );\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( \u0022The chat surface did not stay attached when docking.\u0022 );\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( \u0022Docking changed the chat session or draft.\u0022 );\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( \u0022The editor could not restore its dock layout.\u0022 );\n\t\t\t((GlobalChat.GlobalChatDock)GlobalChat.GlobalChatDock.Dock.Widget).Synchronize();\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003EChecks 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.\u003C/summary\u003E\n\t[McpTool( \u0022editor_global_chat_validate_view\u0022 )]\n\tpublic static async Task\u003Cobject\u003E ValidateView()\n\t{\n\t\tGlobalChat.GlobalChatWindow.Open();\n\t\treturn await GlobalChat.ChatViewValidation.Run( GlobalChat.GlobalChatWindow.Current );\n\t}\n\t/// \u003Csummary\u003ECaptures 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.\u003C/summary\u003E\n\t[McpTool( \u0022editor_global_chat_capture\u0022 )]\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( \u0022Open Global Chat before capturing it.\u0022 );\n\t\tif ( width != 0 || height != 0 )\n\t\t{\n\t\t\tif ( width \u003C 540 || width \u003E 1800 || height \u003C 420 || height \u003E 1400 ) throw new ArgumentOutOfRangeException( nameof( width ), \u0022Use a width of 540\u20131800 and a height of 420\u20131400, or leave both zero.\u0022 );\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, \u0022Local layout preview. This message is not sent to global chat.\u0022, 0, Guid.NewGuid() ),\n\t\t\t\t\tnew( me, \u0022A second sample checks the shared message style and text wrapping when the editor window is narrow. The avatar, name, and message must stay aligned.\u0022, 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/// \u003Csummary\u003ECreates 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.\u003C/summary\u003E\n\t[McpTool( \u0022editor_global_chat_validate_private_party\u0022 )]\n\tpublic static async Task\u003Cobject\u003E 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/// \u003Csummary\u003ECaptures chat with the inline user action menu. Use editor_global_chat_preview_profile first. Does not open a browser or send a message.\u003C/summary\u003E\n\t[McpTool( \u0022editor_global_chat_capture_profile\u0022 )]\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( \u0022Open Global Chat before capturing a profile.\u0022 );\n\t\tusing var bitmap = GlobalChat.ChatWindowCapture.Capture( window, window.Session );\n\t\treturn McpResult.Image( bitmap );\n\t}\n\n\t/// \u003Csummary\u003EOpens 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.\u003C/summary\u003E\n\t[McpTool( \u0022editor_global_chat_preview_profile\u0022 )]\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/// \u003Csummary\u003EOpens the editor global chat window and connects to the normal menu channel. Does not send a message. Call editor_global_chat_status after connection.\u003C/summary\u003E\n\t[McpTool( \u0022editor_global_chat_open\u0022 )]\n\tpublic static ChatStatus Open()\n\t{\n\t\tGlobalChat.GlobalChatWindow.Open();\n\t\treturn Status();\n\t}\n\n\t/// \u003Csummary\u003EReturns 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.\u003C/summary\u003E\n\t[McpTool.ReadOnly( \u0022editor_global_chat_status\u0022 )]\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 \u0026\u0026 !dock.IsClosed \u0026\u0026 !dock.IsFloating,\n\t\t\tFloating = dock is not null \u0026\u0026 !dock.IsClosed \u0026\u0026 dock.IsFloating,\n\t\t\tSurfaceAttached = dock?.Widget is GlobalChat.GlobalChatDock host \u0026\u0026 host.SurfaceAttached,\n\t\t\tJoined = channel?.IsJoined ?? false, ChannelId = channel?.Id.ToString(),\n\t\t\tChannel = channel?.GetData( \u0022channel\u0022 ), LobbyType = channel?.GetData( \u0022lobby_type\u0022 ),\n\t\t\tDev = channel?.GetData( \u0022dev\u0022 ), Api = channel?.GetData( \u0022api\u0022 ), Protocol = channel?.GetData( \u0022protocol\u0022 ),\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/// \u003Csummary\u003EChanges saved editor mention preferences. Does not affect compiler notifications or send chat. Call editor_global_chat_status to read back.\u003C/summary\u003E\n\t[McpTool( \u0022editor_global_chat_alert_settings\u0022 )]\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/// \u003Csummary\u003EExercises 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.\u003C/summary\u003E\n\t[McpTool( \u0022editor_global_chat_preview_mention\u0022 )]\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 ), $\u0022@{me.Name} Mention alert preview.\u0022, 0, Guid.NewGuid() ) );\n\t\treturn Status();\n\t}\n\n\t/// \u003Csummary\u003ECloses the chat window and leaves its channel. Does not send a message. Draft and alert preferences stay saved.\u003C/summary\u003E\n\t[McpTool( \u0022editor_global_chat_close\u0022 )]\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/// \u003Csummary\u003ENumber of sound requests accepted by Windows. This does not verify speaker output.\u003C/summary\u003E\n\tpublic int SoundsPlayed { get; set; }\n\t/// \u003Csummary\u003EFailure from the last sound request, or null when Windows accepted playback. Acceptance does not verify speaker output.\u003C/summary\u003E\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( \u0022Hello @Alice\u0022, \u0022Alice\u0022, true )]\n\t[DataRow( \u0022Hello @Alice.\u0022, \u0022Alice\u0022, true )]\n\t[DataRow( \u0022@Alice. Next sentence\u0022, \u0022Alice\u0022, true )]\n\t[DataRow( \u0022@Alice.com\u0022, \u0022Alice\u0022, false )]\n\t[DataRow( \u0022@aLiCe, hello\u0022, \u0022Alice\u0022, true )]\n\t[DataRow( \u0022Hello @Alice Smith!\u0022, \u0022Alice Smith\u0022, true )]\n\t[DataRow( \u0022@AliceOther\u0022, \u0022Alice\u0022, false )]\n\t[DataRow( \u0022mail@Alice\u0022, \u0022Alice\u0022, false )]\n\t[DataRow( \u0022@@Alice\u0022, \u0022Alice\u0022, false )]\n\t[DataRow( \u0022No mention\u0022, \u0022Alice\u0022, false )]\n\t[DataRow( \u0022@42 hello\u0022, \u0022Alice\u0022, true )]\n\t[DataRow( \u0022\u003C@42\u003E hello\u0022, \u0022Alice\u0022, true )]\n\t[DataRow( \u0022@420 hello\u0022, \u0022Alice\u0022, 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, \u0022Alice\u0022, \u0022@Alice\u0022, false, false, true ) );\n\t\tAssert.IsFalse( gate.ShouldNotify( Guid.NewGuid(), 42, 42, \u0022Alice\u0022, \u0022@Alice\u0022, true, false, true ) );\n\t\tAssert.IsFalse( gate.ShouldNotify( Guid.NewGuid(), 7, 42, \u0022Alice\u0022, \u0022@Alice\u0022, true, true, true ) );\n\t\tvar disabled = Guid.NewGuid();\n\t\tAssert.IsFalse( gate.ShouldNotify( disabled, 7, 42, \u0022Alice\u0022, \u0022@Alice\u0022, true, false, false ) );\n\t\tAssert.IsFalse( gate.ShouldNotify( disabled, 7, 42, \u0022Alice\u0022, \u0022@Alice\u0022, true, false, true ) );\n\t\tAssert.IsTrue( gate.ShouldNotify( Guid.NewGuid(), 7, 42, \u0022Alice\u0022, \u0022@Alice\u0022, 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, \u0022Alice\u0022, \u0022@Alice\u0022, true, false, true ) );\n\t\tAssert.IsFalse( gate.ShouldNotify( id, 7, 42, \u0022Alice\u0022, \u0022@Alice\u0022, true, false, true ) );\n\t\tAssert.IsTrue( gate.ShouldNotify( id, 8, 42, \u0022Alice\u0022, \u0022@Alice\u0022, 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/// \u003Csummary\u003EPlays mention alerts outside the game audio mixer, including when the editor is in the background.\u003C/summary\u003E\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 = \u0022core/sounds/editor/published.wav\u0022;\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 = \u0022Editor chat sound requires Windows.\u0022;\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 = $\u0022Editor chat sound file is missing: {SoundFile}\u0022;\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 = \u0022Windows could not start the editor chat sound. Check the audio output device.\u0022;\n\t\treturn false;\n\t}\n\n\t[DllImport( \u0022winmm.dll\u0022, EntryPoint = \u0022PlaySoundW\u0022, CharSet = CharSet.Unicode, ExactSpelling = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tstatic extern bool PlaySound( string file, IntPtr module, uint flags );\n}\n"}]}