Add pre-testing version of the webinterface
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ChatTwo.Http.MessageProtocol;
|
||||
|
||||
public struct MessageResponse()
|
||||
{
|
||||
[JsonProperty("timestamp")] public string Timestamp = "";
|
||||
[JsonProperty("messageHTML")] public string Message = "";
|
||||
}
|
||||
|
||||
public class WebSocketNewMessage(MessageResponse[] messages) : BaseOutboundMessage(MessageName)
|
||||
{
|
||||
private const string MessageName = "chat-message";
|
||||
|
||||
[JsonProperty("messages")] public MessageResponse[] Messages { get; set; } = messages;
|
||||
}
|
||||
|
||||
public class BaseOutboundMessage(string messageType)
|
||||
{
|
||||
[JsonProperty("messageType")] public string MessageType { get; set; } = messageType;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Globalization;
|
||||
using ChatTwo.Code;
|
||||
using ChatTwo.Http.MessageProtocol;
|
||||
using ChatTwo.Util;
|
||||
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
||||
using Ganss.Xss;
|
||||
|
||||
namespace ChatTwo.Http;
|
||||
|
||||
public class Processing
|
||||
{
|
||||
private readonly Plugin Plugin;
|
||||
private readonly HtmlSanitizer Sanitizer = new();
|
||||
|
||||
public Processing(Plugin plugin)
|
||||
{
|
||||
Plugin = plugin;
|
||||
}
|
||||
|
||||
public string ReadChannelName()
|
||||
{
|
||||
var messages = new List<string>();
|
||||
foreach (var chunk in Plugin.ChatLogWindow.ReadChannelName(Plugin.ChatLogWindow.CurrentTab))
|
||||
messages.Add(ProcessChunk(chunk, noColor: true));
|
||||
|
||||
return string.Join("", messages);
|
||||
}
|
||||
|
||||
internal async Task<List<MessageResponse>> ReadMessageList()
|
||||
{
|
||||
var tabMessages = await Plugin.ChatLogWindow.CurrentTab!.Messages.GetCopy();
|
||||
return tabMessages.Select(ReadMessageContent).ToList();
|
||||
}
|
||||
|
||||
internal MessageResponse ReadMessageContent(Message message)
|
||||
{
|
||||
var response = new MessageResponse
|
||||
{
|
||||
Timestamp = message.Date.ToLocalTime().ToString("t", !Plugin.Config.Use24HourClock ? null : CultureInfo.CreateSpecificCulture("es-ES"))
|
||||
};
|
||||
|
||||
var content = "";
|
||||
if (message.Sender.Count > 0)
|
||||
content = message.Sender.Aggregate(content, (current, chunk) => current + ProcessChunk(chunk));
|
||||
|
||||
content = message.Content.Aggregate(content, (current, chunk) => current + ProcessChunk(chunk));
|
||||
response.Message = content;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private string ProcessChunk(Chunk chunk, bool noColor = false)
|
||||
{
|
||||
if (chunk is IconChunk { } icon)
|
||||
{
|
||||
return IconUtil.GfdFileView.TryGetEntry((uint) icon.Icon, out _)
|
||||
? $"<span class=\"gfd-icon gfd-icon-hq-{(uint)icon.Icon}\" style=\"zoom:calc(16 * 4 / 3 / 40 * 1.4)\"></span>"
|
||||
: "";
|
||||
}
|
||||
|
||||
if (chunk is TextChunk { } text)
|
||||
{
|
||||
if (chunk.Link is EmotePayload emotePayload && Plugin.Config.ShowEmotes)
|
||||
{
|
||||
var image = EmoteCache.GetEmote(emotePayload.Code);
|
||||
if (image is { Failed: false })
|
||||
return $"<span style\"height: 1em\"><img src=\"/emote/{emotePayload.Code}\"></span>";
|
||||
}
|
||||
|
||||
var colour = text.Foreground;
|
||||
if (colour == null && text.FallbackColour != null)
|
||||
{
|
||||
var type = text.FallbackColour.Value;
|
||||
colour = Plugin.Config.ChatColours.TryGetValue(type, out var col) ? col : type.DefaultColor();
|
||||
}
|
||||
|
||||
var color = ColourUtil.RgbaToComponents(colour ?? 0);
|
||||
|
||||
var userContent = text.Content ?? "";
|
||||
if (Plugin.ChatLogWindow.ScreenshotMode)
|
||||
{
|
||||
if (chunk.Link is PlayerPayload playerPayload)
|
||||
userContent = Plugin.ChatLogWindow.HidePlayerInString(userContent, playerPayload.PlayerName, playerPayload.World.RowId);
|
||||
else if (Plugin.ClientState.LocalPlayer is { } player)
|
||||
userContent = Plugin.ChatLogWindow.HidePlayerInString(userContent, player.Name.TextValue, player.HomeWorld.Id);
|
||||
}
|
||||
|
||||
userContent = Sanitizer.Sanitize(userContent);
|
||||
return noColor
|
||||
? userContent
|
||||
: $"<span style=\"color:rgba({color.r}, {color.g}, {color.b}, {color.a})\">{userContent}</span>";
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using Lumina.Data.Files;
|
||||
using WatsonWebserver.Core;
|
||||
|
||||
using HttpMethod = WatsonWebserver.Core.HttpMethod;
|
||||
|
||||
namespace ChatTwo.Http;
|
||||
|
||||
public class RouteController
|
||||
{
|
||||
private readonly Plugin Plugin;
|
||||
private readonly ServerCore Core;
|
||||
|
||||
private readonly string AuthTemplate;
|
||||
private readonly string ChatBoxTemplate;
|
||||
|
||||
public RouteController(Plugin plugin, ServerCore core)
|
||||
{
|
||||
Plugin = plugin;
|
||||
Core = core;
|
||||
|
||||
AuthTemplate = File.ReadAllText(Path.Combine(Core.StaticDir, "templates", "auth.html"));
|
||||
ChatBoxTemplate = File.ReadAllText(Path.Combine(Core.StaticDir, "templates", "start.html"));
|
||||
|
||||
// Pre Auth
|
||||
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/", AuthRoute, ExceptionRoute);
|
||||
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.POST, "/auth", AuthenticateClient, ExceptionRoute);
|
||||
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/gfdata.gfd", GetGfdData, ExceptionRoute);
|
||||
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/fonticon_ps5.tex", GetTexData, ExceptionRoute);
|
||||
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/FFXIV_Lodestone_SSF.ttf", GetLodestoneFont, ExceptionRoute);
|
||||
Core.HostContext.Routes.PreAuthentication.Content.Add("/static", true, ExceptionRoute);
|
||||
|
||||
// Post Auth
|
||||
Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/chat", ChatBoxRoute, ExceptionRoute);
|
||||
Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/send", ReceiveMessage, ExceptionRoute);
|
||||
Core.HostContext.Routes.PostAuthentication.Parameter.Add(HttpMethod.GET, "/emote/{name}", GetEmote, ExceptionRoute);
|
||||
}
|
||||
|
||||
private async Task ExceptionRoute(HttpContextBase ctx, Exception _)
|
||||
{
|
||||
ctx.Response.StatusCode = 500;
|
||||
await ctx.Response.Send("Internal Server Error, please try again");
|
||||
}
|
||||
|
||||
private async Task AuthRoute(HttpContextBase ctx)
|
||||
{
|
||||
await ctx.Response.Send(AuthTemplate);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
#region FileHandlerRoutes
|
||||
private async Task GetTexData(HttpContextBase ctx)
|
||||
{
|
||||
var data = Plugin.DataManager.GetFile<TexFile>("common/font/fonticon_ps5.tex")!.Data;
|
||||
await ctx.Response.Send(data);
|
||||
}
|
||||
|
||||
private async Task GetGfdData(HttpContextBase ctx)
|
||||
{
|
||||
var data = Plugin.DataManager.GetFile("common/font/gfdata.gfd")!.Data;
|
||||
await ctx.Response.Send(data);
|
||||
}
|
||||
|
||||
private async Task GetLodestoneFont(HttpContextBase ctx)
|
||||
{
|
||||
var data = Plugin.FontManager.GameSymFont;
|
||||
await ctx.Response.Send(data);
|
||||
}
|
||||
|
||||
private async Task GetEmote(HttpContextBase ctx)
|
||||
{
|
||||
var name = ctx.Request.Url.Parameters["name"] ?? "";
|
||||
if (name == "" || !EmoteCache.Exists(name))
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
await ctx.Response.Send("Malformed emote name.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var emote = EmoteCache.GetEmote(name);
|
||||
if (emote is not { IsLoaded: true })
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
await ctx.Response.Send("Emote not valid.");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Response.Headers.Add("Cache-Control", "max-age=86400");
|
||||
await ctx.Response.Send(emote.RawData);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region PreAuthRoutes
|
||||
private async Task AuthenticateClient(HttpContextBase ctx)
|
||||
{
|
||||
var receivedPassword = ctx.Request.DataAsString ?? "";
|
||||
if (!receivedPassword.StartsWith("authcode="))
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
await ctx.Response.Send("Authentication content invalid.");
|
||||
return;
|
||||
}
|
||||
|
||||
receivedPassword = receivedPassword[9..];
|
||||
if (receivedPassword != Plugin.Config.WebinterfacePassword)
|
||||
{
|
||||
ctx.Response.StatusCode = 401;
|
||||
await ctx.Response.Send("Authentication failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Response.Headers.Add("Set-Cookie", $"auth={Plugin.Config.WebinterfacePassword}");
|
||||
ctx.Response.Headers.Add("Location", "/chat");
|
||||
ctx.Response.StatusCode = 302;
|
||||
await ctx.Response.Send();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region PostAuthRoutes
|
||||
private async Task ChatBoxRoute(HttpContextBase ctx)
|
||||
{
|
||||
if (Plugin.ChatLogWindow.CurrentTab == null)
|
||||
{
|
||||
await ctx.Response.Send("No valid chat tab!");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Response.Send(ChatBoxTemplate);
|
||||
}
|
||||
|
||||
private async Task ReceiveMessage(HttpContextBase ctx)
|
||||
{
|
||||
var content = ctx.Request.DataAsString;
|
||||
if (content.Length is > 500 or < 2)
|
||||
{
|
||||
await ctx.Response.Send("Invalid length for a chat message received.");
|
||||
return;
|
||||
}
|
||||
|
||||
await Plugin.Framework.RunOnFrameworkThread(() =>
|
||||
{
|
||||
Plugin.ChatLogWindow.Chat = content;
|
||||
Plugin.ChatLogWindow.SendChatBox(Plugin.ChatLogWindow.CurrentTab);
|
||||
});
|
||||
|
||||
await ctx.Response.Send("Message was send to the channel.");
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using ChatTwo.Http.MessageProtocol;
|
||||
using EmbedIO;
|
||||
using WatsonWebserver.Core;
|
||||
using WatsonWebserver.Lite;
|
||||
using ExceptionEventArgs = WatsonWebserver.Core.ExceptionEventArgs;
|
||||
|
||||
namespace ChatTwo.Http;
|
||||
|
||||
public class ServerCore : IDisposable
|
||||
{
|
||||
private readonly Plugin Plugin;
|
||||
private readonly Processing Processing;
|
||||
private readonly RouteController RouteController;
|
||||
|
||||
internal readonly WebserverLite HostContext;
|
||||
private readonly WebSocketServer Websocket;
|
||||
private readonly WebServer Host;
|
||||
|
||||
internal readonly CancellationTokenSource TokenSource = new();
|
||||
internal readonly string StaticDir = Path.Combine(Plugin.Interface.AssemblyLocation.DirectoryName!, "Http");
|
||||
|
||||
public ServerCore(Plugin plugin)
|
||||
{
|
||||
Plugin = plugin;
|
||||
HostContext = new WebserverLite(new WebserverSettings("*", 9000), DefaultRoute);
|
||||
|
||||
Processing = new Processing(plugin);
|
||||
RouteController = new RouteController(plugin, this);
|
||||
|
||||
HostContext.Routes.PreAuthentication.Content.BaseDirectory = StaticDir;
|
||||
HostContext.Routes.AuthenticateRequest = CheckAuthenticationCookie;
|
||||
HostContext.Events.ExceptionEncountered += ExceptionEncountered;
|
||||
|
||||
// Settings
|
||||
HostContext.Settings.Debug.Requests = true;
|
||||
HostContext.Settings.Debug.Routing = true;
|
||||
HostContext.Settings.Debug.Responses = true;
|
||||
HostContext.Settings.Debug.AccessControl = true;
|
||||
HostContext.Events.Logger = logMessage => Plugin.Log.Information(logMessage);
|
||||
|
||||
|
||||
// Websocket
|
||||
Host = new WebServer(o => o
|
||||
.WithUrlPrefixes($"http://*:9001")
|
||||
.WithMode(HttpListenerMode.EmbedIO)
|
||||
);
|
||||
|
||||
Websocket = new WebSocketServer("/ws");
|
||||
Host.WithModule(Websocket);
|
||||
|
||||
Websocket.OnClientConnected += ClientConnected;
|
||||
}
|
||||
|
||||
#region WebsocketFunctions
|
||||
private void ClientConnected(object? sender, EventArgs args)
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
var messages = await WebserverUtil.FrameworkWrapper(Processing.ReadMessageList);
|
||||
Websocket.BroadcastMessage(new WebSocketNewMessage(messages.ToArray()));
|
||||
});
|
||||
}
|
||||
|
||||
internal void SendNewMessage(Message message)
|
||||
{
|
||||
try
|
||||
{
|
||||
Plugin.Framework.RunOnTick(() =>
|
||||
{
|
||||
Websocket.BroadcastMessage(new WebSocketNewMessage([Processing.ReadMessageContent(message)]));
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Plugin.Log.Error(ex, "Send message to websockets failed.");
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region GeneralHandlers
|
||||
private static void ExceptionEncountered(object? _, ExceptionEventArgs args)
|
||||
{
|
||||
Plugin.Log.Error(args.Exception, "Webserver threw an exception.");
|
||||
}
|
||||
|
||||
private async Task DefaultRoute(HttpContextBase ctx)
|
||||
{
|
||||
await ctx.Response.Send("Nothing to see here.");
|
||||
}
|
||||
#endregion
|
||||
|
||||
private async Task CheckAuthenticationCookie(HttpContextBase ctx)
|
||||
{
|
||||
var cookie = ctx.Request.Headers.Get("Cookie") ?? "";
|
||||
if (!cookie.StartsWith("auth=") || cookie[5..] != Plugin.Config.WebinterfacePassword)
|
||||
{
|
||||
ctx.Response.StatusCode = 401;
|
||||
await ctx.Response.Send("Your session auth code was invalid");
|
||||
}
|
||||
|
||||
// Do nothing to let auth pass
|
||||
}
|
||||
|
||||
public bool GetStats()
|
||||
{
|
||||
return HostContext.IsListening;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
try
|
||||
{
|
||||
HostContext.Start(TokenSource.Token);
|
||||
Host.Start(TokenSource.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Plugin.Log.Error(ex, "Startup failed with an error.");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
TokenSource.Cancel();
|
||||
|
||||
HostContext.Stop();
|
||||
HostContext.Dispose();
|
||||
|
||||
Websocket.Dispose();
|
||||
Host.Dispose();
|
||||
|
||||
RouteController.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace ChatTwo.Http;
|
||||
|
||||
public static class WebserverUtil
|
||||
{
|
||||
public static async Task<T> FrameworkWrapper<T>(Func<Task<T>> func)
|
||||
{
|
||||
return await Plugin.Framework.RunOnTick(func).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public class DisposableWrapper : IDisposable {
|
||||
private readonly Action Down;
|
||||
private bool Disposed;
|
||||
|
||||
public DisposableWrapper(Action down) {
|
||||
Down = down;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
if (Disposed) return;
|
||||
|
||||
Down();
|
||||
Disposed = true;
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class AsyncUtils {
|
||||
public static async Task<IDisposable> UseWaitAsync(this SemaphoreSlim semaphore, CancellationToken ct = default) {
|
||||
await semaphore.WaitAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return new WebserverUtil.DisposableWrapper(() => {
|
||||
semaphore.Release();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using ChatTwo.Http.MessageProtocol;
|
||||
using EmbedIO.WebSockets;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ChatTwo.Http;
|
||||
|
||||
public class WebSocketServer : WebSocketModule {
|
||||
private readonly SemaphoreSlim SendLock = new(1, 1);
|
||||
|
||||
public event EventHandler? OnClientConnected;
|
||||
|
||||
public WebSocketServer(string urlPath) : base(urlPath, true) {
|
||||
|
||||
}
|
||||
|
||||
protected override async Task OnMessageReceivedAsync(IWebSocketContext context, byte[] buffer, IWebSocketReceiveResult result)
|
||||
{
|
||||
// Unused method
|
||||
}
|
||||
|
||||
protected override Task OnClientConnectedAsync(IWebSocketContext context)
|
||||
{
|
||||
Plugin.Log.Information($"Client connected: {context.Id}");
|
||||
OnClientConnected?.Invoke(this, EventArgs.Empty);
|
||||
return base.OnClientConnectedAsync(context);
|
||||
}
|
||||
|
||||
protected override Task OnClientDisconnectedAsync(IWebSocketContext context)
|
||||
{
|
||||
Plugin.Log.Information($"Client disconnected: {context.Id}");
|
||||
return base.OnClientConnectedAsync(context);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing) {
|
||||
base.Dispose(disposing);
|
||||
|
||||
SendLock.Dispose();
|
||||
}
|
||||
|
||||
public void BroadcastMessage(BaseOutboundMessage message) {
|
||||
Task.Run(async () => {
|
||||
using (await SendLock.UseWaitAsync()) {
|
||||
var serializedData = JsonConvert.SerializeObject(message);
|
||||
await BroadcastAsync(serializedData);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,243 @@
|
||||
/* fonts */
|
||||
@font-face {
|
||||
font-family: Lodestone;
|
||||
src: url('files/FFXIV_Lodestone_SSF.ttf') format('truetype');
|
||||
unicode-range: U+E020-E0DB;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter var';
|
||||
font-weight: 100 900;
|
||||
font-style: oblique 0deg 10deg;
|
||||
src: url('static/Inter.var.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* variables */
|
||||
:root {
|
||||
--fg: white;
|
||||
--fg-faint: #a0a0a0;
|
||||
--fg-scrollbar: #404040;
|
||||
--bg: #101010;
|
||||
--bg-input: #202020;
|
||||
--bg-input-hover: #282828;
|
||||
--focus-color: #4060a0;
|
||||
|
||||
--gradient-clickable: linear-gradient(to bottom, #404040, var(--bg-input) 65%, var(--bg-input));
|
||||
--gradient-clickable-hover: linear-gradient(to bottom, #505050, var(--bg-input-hover) 65%, var(--bg-input-hover));
|
||||
|
||||
--timestamp-width: 70px;
|
||||
}
|
||||
|
||||
/* reset */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
color: var(--fg);
|
||||
font-family: Lodestone, 'Inter var', sans-serif;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* layout and global styles */
|
||||
body {
|
||||
padding: 50px;
|
||||
height: 100dvh;
|
||||
background-color: var(--bg-input-hover);
|
||||
}
|
||||
|
||||
body > main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--bg);
|
||||
border-radius: 20px;
|
||||
|
||||
box-shadow: 0 0 50px 0 rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* message list */
|
||||
section#messages {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 20px;
|
||||
line-height: 1.5;
|
||||
|
||||
.scroll-container {
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
scrollbar-color: var(--fg-scrollbar) var(--bg);
|
||||
|
||||
&.more-messages::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
left: 100px;
|
||||
width: calc(100% - 200px);
|
||||
height: 200px;
|
||||
background-image: radial-gradient(50% 20% at 50% 100%, #60a0ff40, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
|
||||
.timestamp {
|
||||
flex: 0 0 var(--timestamp-width);
|
||||
color: var(--fg-faint);
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#timestamp-width-probe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* input bar, channel selector, ... */
|
||||
section#input {
|
||||
flex-grow: 0;
|
||||
padding: 20px;
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
input, button, select {
|
||||
font-size: 1rem;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 20px;
|
||||
background-color: var(--bg-input);
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--focus-color);
|
||||
}
|
||||
}
|
||||
|
||||
button, select {
|
||||
padding: 5px 15px;
|
||||
border: 3px solid var(--bg-input);
|
||||
background-image: var(--gradient-clickable);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-input-hover);
|
||||
background-color: var(--bg-input-hover);
|
||||
background-image: var(--gradient-clickable-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.select-container, button {
|
||||
position: relative;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.select-container {
|
||||
flex-basis: 0;
|
||||
|
||||
&::before {
|
||||
/* "message-circle" icon from https://github.com/feathericons/feather, under MIT license */
|
||||
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>');
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
appearance: none;
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
padding-left: calc(20px + 1.5rem);
|
||||
|
||||
&::before {
|
||||
/* "send" icon from https://github.com/feathericons/feather, under MIT license */
|
||||
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>');
|
||||
}
|
||||
}
|
||||
|
||||
.select-container::before, button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
background-color: var(--fg);
|
||||
mask-size: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.select-container::before {
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
}
|
||||
|
||||
.input-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
|
||||
#chat-input {
|
||||
width: 100%;
|
||||
padding: 5px 20px;
|
||||
}
|
||||
|
||||
#channel-hint {
|
||||
position: absolute;
|
||||
top: -1.2em;
|
||||
left: 23px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 550;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*** mobile ***/
|
||||
@media ((max-width: 600px) and (orientation: portrait)) or (max-width: 400px) {
|
||||
body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body > main {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
section#input {
|
||||
button {
|
||||
max-width: 0;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
button::before {
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// websocket connection
|
||||
class WSConnection {
|
||||
constructor() {
|
||||
this.socket = new WebSocket('ws://192.168.2.106:9001/ws');
|
||||
this.socket.addEventListener('open', this.onWSOpen.bind(this));
|
||||
this.socket.addEventListener('close', this.onWSClose.bind(this));
|
||||
this.socket.addEventListener('message', this.onWSMessage.bind(this));
|
||||
}
|
||||
|
||||
onWSOpen() {
|
||||
// send request for initial data (channels, currently selected channel)
|
||||
}
|
||||
|
||||
onWSClose() {
|
||||
// open new websocket here? for mobile
|
||||
}
|
||||
|
||||
onWSMessage(event) {
|
||||
try {
|
||||
let eventData = JSON.parse(event.data);
|
||||
for (let message of eventData.messages)
|
||||
{
|
||||
addMessage(message);
|
||||
}
|
||||
} catch (error) {
|
||||
// TODO: error handling?
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
send(message) {
|
||||
this.socket.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
const ws = new WSConnection();
|
||||
|
||||
|
||||
// channel switcher
|
||||
function updateChannelHint(label) {
|
||||
document.getElementById('channel-hint').innerText = label;
|
||||
}
|
||||
document.getElementById('channel-select').addEventListener('change', (event) => {
|
||||
updateChannelHint(event.target.value);
|
||||
// TODO: send new channel to "backend"
|
||||
// ws.send(...);
|
||||
});
|
||||
|
||||
|
||||
// functions for handling the message list
|
||||
function scrollMessagesToBottom() {
|
||||
const messagesContainer = document.querySelector('#messages > .scroll-container');
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight - messagesContainer.offsetHeight;
|
||||
}
|
||||
|
||||
function messagesContainerIsScrolledToBottom() {
|
||||
const messagesContainer = document.querySelector('#messages > .scroll-container');
|
||||
return messagesContainer.scrollTop == messagesContainer.scrollHeight - messagesContainer.offsetHeight;
|
||||
}
|
||||
|
||||
function addMessage(messageData) {
|
||||
const scrolledToBottom = messagesContainerIsScrolledToBottom();
|
||||
|
||||
const liMessage = document.createElement('li');
|
||||
const spanTimestamp = document.createElement('span');
|
||||
spanTimestamp.classList.add('timestamp');
|
||||
const spanMessage = document.createElement('span');
|
||||
spanMessage.classList.add('message');
|
||||
|
||||
// suggestions, update with actual data model
|
||||
spanTimestamp.innerText = messageData.timestamp;
|
||||
spanMessage.innerHTML = messageData.messageHTML; // or build HTML in here
|
||||
|
||||
liMessage.appendChild(spanTimestamp);
|
||||
liMessage.appendChild(spanMessage);
|
||||
document.getElementById('messages-list').appendChild(liMessage);
|
||||
|
||||
if (scrolledToBottom) {
|
||||
scrollMessagesToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
document.getElementById('messages-list').innerHTML = '';
|
||||
}
|
||||
|
||||
//
|
||||
document.querySelector('#messages > .scroll-container').addEventListener('scroll', () => {
|
||||
const messagesContainer = document.querySelector('#messages > .scroll-container');
|
||||
if (!messagesContainerIsScrolledToBottom()) {
|
||||
messagesContainer.classList.add('more-messages');
|
||||
} else {
|
||||
messagesContainer.classList.remove('more-messages');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// handle message sending
|
||||
document.querySelector('#input > form').addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
const message = chatInput.value;
|
||||
|
||||
fetch('/send', {
|
||||
method: 'POST',
|
||||
body: message,
|
||||
headers: {
|
||||
'Content-type': 'application/txt; charset=UTF-8'
|
||||
}
|
||||
});
|
||||
|
||||
chatInput.value = '';
|
||||
});
|
||||
|
||||
|
||||
// calculate timestamp width
|
||||
// to ensure that all timestamps have the same width. some typefaces have the same width across
|
||||
// all number glyphs, others do not. the solution below is very rudimentary; at the very least,
|
||||
// delaying it to account for font loading might make sense. perhaps there's an even better way?
|
||||
window.setTimeout(() => {
|
||||
const widthProbe = document.getElementById('timestamp-width-probe');
|
||||
widthProbe.innerText = '88:88'; // assume 8 to be widest glyph
|
||||
document.body.style.setProperty('--timestamp-width', Math.ceil(widthProbe.clientWidth) + 'px');
|
||||
}, 100);
|
||||
|
||||
// From kizer
|
||||
async function AddGfdStylesheet(gfdPath, texPath) {
|
||||
const texPromise = LoadTexAsBlob(texPath);
|
||||
const gfdPromise = LoadGfd(gfdPath);
|
||||
const texUrl = URL.createObjectURL(await texPromise);
|
||||
const gfd = await gfdPromise;
|
||||
|
||||
let stylesheets = [];
|
||||
for (const entry of gfd) {
|
||||
if (entry.width * entry.height <= 0)
|
||||
continue;
|
||||
|
||||
if (entry.redirect !== 0) {
|
||||
stylesheets[entry.redirect][0].push(entry.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
stylesheets[entry.id] = [
|
||||
[entry.id],
|
||||
[
|
||||
`background-position: -${entry.left}px -${entry.top}px`,
|
||||
`background-image: url('${texUrl}')`,
|
||||
`width: ${entry.width}px`,
|
||||
`height: ${entry.height}px`,
|
||||
`margin-bottom: -20px`
|
||||
].join(";"),
|
||||
[
|
||||
`background-position: -${entry.left * 2}px -${entry.top * 2 + 341}px`,
|
||||
`background-image: url('${texUrl}')`,
|
||||
`width: ${entry.width * 2}px`,
|
||||
`height: ${entry.height * 2}px`,
|
||||
`margin-bottom: -40px`
|
||||
].join(";")
|
||||
];
|
||||
}
|
||||
|
||||
let stylesheet = ".gfd-icon::before { content: ''; display: inline-block; overflow: hidden; vertical-align: top; height:0; }";
|
||||
for (const entry of stylesheets) {
|
||||
if (!entry)
|
||||
continue;
|
||||
|
||||
stylesheet += `\n${entry[0].map(x => `.gfd-icon.gfd-icon-${x}::before`).join(', ')}{${entry[1]};}`;
|
||||
stylesheet += `\n${entry[0].map(x => `.gfd-icon.gfd-icon-hq-${x}::before`).join(', ')}{${entry[2]};}`;
|
||||
}
|
||||
|
||||
const styleNode = document.createElement("style");
|
||||
styleNode.type = "text/css";
|
||||
styleNode.appendChild(document.createTextNode(stylesheet));
|
||||
document.head.appendChild(styleNode);
|
||||
}
|
||||
|
||||
async function LoadTexAsBlob(path) {
|
||||
const tex = ParseTex(await (await fetch(path)).arrayBuffer());
|
||||
if (tex.format !== 0x1450) // B8G8R8A8
|
||||
throw "Not supported";
|
||||
|
||||
const dataArray = new Uint8ClampedArray(tex.buffer, tex.offsetToSurface[0], tex.width * tex.height * 4);
|
||||
for (let i = 0; i < dataArray.length; i += 4) {
|
||||
const t = dataArray[i];
|
||||
dataArray[i] = dataArray[i + 2];
|
||||
dataArray[i + 2] = t;
|
||||
}
|
||||
const imageData = new ImageData(dataArray, tex.width, tex.height);
|
||||
const bitmap = await createImageBitmap(imageData);
|
||||
|
||||
const canvas = new OffscreenCanvas(tex.width, tex.height);
|
||||
canvas.getContext('bitmaprenderer').transferFromImageBitmap(bitmap);
|
||||
return await canvas.convertToBlob();
|
||||
}
|
||||
|
||||
async function LoadGfd(path) {
|
||||
const buffer = new DataView(await (await fetch(path)).arrayBuffer());
|
||||
const count = buffer.getInt32(8, true);
|
||||
const entries = new Array(count);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const offset = 0x10 + (i * 0x10);
|
||||
entries[i] = {
|
||||
id: buffer.getInt16(offset, true),
|
||||
left: buffer.getInt16(offset + 2, true),
|
||||
top: buffer.getInt16(offset + 4, true),
|
||||
width: buffer.getInt16(offset + 6, true),
|
||||
height: buffer.getInt16(offset + 8, true),
|
||||
unk0A: buffer.getInt16(offset + 10, true),
|
||||
redirect: buffer.getInt16(offset + 12, true),
|
||||
unk0E: buffer.getInt16(offset + 14, true),
|
||||
};
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function ParseTex(arrayBuffer) {
|
||||
const buffer = new DataView(arrayBuffer);
|
||||
const type = buffer.getInt32(0, true);
|
||||
const format = buffer.getInt32(4, true);
|
||||
const width = buffer.getInt16(8, true);
|
||||
const height = buffer.getInt16(10, true);
|
||||
const depth = buffer.getInt16(12, true);
|
||||
const mipsAndFlag = buffer.getInt8(14, true);
|
||||
const arraySize = buffer.getInt8(15, true);
|
||||
const lodOffsets = [buffer.getInt32(16, true), buffer.getInt32(20, true), buffer.getInt32(24, true)];
|
||||
const offsetToSurface = [buffer.getInt32(28, true), buffer.getInt32(32, true), buffer.getInt32(36, true), buffer.getInt32(40, true), buffer.getInt32(44, true), buffer.getInt32(48, true), buffer.getInt32(52, true), buffer.getInt32(56, true), buffer.getInt32(60, true), buffer.getInt32(64, true), buffer.getInt32(68, true), buffer.getInt32(72, true), buffer.getInt32(76, true)];
|
||||
return {
|
||||
buffer: arrayBuffer,
|
||||
type,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
depth,
|
||||
mipsAndFlag,
|
||||
arraySize,
|
||||
lodOffsets,
|
||||
offsetToSurface,
|
||||
};
|
||||
}
|
||||
|
||||
AddGfdStylesheet("/files/gfdata.gfd", "/files/fonticon_ps5.tex");
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Authentication</title>
|
||||
|
||||
<link rel="stylesheet" href="/static/start.css">
|
||||
</head>
|
||||
<body>
|
||||
<div style="text-align: center;">
|
||||
<h3 style="color: #fff">Authcode</h3>
|
||||
<form action="/auth" method="POST">
|
||||
<input style="color: #fff; background: #121212" type="password" id="authcode" name="authcode">
|
||||
<input style="color: #fff; background: #121212" type="submit" value="Submit">
|
||||
</form>
|
||||
|
||||
<img src="http://192.168.2.106:9000/emote/Sure">
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Reference in New Issue
Block a user