diff --git a/XIVChat Desktop/App.xaml b/XIVChat Desktop/App.xaml
index ceab6dd..bffbd17 100644
--- a/XIVChat Desktop/App.xaml
+++ b/XIVChat Desktop/App.xaml
@@ -2,8 +2,19 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:XIVChat_Desktop"
- StartupUri="MainWindow.xaml">
+ xmlns:ui="http://schemas.modernwpf.com/2019"
+ Startup="Application_Startup">
-
+
+
+
+
+
+
+
+
+
+
+
-
+
\ No newline at end of file
diff --git a/XIVChat Desktop/App.xaml.cs b/XIVChat Desktop/App.xaml.cs
index e1eeccd..6458f98 100644
--- a/XIVChat Desktop/App.xaml.cs
+++ b/XIVChat Desktop/App.xaml.cs
@@ -1,8 +1,5 @@
using System;
-using System.Collections.Generic;
-using System.Configuration;
-using System.Data;
-using System.Linq;
+using System.ComponentModel;
using System.Threading.Tasks;
using System.Windows;
@@ -10,6 +7,74 @@ namespace XIVChat_Desktop {
///
/// Interaction logic for App.xaml
///
- public partial class App : Application {
+ public partial class App : INotifyPropertyChanged {
+ public MainWindow Window { get; private set; } = null!;
+ public Configuration Config { get; private set; } = null!;
+
+ public string? LastHost { get; set; }
+
+ private Connection? connection;
+
+ public Connection? Connection {
+ get => this.connection;
+ set {
+ this.connection = value;
+ this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.Connection)));
+ this.ConnectionStatusChanged();
+ }
+ }
+
+ public bool Connected => this.Connection != null;
+ public bool Disconnected => this.Connection == null;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private void Application_Startup(object sender, StartupEventArgs e) {
+ try {
+ this.Config = Configuration.Load() ?? new Configuration();
+ } catch (Exception) {
+ this.Config = new Configuration();
+ }
+
+ try {
+ this.Config.Save();
+ } catch (Exception) {
+ // TODO
+ }
+
+ var wnd = new MainWindow();
+ this.Window = wnd;
+
+ // I guess this gets initialised where you call it the first time, so initialise it on the UI thread
+ this.Dispatcher.Invoke(() => { });
+
+ wnd.Show();
+
+ // initialise a config window to apply all our settings
+ _ = new ConfigWindow(wnd, this.Config);
+ }
+
+ private void ConnectionStatusChanged() {
+ this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.Connected)));
+ this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.Disconnected)));
+ }
+
+ public void Connect(string host, ushort port) {
+ if (this.Connected) {
+ return;
+ }
+
+ this.Connection = new Connection(this, host, port);
+ Task.Run(this.Connection.Connect);
+ }
+
+ public void Disconnect() {
+ if (!this.Connected) {
+ return;
+ }
+
+ this.Connection?.Disconnect();
+ this.Connection = null;
+ }
}
}
diff --git a/XIVChat Desktop/ConfigWindow.xaml b/XIVChat Desktop/ConfigWindow.xaml
new file mode 100644
index 0000000..231f5be
--- /dev/null
+++ b/XIVChat Desktop/ConfigWindow.xaml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/XIVChat Desktop/ConfigWindow.xaml.cs b/XIVChat Desktop/ConfigWindow.xaml.cs
new file mode 100644
index 0000000..9ec34f0
--- /dev/null
+++ b/XIVChat Desktop/ConfigWindow.xaml.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Linq;
+using System.Windows;
+using System.Windows.Input;
+
+namespace XIVChat_Desktop {
+ ///
+ /// Interaction logic for ConfigWindow.xaml
+ ///
+ public partial class ConfigWindow {
+ public Configuration Config { get; private set; }
+
+ public ConfigWindow(Window owner, Configuration config) {
+ this.Owner = owner;
+ this.Config = config;
+
+ this.InitializeComponent();
+ this.DataContext = this;
+ }
+
+ private void AlwaysOnTop_Checked(object? sender, RoutedEventArgs e) {
+ this.SetAlwaysOnTop(true);
+ }
+
+ private void AlwaysOnTop_Unchecked(object? sender, RoutedEventArgs e) {
+ this.SetAlwaysOnTop(false);
+ }
+
+ private void SetAlwaysOnTop(bool onTop) {
+ this.Owner.Topmost = onTop;
+ this.Config.AlwaysOnTop = onTop;
+ }
+
+ private void Save_Click(object? sender, RoutedEventArgs e) {
+ this.Config.Save();
+ }
+
+ private void SavedServers_ItemDoubleClick(SavedServer? server) {
+ new ManageServer(this, server).ShowDialog();
+ }
+
+ private void ConfigWindow_OnContentRendered(object? sender, EventArgs e) {
+ this.InvalidateVisual();
+ }
+
+ private void NumericInputFilter(object sender, TextCompositionEventArgs e) {
+ var allDigits = e.Text.All(c => char.IsDigit(c));
+ e.Handled = !allDigits;
+ }
+ }
+}
diff --git a/XIVChat Desktop/Configuration.cs b/XIVChat Desktop/Configuration.cs
new file mode 100644
index 0000000..74362ce
--- /dev/null
+++ b/XIVChat Desktop/Configuration.cs
@@ -0,0 +1,192 @@
+using Newtonsoft.Json;
+using Sodium;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.IO;
+using System.Linq;
+using XIVChatCommon;
+
+namespace XIVChat_Desktop {
+ [JsonObject]
+ public class Configuration : INotifyPropertyChanged {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public KeyPair KeyPair { get; set; } = PublicKeyBox.GenerateKeyPair();
+
+ public ObservableCollection Servers { get; set; } = new ObservableCollection();
+ public HashSet TrustedKeys { get; set; } = new HashSet();
+
+ public ObservableCollection Tabs { get; set; } = Tab.Defaults();
+
+ public bool AlwaysOnTop { get; set; }
+
+ private double fontSize = 14d;
+
+ public double FontSize {
+ get => this.fontSize;
+ set {
+ this.fontSize = value;
+ this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.FontSize)));
+ }
+ }
+
+ public ushort BacklogMessages { get; set; } = 500;
+
+ #region io
+
+ private static string FilePath() => Path.Join(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "XIVChat for Windows",
+ "config.json"
+ );
+
+ public static Configuration? Load() {
+ var path = FilePath();
+ if (!File.Exists(path)) {
+ return null;
+ }
+
+ using var reader = File.OpenText(path);
+ using var json = new JsonTextReader(reader);
+
+ var serializer = new JsonSerializer {
+ ObjectCreationHandling = ObjectCreationHandling.Replace,
+ };
+ return serializer.Deserialize(json);
+ }
+
+ public void Save() {
+ var path = FilePath();
+ if (!File.Exists(path)) {
+ var dir = Path.GetDirectoryName(path);
+ Directory.CreateDirectory(dir);
+ }
+
+ using var file = File.CreateText(path);
+ using var json = new JsonTextWriter(file);
+
+ var serialiser = new JsonSerializer();
+ serialiser.Serialize(json, this);
+ }
+
+ #endregion
+ }
+
+ [JsonObject]
+ public class SavedServer {
+ public string Name { get; set; }
+ public string Host { get; set; }
+ public ushort Port { get; set; }
+
+ public SavedServer(string name, string host, ushort port) {
+ this.Name = name;
+ this.Host = host;
+ this.Port = port;
+ }
+ }
+
+ [JsonObject]
+ public class TrustedKey {
+ public string Name { get; set; }
+ public byte[] Key { get; set; }
+
+ public TrustedKey(string name, byte[] key) {
+ this.Name = name;
+ this.Key = key;
+ }
+ }
+
+ [JsonObject]
+ public class Tab : INotifyPropertyChanged {
+ private string name;
+
+ public Tab(string name) {
+ this.name = name;
+ }
+
+ public string Name {
+ get => this.name;
+ set {
+ this.name = value;
+ this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.Name)));
+ }
+ }
+
+ public Filter Filter { get; set; } = new Filter();
+
+ [JsonIgnore]
+ public ObservableCollection Messages { get; } = new ObservableCollection();
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public void RepopulateMessages(ConcurrentStack mainMessages) {
+ this.Messages.Clear();
+
+ foreach (var message in mainMessages.Where(msg => this.Filter.Allowed(msg)).Reverse()) {
+ this.Messages.Add(message);
+ }
+
+ this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.Messages)));
+ }
+
+ public void AddMessage(ServerMessage message) {
+ if (message.Channel != 0 && !this.Filter.Allowed(message)) {
+ return;
+ }
+
+ this.Messages.Add(message);
+
+ this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.Messages)));
+ }
+
+ public void ClearMessages() {
+ this.Messages.Clear();
+ this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.Messages)));
+ }
+
+ public static Filter GeneralFilter() {
+ var generalFilters = FilterCategory.Chat.Types()
+ .Concat(FilterCategory.Announcements.Types())
+ .ToHashSet();
+ generalFilters.Remove(FilterType.OwnBattleSystem);
+ generalFilters.Remove(FilterType.OthersBattleSystem);
+ generalFilters.Remove(FilterType.NpcDialogue);
+ generalFilters.Remove(FilterType.OthersFishing);
+ return new Filter {
+ Types = generalFilters,
+ };
+ }
+
+ public static ObservableCollection Defaults() {
+ var battleFilters = FilterCategory.Battle.Types()
+ .Append(FilterType.OwnBattleSystem)
+ .Append(FilterType.OthersBattleSystem)
+ .ToHashSet();
+
+ return new ObservableCollection {
+ new Tab("General") {
+ Filter = GeneralFilter(),
+ },
+ new Tab("Battle") {
+ Filter = new Filter {
+ Types = battleFilters,
+ },
+ },
+ };
+ }
+ }
+
+ [JsonObject]
+ public class Filter {
+ public HashSet Types { get; set; } = new HashSet();
+
+ public bool Allowed(ServerMessage message) {
+ return this.Types
+ .SelectMany(type => type.Types())
+ .Contains(new ChatCode((ushort)message.Channel).Type);
+ }
+ }
+}
diff --git a/XIVChat Desktop/ConnectDialog.xaml b/XIVChat Desktop/ConnectDialog.xaml
new file mode 100644
index 0000000..dc4c049
--- /dev/null
+++ b/XIVChat Desktop/ConnectDialog.xaml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/XIVChat Desktop/ConnectDialog.xaml.cs b/XIVChat Desktop/ConnectDialog.xaml.cs
new file mode 100644
index 0000000..03ff6a0
--- /dev/null
+++ b/XIVChat Desktop/ConnectDialog.xaml.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Windows;
+
+namespace XIVChat_Desktop {
+ ///
+ /// Interaction logic for ConnectDialog.xaml
+ ///
+ public partial class ConnectDialog {
+ public App App => (App)Application.Current;
+
+ public ConnectDialog() {
+ this.InitializeComponent();
+ this.DataContext = this;
+ }
+
+ private void Connect_Clicked(object? sender, RoutedEventArgs e) {
+ this.ConnectTo(this.Servers.SelectedServer);
+ }
+
+ private void Cancel_Click(object? sender, RoutedEventArgs e) {
+ this.Close();
+ }
+
+ private void Servers_ItemDoubleClick(SavedServer? server) {
+ this.ConnectTo(server);
+ }
+
+ private void ConnectTo(SavedServer? server) {
+ if (server == null) {
+ return;
+ }
+
+ this.App.Connect(server.Host, server.Port);
+
+ this.Close();
+ }
+
+ private void ConnectDialog_OnContentRendered(object? sender, EventArgs e) {
+ this.InvalidateVisual();
+ }
+ }
+}
diff --git a/XIVChat Desktop/Connection.cs b/XIVChat Desktop/Connection.cs
new file mode 100644
index 0000000..30d5893
--- /dev/null
+++ b/XIVChat Desktop/Connection.cs
@@ -0,0 +1,267 @@
+using System;
+using System.ComponentModel;
+using System.Linq;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Channels;
+using System.Threading.Tasks;
+using System.Windows;
+using XIVChatCommon;
+
+namespace XIVChat_Desktop {
+ public class Connection : INotifyPropertyChanged {
+ private readonly App app;
+
+ private readonly string host;
+ private readonly ushort port;
+
+ private TcpClient? client;
+
+ private readonly Channel outgoing = Channel.CreateUnbounded();
+ private readonly Channel incoming = Channel.CreateUnbounded();
+ private readonly Channel cancelChannel = Channel.CreateBounded(2);
+
+ public readonly CancellationTokenSource cancel = new CancellationTokenSource();
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ public string? CurrentChannel { get; private set; }
+
+ public Connection(App app, string host, ushort port) {
+ this.app = app;
+
+ this.host = host;
+ this.port = port;
+ }
+
+ public void SendMessage(string message) {
+ this.outgoing.Writer.TryWrite(message);
+ }
+
+ public void Disconnect() {
+ this.cancel.Cancel();
+ for (var i = 0; i < 2; i++) {
+ this.cancelChannel.Writer.TryWrite(1);
+ }
+ }
+
+ public async Task Connect() {
+ this.client = new TcpClient(this.host, this.port);
+ var stream = this.client.GetStream();
+
+ await stream.WriteAsync(new byte[] {
+ 14, 20, 67,
+ });
+
+ var handshake = await KeyExchange.ClientHandshake(this.app.Config.KeyPair, stream);
+
+ if (!this.app.Config.TrustedKeys.Any(trusted => trusted.Key.SequenceEqual(handshake.RemotePublicKey))) {
+ var trustChannel = Channel.CreateBounded(1);
+
+ this.Dispatch(() => {
+ new TrustDialog(this.app.Window, trustChannel.Writer, handshake.RemotePublicKey).Show();
+ });
+
+ var trusted = await trustChannel.Reader.ReadAsync(this.cancel.Token);
+
+ if (!trusted) {
+ goto Close;
+ }
+ }
+
+ // clear messages if connecting to a different host
+ var currentHost = $"{this.host}:{this.port}";
+ var sameHost = this.app.LastHost == currentHost;
+ if (!sameHost) {
+ this.Dispatch(() => {
+ this.app.Window.ClearAllMessages();
+ this.app.LastHost = currentHost;
+ });
+ }
+
+ this.Dispatch(() => {
+ this.app.Window.AddSystemMessage("Connected");
+ });
+
+ // check if backlog or catch-up is needed
+ if (sameHost) {
+ // catch-up
+ var lastRealMessage = this.app.Window.Messages.LastOrDefault(msg => msg.Channel != 0);
+ if (lastRealMessage != null) {
+ var catchUp = new ClientCatchUp {
+ After = lastRealMessage.Timestamp,
+ };
+ await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, catchUp, this.cancel.Token);
+ }
+ } else if (this.app.Config.BacklogMessages > 0) {
+ // backlog
+ var backlogReq = new ClientBacklog {
+ Amount = this.app.Config.BacklogMessages,
+ };
+ await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, backlogReq, this.cancel.Token);
+ }
+
+ // start a task for accepting incoming messages and sending them down the channel
+ _ = Task.Run(async () => {
+ var inc = SecretMessage.ReadSecretMessage(stream, handshake.Keys.rx, this.cancel.Token);
+ var cancel = this.cancelChannel.Reader.ReadAsync().AsTask();
+
+ while (!this.cancel.IsCancellationRequested) {
+ var result = await Task.WhenAny(inc, cancel);
+ if (result == inc) {
+ if (inc.Exception != null) {
+ this.Dispatch(() => {
+ this.app.Window.AddSystemMessage("Error reading incoming message.");
+ // ReSharper disable once LocalizableElement
+ Console.WriteLine($"Error reading incoming message: {inc.Exception.Message}");
+ foreach (var inner in inc.Exception.InnerExceptions) {
+ Console.WriteLine(inner.StackTrace);
+ }
+ });
+ break;
+ }
+
+ var rawMessage = await inc;
+ inc = SecretMessage.ReadSecretMessage(stream, handshake.Keys.rx, this.cancel.Token);
+ await this.incoming.Writer.WriteAsync(rawMessage);
+ } else if (result == cancel) {
+ break;
+ }
+ }
+ });
+
+ var incoming = this.incoming.Reader.ReadAsync().AsTask();
+ var outgoing = this.outgoing.Reader.ReadAsync().AsTask();
+ var cancel = this.cancelChannel.Reader.ReadAsync().AsTask();
+
+ // listen for incoming and outgoing messages and cancel requests
+ while (!this.cancel.IsCancellationRequested) {
+ var result = await Task.WhenAny(incoming, outgoing, cancel);
+ if (result == incoming) {
+ if (this.incoming.Reader.Completion.IsCompleted) {
+ break;
+ }
+ var rawMessage = await incoming;
+ incoming = this.incoming.Reader.ReadAsync().AsTask();
+
+ await this.HandleIncoming(rawMessage);
+ } else if (result == outgoing) {
+ var toSend = await outgoing;
+ outgoing = this.outgoing.Reader.ReadAsync().AsTask();
+
+ var message = new ClientMessage {
+ Content = toSend,
+ };
+ try {
+ await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, message, this.cancel.Token);
+ } catch (Exception ex) {
+ this.Dispatch(() => {
+ this.app.Window.AddSystemMessage("Error sending message.");
+ // ReSharper disable once LocalizableElement
+ Console.WriteLine($"Error sending message: {ex.Message}");
+ Console.WriteLine(ex.StackTrace);
+ });
+ break;
+ }
+ } else if (result == cancel) {
+ try {
+ await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, ClientShutdown.Instance, this.cancel.Token);
+ } catch (Exception ex) {
+ this.Dispatch(() => {
+ this.app.Window.AddSystemMessage("Error sending message.");
+ // ReSharper disable once LocalizableElement
+ Console.WriteLine($"Error sending message: {ex.Message}");
+ Console.WriteLine(ex.StackTrace);
+ });
+ }
+
+ break;
+ }
+ }
+
+ // at this point, we are disconnected, so log it
+ this.Dispatch(() => {
+ this.app.Window.AddSystemMessage("Disconnected");
+ });
+
+ // wait up to a second to send the shutdown packet
+ await Task.WhenAny(Task.Delay(1_000), SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, ClientShutdown.Instance));
+
+ Close:
+ try {
+ this.client.Close();
+ } catch (ObjectDisposedException) { }
+ }
+
+ private async Task HandleIncoming(byte[] rawMessage) {
+ var type = (ServerOperation)rawMessage[0];
+ var payload = new byte[rawMessage.Length - 1];
+ Array.Copy(rawMessage, 1, payload, 0, payload.Length);
+
+ switch (type) {
+ case ServerOperation.Pong:
+ break;
+ case ServerOperation.Message:
+ var message = ServerMessage.Decode(payload);
+
+ this.Dispatch(() => {
+ this.app.Window.AddMessage(message);
+ });
+ break;
+ case ServerOperation.Shutdown:
+ this.Disconnect();
+ break;
+ case ServerOperation.PlayerData:
+ var playerData = payload.Length == 0 ? null : PlayerData.Decode(payload);
+
+ var visibility = playerData == null ? Visibility.Collapsed : Visibility.Visible;
+
+ this.Dispatch(() => {
+ var window = this.app.Window;
+
+ window.LoggedInAs.Content = playerData?.name;
+ window.LoggedInAs.Visibility = visibility;
+
+ window.LoggedInAsSeparator.Visibility = visibility;
+
+ window.CurrentWorld.Content = playerData?.currentWorld;
+ window.CurrentWorld.Visibility = visibility;
+
+ window.CurrentWorldSeparator.Visibility = visibility;
+
+ window.Location.Content = playerData?.location;
+ window.Location.Visibility = visibility;
+ });
+ break;
+ case ServerOperation.Availability:
+ break;
+ case ServerOperation.Channel:
+ var channel = ServerChannel.Decode(payload);
+
+ this.CurrentChannel = channel.name;
+
+ this.Dispatch(() => {
+ this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.CurrentChannel)));
+ });
+ break;
+ case ServerOperation.Backlog:
+ var backlog = ServerBacklog.Decode(payload);
+
+ foreach (var msg in backlog.messages) {
+ this.Dispatch(() => {
+ this.app.Window.AddMessage(msg);
+ });
+ }
+
+ break;
+ case ServerOperation.PlayerList:
+ break;
+ case ServerOperation.LinkshellList:
+ break;
+ }
+ }
+
+ private void Dispatch(Action action) {
+ this.app.Dispatcher.BeginInvoke(action);
+ }
+ }
+}
diff --git a/XIVChat Desktop/Controls/SavedServers.xaml b/XIVChat Desktop/Controls/SavedServers.xaml
new file mode 100644
index 0000000..62978d2
--- /dev/null
+++ b/XIVChat Desktop/Controls/SavedServers.xaml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/XIVChat Desktop/Controls/SavedServers.xaml.cs b/XIVChat Desktop/Controls/SavedServers.xaml.cs
new file mode 100644
index 0000000..7117fb7
--- /dev/null
+++ b/XIVChat Desktop/Controls/SavedServers.xaml.cs
@@ -0,0 +1,73 @@
+using System.Collections.Generic;
+using System.Windows;
+
+namespace XIVChat_Desktop.Controls {
+ ///
+ /// Interaction logic for SavedServers.xaml
+ ///
+ public partial class SavedServers {
+ public App App => (App)Application.Current;
+ private Configuration Config => this.App.Config;
+ private Window Window => Window.GetWindow(this)!;
+
+ public IEnumerable ItemsSource {
+ get { return (IEnumerable)this.GetValue(ItemsSourceProperty); }
+ set { this.SetValue(ItemsSourceProperty, value); }
+ }
+
+ public SavedServer? SelectedServer {
+ get {
+ var item = this.Servers.SelectedItem;
+
+ return item as SavedServer;
+ }
+ }
+
+ public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register(
+ "ItemsSource",
+ typeof(IEnumerable),
+ typeof(SavedServers),
+ new PropertyMetadata(null)
+ );
+
+ public SavedServers() {
+ this.InitializeComponent();
+ }
+
+ private void AddServer_Click(object sender, RoutedEventArgs e) {
+ new ManageServer(this.Window, null).ShowDialog();
+ }
+
+ private void DeleteServer_Click(object sender, RoutedEventArgs e) {
+ var server = this.SelectedServer;
+ if (server == null) {
+ return;
+ }
+
+ this.Config.Servers.Remove(server);
+ this.Config.Save();
+ }
+
+ private void EditServer_Click(object sender, RoutedEventArgs e) {
+ var server = this.SelectedServer;
+ if (server == null) {
+ return;
+ }
+
+ new ManageServer(this.Window, server).ShowDialog();
+ }
+
+ private void Item_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e) {
+ var server = ((FrameworkElement)e.OriginalSource).DataContext;
+ if (!(server is SavedServer)) {
+ return;
+ }
+
+ this.ItemDoubleClick?.Invoke((SavedServer)server);
+ }
+
+ public delegate void MouseDoubleClickHandler(SavedServer server);
+
+ public event MouseDoubleClickHandler? ItemDoubleClick;
+ }
+}
diff --git a/XIVChat Desktop/Converters.cs b/XIVChat Desktop/Converters.cs
new file mode 100644
index 0000000..e363fee
--- /dev/null
+++ b/XIVChat Desktop/Converters.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Globalization;
+using System.Windows.Data;
+
+namespace XIVChat_Desktop {
+ public class DoubleConverter : IValueConverter {
+ public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) {
+ return value.ToString();
+ }
+
+ public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) {
+ if (double.TryParse(value.ToString(), out var res)) {
+ return res;
+ }
+
+ return null;
+ }
+ }
+
+ public class UShortConverter : IValueConverter {
+ public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) {
+ return value.ToString();
+ }
+
+ public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) {
+ if (ushort.TryParse(value.ToString(), out var res)) {
+ return res;
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/XIVChat Desktop/Filters.cs b/XIVChat Desktop/Filters.cs
new file mode 100644
index 0000000..a61a1d7
--- /dev/null
+++ b/XIVChat Desktop/Filters.cs
@@ -0,0 +1,347 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using XIVChatCommon;
+
+namespace XIVChat_Desktop {
+ public enum FilterCategory {
+ [Category("Chat",
+ FilterType.Say,
+ FilterType.Yell,
+ FilterType.Shout,
+ FilterType.Tell,
+ FilterType.Party,
+ FilterType.Alliance,
+ FilterType.FreeCompany,
+ FilterType.PvpTeam,
+ FilterType.CrossLinkshell1,
+ FilterType.CrossLinkshell2,
+ FilterType.CrossLinkshell3,
+ FilterType.CrossLinkshell4,
+ FilterType.CrossLinkshell5,
+ FilterType.CrossLinkshell6,
+ FilterType.CrossLinkshell7,
+ FilterType.CrossLinkshell8,
+ FilterType.Linkshell1,
+ FilterType.Linkshell2,
+ FilterType.Linkshell3,
+ FilterType.Linkshell4,
+ FilterType.Linkshell5,
+ FilterType.Linkshell6,
+ FilterType.Linkshell7,
+ FilterType.Linkshell8,
+ FilterType.NoviceNetwork,
+ FilterType.StandardEmote,
+ FilterType.CustomEmote,
+ FilterType.Gm
+ )]
+ Chat,
+
+ [Category("Battle", FilterType.Battle)]
+ Battle,
+
+ [Category("Announcements",
+ FilterType.Debug,
+ FilterType.Urgent,
+ FilterType.Notice,
+ FilterType.SystemMessages,
+ FilterType.OwnBattleSystem,
+ FilterType.OthersBattleSystem,
+ FilterType.GatheringSystem,
+ FilterType.ErrorMessages,
+ FilterType.Echo,
+ FilterType.NoviceNetworkAnnouncements,
+ FilterType.FreeCompanyAnnouncements,
+ FilterType.PvpTeamAnnouncements,
+ FilterType.FreeCompanyLoginLogout,
+ FilterType.PvpTeamLoginLogout,
+ FilterType.RetainerSale,
+ FilterType.NpcDialogue,
+ FilterType.NpcAnnouncement,
+ FilterType.Loot,
+ FilterType.OwnProgression,
+ FilterType.PartyProgression,
+ FilterType.OthersProgression,
+ FilterType.OwnLoot,
+ FilterType.OthersLoot,
+ FilterType.OwnCrafting,
+ FilterType.OthersCrafting,
+ FilterType.OwnGathering,
+ FilterType.OthersFishing,
+ FilterType.PeriodicRecruitment,
+ FilterType.Sign,
+ FilterType.Random,
+ FilterType.Orchestrion,
+ FilterType.MessageBook,
+ FilterType.Alarm
+ )]
+ Announcements,
+ }
+
+ public class CategoryAttribute : Attribute {
+ public string Name { get; }
+ public FilterType[] Types { get; }
+
+ public CategoryAttribute(string name, params FilterType[] types) {
+ this.Name = name;
+ this.Types = types;
+ }
+ }
+
+ public static class FilterCategoryExtensions {
+ private static CategoryAttribute? Info(this FilterCategory filter) => filter
+ .GetType()
+ .GetField(filter.ToString())
+ ?.GetCustomAttribute(false);
+
+ public static string? Name(this FilterCategory category) => category.Info()?.Name;
+
+ public static IEnumerable Types(this FilterCategory category) => category.Info()?.Types ?? new FilterType[0];
+ }
+
+ // NOTE: Changing the order of these is a breaking change
+ public enum FilterType {
+ [Filter("Say", ChatType.Say)] Say,
+ [Filter("Shout", ChatType.Shout)] Shout,
+ [Filter("Yell", ChatType.Yell)] Yell,
+
+ [Filter("Tell", ChatType.TellOutgoing, ChatType.TellIncoming)]
+ Tell,
+
+ [Filter("Party", ChatType.Party, ChatType.CrossParty)]
+ Party,
+
+ [Filter("Alliance", ChatType.Alliance)]
+ Alliance,
+
+ [Filter("Free Company", ChatType.FreeCompany)]
+ FreeCompany,
+ [Filter("PvP Team", ChatType.PvpTeam)] PvpTeam,
+
+ [Filter("Cross-world Linkshell [1]", ChatType.CrossLinkshell1)]
+ CrossLinkshell1,
+
+ [Filter("Cross-world Linkshell [2]", ChatType.CrossLinkshell2)]
+ CrossLinkshell2,
+
+ [Filter("Cross-world Linkshell [3]", ChatType.CrossLinkshell3)]
+ CrossLinkshell3,
+
+ [Filter("Cross-world Linkshell [4]", ChatType.CrossLinkshell4)]
+ CrossLinkshell4,
+
+ [Filter("Cross-world Linkshell [5]", ChatType.CrossLinkshell5)]
+ CrossLinkshell5,
+
+ [Filter("Cross-world Linkshell [6]", ChatType.CrossLinkshell6)]
+ CrossLinkshell6,
+
+ [Filter("Cross-world Linkshell [7]", ChatType.CrossLinkshell7)]
+ CrossLinkshell7,
+
+ [Filter("Cross-world Linkshell [8]", ChatType.CrossLinkshell8)]
+ CrossLinkshell8,
+
+ [Filter("Linkshell [1]", ChatType.Linkshell1)]
+ Linkshell1,
+
+ [Filter("Linkshell [2]", ChatType.Linkshell2)]
+ Linkshell2,
+
+ [Filter("Linkshell [3]", ChatType.Linkshell3)]
+ Linkshell3,
+
+ [Filter("Linkshell [4]", ChatType.Linkshell4)]
+ Linkshell4,
+
+ [Filter("Linkshell [5]", ChatType.Linkshell5)]
+ Linkshell5,
+
+ [Filter("Linkshell [6]", ChatType.Linkshell6)]
+ Linkshell6,
+
+ [Filter("Linkshell [7]", ChatType.Linkshell7)]
+ Linkshell7,
+
+ [Filter("Linkshell [8]", ChatType.Linkshell8)]
+ Linkshell8,
+
+ [Filter("Novice Network", ChatType.NoviceNetwork)]
+ NoviceNetwork,
+
+ [Filter("Standard Emotes", ChatType.StandardEmote)]
+ StandardEmote,
+
+ [Filter("Custom Emotes", ChatType.CustomEmote)]
+ CustomEmote,
+
+ [Filter("Battle",
+ ChatType.Damage,
+ ChatType.Miss,
+ ChatType.Action,
+ ChatType.Item,
+ ChatType.Healing,
+ ChatType.GainBuff,
+ ChatType.LoseBuff,
+ ChatType.GainDebuff,
+ ChatType.LoseDebuff,
+ ChatType.BattleSystem
+ )]
+ Battle,
+
+ [Filter("Debug", ChatType.Debug)] Debug,
+ [Filter("Urgent", ChatType.Urgent)] Urgent,
+ [Filter("Notice", ChatType.Notice)] Notice,
+
+ [Filter("System Messages", ChatType.System)]
+ SystemMessages,
+
+ [Filter("Own Battle System Messages", ChatType.BattleSystem, Source = FilterSource.Self)]
+ OwnBattleSystem,
+
+ [Filter("Others' Battle System Messages", ChatType.BattleSystem, Source = FilterSource.Others)]
+ OthersBattleSystem,
+
+ [Filter("Gathering System Messages", ChatType.GatheringSystem)]
+ GatheringSystem,
+
+ [Filter("Error Messages", ChatType.Error)]
+ ErrorMessages,
+ [Filter("Echo", ChatType.Echo)] Echo,
+
+ [Filter("Novice Network Notifications", ChatType.NoviceNetworkSystem)]
+ NoviceNetworkAnnouncements,
+
+ [Filter("Free Company Announcements", ChatType.FreeCompanyAnnouncement)]
+ FreeCompanyAnnouncements,
+
+ [Filter("PvP Team Announcements", ChatType.PvpTeamAnnouncement)]
+ PvpTeamAnnouncements,
+
+ [Filter("Free Company Member Login Notifications", ChatType.FreeCompanyLoginLogout)]
+ FreeCompanyLoginLogout,
+
+ [Filter("PvP Team Member Login Notifications", ChatType.PvpTeamLoginLogout)]
+ PvpTeamLoginLogout,
+
+ [Filter("Retainer Sale Notifications", ChatType.RetainerSale)]
+ RetainerSale,
+
+ [Filter("NPC Dialogue", ChatType.NpcDialogue)]
+ NpcDialogue,
+
+ [Filter("NPC Dialogue (Announcements)", ChatType.NpcAnnouncement)]
+ NpcAnnouncement,
+
+ [Filter("Loot Notices", ChatType.LootNotice)]
+ Loot,
+
+ [Filter("Own Progression Messages", ChatType.Progress, Source = FilterSource.Self)]
+ OwnProgression,
+
+ [Filter("Party Members' Progression Messages", ChatType.Progress, Source = FilterSource.Party)]
+ PartyProgression,
+
+ [Filter("Others' Progression Messages", ChatType.Progress, Source = FilterSource.Others)]
+ OthersProgression,
+
+ [Filter("Own Loot Messages", ChatType.LootRoll, Source = FilterSource.Self)]
+ OwnLoot,
+
+ [Filter("Others' Loot Messages", ChatType.LootRoll, Source = FilterSource.Others)]
+ OthersLoot,
+
+ [Filter("Own Synthesis Messages", ChatType.Crafting, Source = FilterSource.Self)]
+ OwnCrafting,
+
+ [Filter("Others' Synthesis Messages", ChatType.Crafting, Source = FilterSource.Self)]
+ OthersCrafting,
+
+ [Filter("Own Gathering Messages", ChatType.Gathering, Source = FilterSource.Self)]
+ OwnGathering,
+
+ [Filter("Others' Fishing Messages", ChatType.Gathering, Source = FilterSource.Others)]
+ OthersFishing,
+
+ [Filter("Periodic Recruitment Notifications", ChatType.PeriodicRecruitmentNotification)]
+ PeriodicRecruitment,
+
+ [Filter("Sign Messages for PC Targets", ChatType.Sign)]
+ Sign,
+
+ [Filter("Random Number Messages", ChatType.RandomNumber)]
+ Random,
+
+ [Filter("Current Orchestrion Track Messages", ChatType.Orchestrion)]
+ Orchestrion,
+
+ [Filter("Message Book Alert", ChatType.MessageBook)]
+ MessageBook,
+
+ [Filter("Alarm Notifications", ChatType.Alarm)]
+ Alarm,
+
+ [Filter("GM Messages",
+ ChatType.GmTell,
+ ChatType.GmSay,
+ ChatType.GmShout,
+ ChatType.GmYell,
+ ChatType.GmParty,
+ ChatType.GmFreeCompany,
+ ChatType.GmLinkshell1,
+ ChatType.GmLinkshell2,
+ ChatType.GmLinkshell3,
+ ChatType.GmLinkshell4,
+ ChatType.GmLinkshell5,
+ ChatType.GmLinkshell6,
+ ChatType.GmLinkshell7,
+ ChatType.GmLinkshell8,
+ ChatType.GmNoviceNetwork
+ )]
+ Gm,
+ }
+
+ public enum FilterSource {
+ None,
+ Self,
+ Party,
+ Others,
+ }
+
+ public class FilterAttribute : Attribute {
+ public string Name { get; }
+ public ChatType[] Types { get; }
+ public FilterSource Source { get; set; } = FilterSource.None;
+
+ public FilterAttribute(string name, params ChatType[] types) {
+ this.Name = name;
+ this.Types = types;
+ }
+ }
+
+ public static class FilterTypeExtensions {
+ private static readonly ChatSource[] Others = {
+ ChatSource.PartyMember, ChatSource.AllianceMember, ChatSource.Other, ChatSource.EngagedEnemy, ChatSource.UnengagedEnemy, ChatSource.FriendlyNpc, ChatSource.PartyPet, ChatSource.AlliancePet, ChatSource.OtherPet,
+ };
+
+ private static FilterAttribute? Info(this FilterType filter) => filter
+ .GetType()
+ .GetField(filter.ToString())
+ ?.GetCustomAttribute(false);
+
+ public static string? Name(this FilterType filter) => filter.Info()?.Name;
+
+ public static IEnumerable Types(this FilterType filter) => filter.Info()?.Types ?? new ChatType[0];
+
+ public static ChatSource[] Sources(this FilterType filter) => filter.Info()?.Source switch {
+ FilterSource.Self => new[] {
+ ChatSource.Self,
+ },
+ FilterSource.Party => new[] {
+ ChatSource.PartyMember,
+ },
+ FilterSource.Others => Others,
+ _ => new ChatSource[0],
+ };
+ }
+}
diff --git a/XIVChat Desktop/MainWindow.xaml b/XIVChat Desktop/MainWindow.xaml
index 95e683b..543560b 100644
--- a/XIVChat Desktop/MainWindow.xaml
+++ b/XIVChat Desktop/MainWindow.xaml
@@ -4,22 +4,110 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:XIVChat_Desktop"
+ xmlns:ui="http://schemas.modernwpf.com/2019"
+ ui:WindowHelper.UseModernWindowStyle="True"
mc:Ignorable="d"
- Title="MainWindow" Height="450" Width="800">
+ Title="XIVChat for Windows"
+ Height="450"
+ Width="800"
+ x:Name="Main"
+ Icon="/ic_launcher-playstore.png"
+ d:DataContext="{d:DesignInstance local:MainWindow}">
-
-
-
+
+
+
-
-
+
\ No newline at end of file
diff --git a/XIVChat Desktop/MainWindow.xaml.cs b/XIVChat Desktop/MainWindow.xaml.cs
index 4333cfc..c5a795e 100644
--- a/XIVChat Desktop/MainWindow.xaml.cs
+++ b/XIVChat Desktop/MainWindow.xaml.cs
@@ -1,25 +1,136 @@
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
-using System.Linq;
using System.Text;
-using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
-using System.Windows.Data;
-using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
-using System.Windows.Media.Imaging;
-using System.Windows.Navigation;
-using System.Windows.Shapes;
+using XIVChatCommon;
namespace XIVChat_Desktop {
///
/// Interaction logic for MainWindow.xaml
///
- public partial class MainWindow : Window {
+ public partial class MainWindow {
+ public App App => (App)Application.Current;
+
+ public ConcurrentStack Messages { get; } = new ConcurrentStack();
+
public MainWindow() {
- InitializeComponent();
+ this.InitializeComponent();
+ this.DataContext = this;
+ }
+
+ private T? FindElementByName(DependencyObject element, string sChildName) where T : FrameworkElement {
+ T? childElement = null;
+ var nChildCount = VisualTreeHelper.GetChildrenCount(element);
+ for (int i = 0; i < nChildCount; i++) {
+ if (!(VisualTreeHelper.GetChild(element, i) is FrameworkElement child)) {
+ continue;
+ }
+
+ if (child is T t && child.Name.Equals(sChildName)) {
+ childElement = t;
+ break;
+ }
+
+ childElement = this.FindElementByName(child, sChildName);
+
+ if (childElement != null) {
+ break;
+ }
+ }
+
+ return childElement;
+ }
+
+ public void ClearAllMessages() {
+ this.Messages.Clear();
+ foreach (var tab in this.App.Config.Tabs) {
+ tab.ClearMessages();
+ }
+ }
+
+ public void AddSystemMessage(string content) {
+ var message = new ServerMessage {
+ Channel = 0,
+ Content = Encoding.UTF8.GetBytes(content),
+ Timestamp = DateTime.UtcNow,
+ Chunks = new List {
+ new TextChunk {
+ Foreground = 0xb38cffff,
+ Content = content,
+ },
+ },
+ };
+ this.AddMessage(message);
+ }
+
+ public void AddMessage(ServerMessage message) {
+ // detect if scroller is at the bottom
+ var scroller = this.FindElementByName(this.Tabs, "scroller");
+ var wasAtBottom = Math.Abs(scroller!.VerticalOffset - scroller.ScrollableHeight) < .0001;
+
+ // add message to main list
+ this.Messages.Push(message);
+ // add message to each tab if the filter allows for it
+ foreach (var tab in this.App.Config.Tabs) {
+ tab.AddMessage(message);
+ }
+
+ // scroll to the bottom if previously at the bottom
+ if (wasAtBottom) {
+ scroller.ScrollToBottom();
+ }
+ }
+
+ private void Connect_Click(object sender, RoutedEventArgs e) {
+ var dialog = new ConnectDialog {
+ Owner = this,
+ };
+ dialog.ShowDialog();
+ }
+
+ private void Disconnect_Click(object sender, RoutedEventArgs e) {
+ this.App.Disconnect();
+ }
+
+ private void Input_Submit(object sender, KeyEventArgs e) {
+ if (e.Key != Key.Return) {
+ return;
+ }
+
+ var conn = this.App.Connection;
+ if (conn == null) {
+ return;
+ }
+
+ if (!(sender is TextBox)) {
+ return;
+ }
+
+ var textBox = (TextBox)sender;
+
+ conn.SendMessage(textBox.Text);
+ textBox.Text = "";
+ }
+
+ private void Configuration_Click(object sender, RoutedEventArgs e) {
+ new ConfigWindow(this, this.App.Config).Show();
+ }
+
+ private void Tabs_Loaded(object sender, RoutedEventArgs e) {
+ this.Tabs.SelectedIndex = 0;
+ }
+
+ private void Tabs_SelectionChanged(object sender, SelectionChangedEventArgs e) {
+ var scroller = this.FindElementByName(this.Tabs, "scroller");
+ scroller?.ScrollToBottom();
+ }
+
+ private void ManageTabs_Click(object sender, RoutedEventArgs e) {
+ new ManageTabs(this).Show();
}
}
}
diff --git a/XIVChat Desktop/ManageServer.xaml b/XIVChat Desktop/ManageServer.xaml
new file mode 100644
index 0000000..97c5ab2
--- /dev/null
+++ b/XIVChat Desktop/ManageServer.xaml
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/XIVChat Desktop/ManageServer.xaml.cs b/XIVChat Desktop/ManageServer.xaml.cs
new file mode 100644
index 0000000..5059af8
--- /dev/null
+++ b/XIVChat Desktop/ManageServer.xaml.cs
@@ -0,0 +1,64 @@
+using System;
+using System.Windows;
+
+namespace XIVChat_Desktop {
+ ///
+ /// Interaction logic for ManageServer.xaml
+ ///
+ public partial class ManageServer {
+ public App App => (App)Application.Current;
+ public SavedServer? Server { get; private set; }
+
+ private readonly bool isNewServer;
+
+ public ManageServer(Window owner, SavedServer? server) {
+ this.Owner = owner;
+ this.Server = server;
+ this.isNewServer = server == null;
+
+ this.InitializeComponent();
+ this.DataContext = this;
+ }
+
+ private void Save_Click(object sender, RoutedEventArgs e) {
+ var serverName = this.ServerName.Text;
+ var serverHost = this.ServerHost.Text;
+
+ if (serverName.Length == 0 || serverHost.Length == 0) {
+ MessageBox.Show("Server must have a name and host.");
+ return;
+ }
+
+ ushort port;
+ if (this.ServerPort.Text.Length == 0) {
+ port = 14777;
+ } else {
+ if (!ushort.TryParse(this.ServerPort.Text, out port) || port < 1) {
+ MessageBox.Show("Port was not valid. It must be a number between 1 and 65535.");
+ return;
+ }
+ }
+
+ if (this.isNewServer) {
+ this.Server = new SavedServer(
+ serverName,
+ serverHost,
+ port
+ );
+ this.App.Config.Servers.Add(this.Server);
+ }
+
+ this.App.Config.Save();
+
+ this.Close();
+ }
+
+ private void Cancel_Click(object sender, RoutedEventArgs e) {
+ this.Close();
+ }
+
+ private void ManageServer_OnContentRendered(object? sender, EventArgs e) {
+ this.InvalidateVisual();
+ }
+ }
+}
diff --git a/XIVChat Desktop/ManageTab.xaml b/XIVChat Desktop/ManageTab.xaml
new file mode 100644
index 0000000..dcd38c8
--- /dev/null
+++ b/XIVChat Desktop/ManageTab.xaml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/XIVChat Desktop/ManageTab.xaml.cs b/XIVChat Desktop/ManageTab.xaml.cs
new file mode 100644
index 0000000..822793f
--- /dev/null
+++ b/XIVChat Desktop/ManageTab.xaml.cs
@@ -0,0 +1,116 @@
+using System;
+using System.Collections.Immutable;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Documents;
+
+namespace XIVChat_Desktop {
+ ///
+ /// Interaction logic for FiltersSelection.xaml
+ ///
+ public partial class FiltersSelection {
+ public App App => (App)Application.Current;
+
+ public Tab Tab { get; }
+
+ private readonly bool isNewTab;
+ private readonly IImmutableSet oldFilters;
+
+ public FiltersSelection(Window owner, Tab? tab) {
+ this.Owner = owner;
+ this.isNewTab = tab == null;
+ this.Tab = tab ?? new Tab("") {
+ Filter = Tab.GeneralFilter(),
+ };
+ this.oldFilters = this.Tab.Filter.Types.ToImmutableHashSet();
+
+ this.InitializeComponent();
+ this.DataContext = this;
+
+ foreach (var category in (FilterCategory[])Enum.GetValues(typeof(FilterCategory))) {
+ var panel = new WrapPanel {
+ Margin = new Thickness(8),
+ Orientation = Orientation.Vertical,
+ };
+
+ var tabContent = new ScrollViewer {
+ Content = panel,
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ };
+
+ var buttonsPanel = new WrapPanel {
+ Margin = new Thickness(0, 0, 0, 4),
+ };
+
+ var selectButton = new Button {
+ Content = "Select all",
+ };
+ selectButton.Click += (sender, e) => SetAllChecked(true);
+
+ var deselectButton = new Button {
+ Content = "Deselect all",
+ Margin = new Thickness(4, 0, 0, 0),
+ };
+ deselectButton.Click += (sender, e) => SetAllChecked(false);
+
+ void SetAllChecked(bool isChecked) {
+ foreach (var child in panel.Children) {
+ if (!(child is CheckBox)) {
+ continue;
+ }
+
+ var check = (CheckBox)child;
+ check.IsChecked = isChecked;
+ }
+ }
+
+ buttonsPanel.Children.Add(selectButton);
+ buttonsPanel.Children.Add(deselectButton);
+
+ panel.Children.Add(buttonsPanel);
+ panel.Children.Add(new Separator());
+
+ foreach (var type in category.Types()) {
+ var check = new CheckBox {
+ Content = type.Name(),
+ IsChecked = this.Tab.Filter.Types.Contains(type),
+ };
+
+ check.Checked += (sender, e) => {
+ this.Tab.Filter.Types.Add(type);
+ };
+ check.Unchecked += (sender, e) => {
+ this.Tab.Filter.Types.Remove(type);
+ };
+
+ panel.Children.Add(check);
+ }
+
+ var tabItem = new TabItem {
+ Header = new TextBlock(new Run(category.Name())),
+ Content = tabContent,
+ };
+
+ this.Tabs.Items.Add(tabItem);
+ }
+ }
+
+ private void Save_Click(object sender, RoutedEventArgs e) {
+ if (this.Tab.Name.Length == 0) {
+ MessageBox.Show("Tab must have a name.");
+ return;
+ }
+
+ if (this.isNewTab) {
+ this.App.Config.Tabs.Add(this.Tab);
+ }
+
+ if (this.isNewTab || !this.oldFilters.SetEquals(this.Tab.Filter.Types)) {
+ this.Tab.RepopulateMessages(this.App.Window.Messages);
+ }
+
+ this.App.Config.Save();
+ this.Close();
+ }
+ }
+}
diff --git a/XIVChat Desktop/ManageTabs.xaml b/XIVChat Desktop/ManageTabs.xaml
new file mode 100644
index 0000000..0ca4b78
--- /dev/null
+++ b/XIVChat Desktop/ManageTabs.xaml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/XIVChat Desktop/ManageTabs.xaml.cs b/XIVChat Desktop/ManageTabs.xaml.cs
new file mode 100644
index 0000000..698f70f
--- /dev/null
+++ b/XIVChat Desktop/ManageTabs.xaml.cs
@@ -0,0 +1,59 @@
+using System.Windows;
+using System.Windows.Input;
+
+namespace XIVChat_Desktop {
+ ///
+ /// Interaction logic for ManageTabs.xaml
+ ///
+ public partial class ManageTabs {
+ public App App => (App)Application.Current;
+
+ private Tab? SelectedTab {
+ get {
+ var item = this.Tabs.SelectedItem;
+
+ return item as Tab;
+ }
+ }
+
+ public ManageTabs(Window owner) {
+ this.Owner = owner;
+ this.InitializeComponent();
+ this.DataContext = this;
+ }
+
+ private void AddTab_Click(object sender, RoutedEventArgs e) {
+ new FiltersSelection(this, null).ShowDialog();
+ }
+
+ private void EditTab_Click(object sender, RoutedEventArgs e) {
+ var tab = this.SelectedTab;
+ if (tab == null) {
+ return;
+ }
+ new FiltersSelection(this, tab).ShowDialog();
+ }
+
+ private void DeleteTab_Click(object sender, RoutedEventArgs e) {
+ var tab = this.SelectedTab;
+ if (tab == null) {
+ return;
+ }
+
+ this.App.Config.Tabs.Remove(tab);
+ this.App.Config.Save();
+
+ }
+
+ private void Tab_MouseDoubleClick(object sender, MouseButtonEventArgs e) {
+ var item = ((FrameworkElement)e.OriginalSource).DataContext;
+ if (!(item is Tab)) {
+ return;
+ }
+
+ var tab = item as Tab;
+
+ new FiltersSelection(this, tab).ShowDialog();
+ }
+ }
+}
diff --git a/XIVChat Desktop/MessageFormatter.cs b/XIVChat Desktop/MessageFormatter.cs
new file mode 100644
index 0000000..a27b5dc
--- /dev/null
+++ b/XIVChat Desktop/MessageFormatter.cs
@@ -0,0 +1,213 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Documents;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using XIVChatCommon;
+
+namespace XIVChat_Desktop {
+ public class MessageFormatter {
+ private static readonly BitmapFrame FontIcon =
+ BitmapFrame.Create(new Uri("pack://application:,,,/Resources/fonticon_ps4.tex.png"));
+
+ public static readonly DependencyProperty FormattedTextProperty = DependencyProperty.RegisterAttached(
+ "FormattedText",
+ typeof(ServerMessage),
+ typeof(MessageFormatter),
+ new PropertyMetadata(null, FormattedTextPropertyChanged)
+ );
+
+ public static void SetFormattedText(DependencyObject textBlock, ServerMessage value) {
+ textBlock.SetValue(FormattedTextProperty, value);
+ }
+
+ public static string GetFormattedText(DependencyObject textBlock) {
+ return (string)textBlock.GetValue(FormattedTextProperty);
+ }
+
+ private static void FormattedTextPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) {
+ // Clear current textBlock
+ if (!(d is TextBlock textBlock)) {
+ return;
+ }
+
+ textBlock.ClearValue(TextBlock.TextProperty);
+ textBlock.Inlines.Clear();
+
+ // Create new formatted text
+ var lineHeight = textBlock.FontFamily.LineSpacing * textBlock.FontSize;
+ foreach (var inline in ChunksToTextBlock(lineHeight, (ServerMessage)e.NewValue)) {
+ textBlock.Inlines.Add(inline);
+ }
+ }
+
+ private static IEnumerable ChunksToTextBlock(double lineHeight, ServerMessage message) {
+ var elements = new List();
+
+ var timestampString = message.Timestamp.ToLocalTime().ToString("t", CultureInfo.CurrentUICulture);
+ elements.Add(new Run($"[{timestampString}]") {
+ Foreground = new SolidColorBrush(Colors.White),
+ });
+
+ foreach (var chunk in message.Chunks) {
+ switch (chunk) {
+ case TextChunk textChunk:
+ var colour = textChunk.Foreground ?? textChunk.FallbackColour ?? 0;
+
+ var r = (byte)((colour >> 24) & 0xFF);
+ var g = (byte)((colour >> 16) & 0xFF);
+ var b = (byte)((colour >> 8) & 0xFF);
+ var a = (byte)(colour & 0xFF);
+
+ var brush = new SolidColorBrush(Color.FromArgb(a, r, g, b));
+ var style = textChunk.Italic ? FontStyles.Italic : FontStyles.Normal;
+
+ //var part = string.Empty;
+ //foreach (char c in textChunk.Content) {
+ // if (c >= '\ue000' && c <= '\uf8ff') {
+ // // private use
+
+ // // add existing text if necessary
+ // if (part.Length != 0) {
+ // elements.Add(new Run(part) {
+ // Foreground = brush,
+ // FontStyle = style,
+ // });
+ // part = string.Empty;
+ // }
+
+ // // add private use segment with font
+ // elements.Add(new Run(c.ToString()) {
+ // Foreground = brush,
+ // FontStyle = style,
+ // FontFamily = new FontFamily(new Uri("pack://application:,,,/"), "/fonts/#XIV AXIS Std ATK"),
+ // });
+ // continue;
+ // }
+
+ // part += c;
+ //}
+
+ elements.Add(new Run(textChunk.Content) {
+ Foreground = brush,
+ FontStyle = style,
+ });
+ break;
+ case IconChunk iconChunk:
+ var bounds = GetBounds(iconChunk.Index);
+ if (bounds == null) {
+ break;
+ }
+
+ var width = lineHeight / bounds.Value.Height * bounds.Value.Width;
+
+ var cropped = new CroppedBitmap(FontIcon, bounds.Value);
+ var image = new Image {
+ Source = cropped,
+ Width = width,
+ Height = lineHeight,
+ };
+ elements.Add(new InlineUIContainer(image) {
+ BaselineAlignment = BaselineAlignment.Bottom,
+ });
+ break;
+ }
+ }
+
+ return elements;
+ }
+
+ private static Int32Rect? GetBounds(byte id) => id switch {
+ 1 => new Int32Rect(0, 0, 20, 20),
+ 2 => new Int32Rect(20, 0, 20, 20),
+ 3 => new Int32Rect(40, 0, 20, 20),
+ 4 => new Int32Rect(60, 0, 20, 20),
+ 5 => new Int32Rect(80, 0, 20, 20),
+ 6 => new Int32Rect(0, 20, 20, 20),
+ 7 => new Int32Rect(20, 20, 20, 20),
+ 8 => new Int32Rect(40, 20, 20, 20),
+ 9 => new Int32Rect(60, 20, 20, 20),
+ 10 => new Int32Rect(80, 20, 20, 20),
+ 11 => new Int32Rect(0, 40, 20, 20),
+ 12 => new Int32Rect(20, 40, 20, 20),
+ 13 => new Int32Rect(40, 40, 20, 20),
+ 14 => new Int32Rect(60, 40, 20, 20),
+ 15 => new Int32Rect(80, 40, 20, 20),
+ 16 => new Int32Rect(60, 100, 20, 20),
+ 17 => new Int32Rect(80, 100, 20, 20),
+ 18 => new Int32Rect(0, 60, 54, 20),
+ 19 => new Int32Rect(54, 60, 54, 20),
+ 20 => new Int32Rect(60, 80, 20, 20),
+ 21 => new Int32Rect(0, 80, 28, 20),
+ 22 => new Int32Rect(28, 80, 32, 20),
+ 23 => new Int32Rect(80, 80, 20, 20),
+ 24 => new Int32Rect(0, 100, 28, 20),
+ 25 => new Int32Rect(28, 100, 32, 20),
+ 51 => new Int32Rect(124, 0, 20, 20),
+ 52 => new Int32Rect(144, 0, 20, 20),
+ 53 => new Int32Rect(164, 0, 20, 20),
+ 54 => new Int32Rect(100, 0, 12, 20),
+ 55 => new Int32Rect(112, 0, 12, 20),
+ 56 => new Int32Rect(100, 20, 20, 20),
+ 57 => new Int32Rect(120, 20, 20, 20),
+ 58 => new Int32Rect(140, 20, 20, 20),
+ 59 => new Int32Rect(100, 40, 20, 20),
+ 60 => new Int32Rect(120, 40, 20, 20),
+ 61 => new Int32Rect(140, 40, 20, 20),
+ 62 => new Int32Rect(160, 20, 20, 20),
+ 63 => new Int32Rect(160, 40, 20, 20),
+ 64 => new Int32Rect(184, 0, 20, 20),
+ 65 => new Int32Rect(204, 0, 20, 20),
+ 66 => new Int32Rect(224, 0, 20, 20),
+ 67 => new Int32Rect(180, 20, 20, 20),
+ 68 => new Int32Rect(200, 20, 20, 20),
+ 69 => new Int32Rect(236, 236, 20, 20),
+ 70 => new Int32Rect(180, 40, 20, 20),
+ 71 => new Int32Rect(200, 40, 20, 20),
+ 72 => new Int32Rect(220, 40, 20, 20),
+ 73 => new Int32Rect(220, 20, 20, 20),
+ 74 => new Int32Rect(108, 60, 20, 20),
+ 75 => new Int32Rect(128, 60, 20, 20),
+ 76 => new Int32Rect(148, 60, 20, 20),
+ 77 => new Int32Rect(168, 60, 20, 20),
+ 78 => new Int32Rect(188, 60, 20, 20),
+ 79 => new Int32Rect(208, 60, 20, 20),
+ 80 => new Int32Rect(228, 60, 20, 20),
+ 81 => new Int32Rect(100, 80, 20, 20),
+ 82 => new Int32Rect(120, 80, 20, 20),
+ 83 => new Int32Rect(140, 80, 20, 20),
+ 84 => new Int32Rect(160, 80, 20, 20),
+ 85 => new Int32Rect(180, 80, 20, 20),
+ 86 => new Int32Rect(200, 80, 20, 20),
+ 87 => new Int32Rect(220, 80, 20, 20),
+ 88 => new Int32Rect(100, 100, 20, 20),
+ 89 => new Int32Rect(120, 100, 20, 20),
+ 90 => new Int32Rect(140, 100, 20, 20),
+ 91 => new Int32Rect(160, 100, 20, 20),
+ 92 => new Int32Rect(180, 100, 20, 20),
+ 93 => new Int32Rect(200, 100, 20, 20),
+ 94 => new Int32Rect(220, 100, 20, 20),
+ 95 => new Int32Rect(0, 120, 20, 20),
+ 96 => new Int32Rect(20, 120, 20, 20),
+ 97 => new Int32Rect(40, 120, 20, 20),
+ 98 => new Int32Rect(60, 120, 20, 20),
+ 99 => new Int32Rect(80, 120, 20, 20),
+ 100 => new Int32Rect(100, 120, 20, 20),
+ 101 => new Int32Rect(120, 120, 20, 20),
+ 102 => new Int32Rect(140, 120, 20, 20),
+ 103 => new Int32Rect(160, 120, 20, 20),
+ 104 => new Int32Rect(180, 120, 20, 20),
+ 105 => new Int32Rect(200, 120, 20, 20),
+ 106 => new Int32Rect(220, 120, 20, 20),
+ 107 => new Int32Rect(0, 140, 20, 20),
+ 108 => new Int32Rect(20, 140, 20, 20),
+ 109 => new Int32Rect(40, 140, 20, 20),
+ 110 => new Int32Rect(60, 140, 20, 20),
+ 111 => new Int32Rect(80, 140, 20, 20),
+ _ => null,
+ };
+ }
+}
diff --git a/XIVChat Desktop/Properties/DesignTimeResources.xaml b/XIVChat Desktop/Properties/DesignTimeResources.xaml
new file mode 100644
index 0000000..b269956
--- /dev/null
+++ b/XIVChat Desktop/Properties/DesignTimeResources.xaml
@@ -0,0 +1,7 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/XIVChat Desktop/Properties/Resources.Designer.cs b/XIVChat Desktop/Properties/Resources.Designer.cs
new file mode 100644
index 0000000..669e441
--- /dev/null
+++ b/XIVChat Desktop/Properties/Resources.Designer.cs
@@ -0,0 +1,72 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+// Runtime Version:4.0.30319.42000
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+namespace XIVChat_Desktop.Properties {
+ using System;
+
+
+ ///
+ /// A strongly-typed resource class, for looking up localized strings, etc.
+ ///
+ // This class was auto-generated by the StronglyTypedResourceBuilder
+ // class via a tool like ResGen or Visual Studio.
+ // To add or remove a member, edit your .ResX file then rerun ResGen
+ // with the /str option, or rebuild your VS project.
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ public class Resources {
+
+ private static global::System.Resources.ResourceManager resourceMan;
+
+ private static global::System.Globalization.CultureInfo resourceCulture;
+
+ [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+ internal Resources() {
+ }
+
+ ///
+ /// Returns the cached ResourceManager instance used by this class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ public static global::System.Resources.ResourceManager ResourceManager {
+ get {
+ if (object.ReferenceEquals(resourceMan, null)) {
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("XIVChat_Desktop.Properties.Resources", typeof(Resources).Assembly);
+ resourceMan = temp;
+ }
+ return resourceMan;
+ }
+ }
+
+ ///
+ /// Overrides the current thread's CurrentUICulture property for all
+ /// resource lookups using this strongly typed resource class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ public static global::System.Globalization.CultureInfo Culture {
+ get {
+ return resourceCulture;
+ }
+ set {
+ resourceCulture = value;
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to XIVChat for Windows.
+ ///
+ public static string AppName {
+ get {
+ return ResourceManager.GetString("AppName", resourceCulture);
+ }
+ }
+ }
+}
diff --git a/XIVChat Desktop/Properties/Resources.resx b/XIVChat Desktop/Properties/Resources.resx
new file mode 100644
index 0000000..845753f
--- /dev/null
+++ b/XIVChat Desktop/Properties/Resources.resx
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ XIVChat for Windows
+
+
\ No newline at end of file
diff --git a/XIVChat Desktop/Resources/fonticon_ps4.tex.png b/XIVChat Desktop/Resources/fonticon_ps4.tex.png
new file mode 100644
index 0000000..5026b15
Binary files /dev/null and b/XIVChat Desktop/Resources/fonticon_ps4.tex.png differ
diff --git a/XIVChat Desktop/TrustDialog.xaml b/XIVChat Desktop/TrustDialog.xaml
new file mode 100644
index 0000000..b68925a
--- /dev/null
+++ b/XIVChat Desktop/TrustDialog.xaml
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You are attempting to connect to a server you have never connected to before. Please check the server and ensure the two keys below match.
+
+
+
+
+
+
+
+
+
+ Server:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Client:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Give the key a name to remember it.
+ No name
+
+
+ Do both keys match?
+
+
+
+
+
+
+
diff --git a/XIVChat Desktop/TrustDialog.xaml.cs b/XIVChat Desktop/TrustDialog.xaml.cs
new file mode 100644
index 0000000..a3bbd26
--- /dev/null
+++ b/XIVChat Desktop/TrustDialog.xaml.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Channels;
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Shapes;
+
+namespace XIVChat_Desktop {
+ ///
+ /// Interaction logic for TrustDialog.xaml
+ ///
+ public partial class TrustDialog {
+ private readonly ChannelWriter trustChannel;
+ private readonly byte[] remoteKey;
+
+ private App App => (App)Application.Current;
+
+ public TrustDialog(Window owner, ChannelWriter trustChannel, byte[] remoteKey) {
+ this.Owner = owner;
+ this.trustChannel = trustChannel;
+ this.remoteKey = remoteKey;
+
+ this.InitializeComponent();
+
+ this.ClientPublicKey.Text = ToHexString(this.App.Config.KeyPair.PublicKey);
+ var clientColours = BreakIntoColours(this.App.Config.KeyPair.PublicKey);
+ for (int i = 0; i < this.ClientPublicKeyColours.Children.Count; i++) {
+ var rect = (Rectangle)this.ClientPublicKeyColours.Children[i];
+ rect.Fill = new SolidColorBrush(clientColours[i]);
+ }
+
+ this.ServerPublicKey.Text = ToHexString(remoteKey);
+ var serverColours = BreakIntoColours(remoteKey);
+ for (int i = 0; i < this.ServerPublicKeyColours.Children.Count; i++) {
+ var rect = (Rectangle)this.ServerPublicKeyColours.Children[i];
+ rect.Fill = new SolidColorBrush(serverColours[i]);
+ }
+ }
+
+ private static List BreakIntoColours(IEnumerable key) {
+ var colours = new List();
+
+ // ReSharper disable once LoopCanBeConvertedToQuery
+ foreach (var chunk in SplitList(key.ToList(), 3)) {
+ var r = chunk[0];
+ var g = chunk.Count > 1 ? chunk[1] : (byte)0;
+ var b = chunk.Count > 2 ? chunk[2] : (byte)0;
+
+ colours.Add(Color.FromRgb(r, g, b));
+ }
+
+ return colours;
+ }
+
+ private static IEnumerable> SplitList(List locations, int nSize) {
+ for (int i = 0; i < locations.Count; i += nSize) {
+ yield return locations.GetRange(i, Math.Min(nSize, locations.Count - i));
+ }
+ }
+
+ private static string ToHexString(IEnumerable bytes) {
+ return string.Join("", bytes.Select(b => b.ToString("X2")));
+ }
+
+ private async void Yes_Click(object sender, RoutedEventArgs e) {
+ var keyName = this.KeyName.Text;
+ if (keyName.Length == 0) {
+ MessageBox.Show("You must give this key a name.");
+ return;
+ }
+
+ var trustedKey = new TrustedKey(keyName, this.remoteKey);
+ this.App.Config.TrustedKeys.Add(trustedKey);
+ this.App.Config.Save();
+ await this.trustChannel.WriteAsync(true);
+ this.Close();
+ }
+
+ private async void No_Click(object sender, RoutedEventArgs e) {
+ await this.trustChannel.WriteAsync(false);
+ this.Close();
+ }
+ }
+}
diff --git a/XIVChat Desktop/XIVChat Desktop.csproj b/XIVChat Desktop/XIVChat Desktop.csproj
index 2c2d56c..868bbeb 100644
--- a/XIVChat Desktop/XIVChat Desktop.csproj
+++ b/XIVChat Desktop/XIVChat Desktop.csproj
@@ -1,10 +1,59 @@
-
- WinExe
- netcoreapp3.1
- XIVChat_Desktop
- true
-
+
+ WinExe
+ netcoreapp3.1
+ XIVChat_Desktop
+ true
+ XIVChat Desktop
+ enable
+ XIVChat
+ 1.0.0
+ 1.0.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ True
+ True
+ Resources.resx
+
+
+
+
+
+ PublicResXFileCodeGenerator
+ Resources.Designer.cs
+
+
+
+
+
+ Designer
+ true
+
+
\ No newline at end of file
diff --git a/XIVChat Desktop/fonts/ffxiv.ttf b/XIVChat Desktop/fonts/ffxiv.ttf
new file mode 100644
index 0000000..a627f77
Binary files /dev/null and b/XIVChat Desktop/fonts/ffxiv.ttf differ
diff --git a/XIVChat Desktop/ic_launcher-playstore.png b/XIVChat Desktop/ic_launcher-playstore.png
new file mode 100644
index 0000000..3bb6883
Binary files /dev/null and b/XIVChat Desktop/ic_launcher-playstore.png differ
diff --git a/XIVChat.sln b/XIVChat.sln
index 50a3c2b..c6279dc 100644
--- a/XIVChat.sln
+++ b/XIVChat.sln
@@ -12,7 +12,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XIVChatCommon", "XIVChatCommon\XIVChatCommon.csproj", "{6F426EAB-15D4-48AC-80D2-2D2DF2CE5EE0}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XIVChat Desktop", "XIVChat Desktop\XIVChat Desktop.csproj", "{D2773EAE-6B9E-4017-9681-585AAB5A99F4}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XIVChat Desktop", "XIVChat Desktop\XIVChat Desktop.csproj", "{D2773EAE-6B9E-4017-9681-585AAB5A99F4}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution