chore(desktop): add initial code

This commit is contained in:
Anna 2020-10-31 21:31:10 -04:00
parent d0c16bf655
commit ff76997b0b
Signed by: anna
GPG Key ID: 0B391D8F06FCD9E0
31 changed files with 2532 additions and 34 deletions

View File

@ -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">
<Application.Resources>
<ResourceDictionary>
<local:DoubleConverter x:Key="DoubleConverter" />
<local:UShortConverter x:Key="UShortConverter" />
<ResourceDictionary.MergedDictionaries>
<ui:ThemeResources AccentColor="#02ccee" />
<ui:XamlControlsResources />
<!-- Other merged dictionaries here -->
</ResourceDictionary.MergedDictionaries>
<!-- Other app resources here -->
</ResourceDictionary>
</Application.Resources>
</Application>
</Application>

View File

@ -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 {
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
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;
}
}
}

View File

@ -0,0 +1,77 @@
<Window x:Class="XIVChat_Desktop.ConfigWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
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:cc="clr-namespace:XIVChat_Desktop.Controls"
xmlns:ui="http://schemas.modernwpf.com/2019"
ui:WindowHelper.UseModernWindowStyle="True"
mc:Ignorable="d"
MinWidth="450"
MinHeight="350"
WindowStartupLocation="CenterOwner"
ContentRendered="ConfigWindow_OnContentRendered"
SizeToContent="WidthAndHeight"
Title="Configuration"
d:DataContext="{d:DesignInstance local:ConfigWindow}">
<TabControl>
<TabItem Header="Servers">
<cc:SavedServers ItemsSource="{Binding Config.Servers}"
ItemDoubleClick="SavedServers_ItemDoubleClick" />
</TabItem>
<TabItem Header="Window">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<CheckBox Grid.Row="0"
Grid.Column="0"
Content="Always on top"
IsChecked="{Binding Config.AlwaysOnTop}"
Checked="AlwaysOnTop_Checked"
Unchecked="AlwaysOnTop_Unchecked" />
<TextBox Grid.Row="1"
Grid.Column="0"
HorizontalAlignment="Left"
PreviewTextInput="NumericInputFilter"
Text="{Binding Config.FontSize, Converter={StaticResource DoubleConverter}}"
ui:ControlHelper.Header="Log font size"
Width="200" />
<Button Grid.Row="3"
HorizontalAlignment="Right"
Click="Save_Click">
Save
</Button>
</Grid>
</TabItem>
<TabItem Header="Connection">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBox Grid.Row="0"
Grid.Column="0"
ui:ControlHelper.Header="Backlog messages to request"
HorizontalAlignment="Left"
PreviewTextInput="NumericInputFilter"
Text="{Binding Config.BacklogMessages, Converter={StaticResource UShortConverter}}"
Width="200" />
<Button Grid.Row="2"
HorizontalAlignment="Right"
Click="Save_Click">
Save
</Button>
</Grid>
</TabItem>
</TabControl>
</Window>

View File

@ -0,0 +1,51 @@
using System;
using System.Linq;
using System.Windows;
using System.Windows.Input;
namespace XIVChat_Desktop {
/// <summary>
/// Interaction logic for ConfigWindow.xaml
/// </summary>
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;
}
}
}

View File

@ -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<SavedServer> Servers { get; set; } = new ObservableCollection<SavedServer>();
public HashSet<TrustedKey> TrustedKeys { get; set; } = new HashSet<TrustedKey>();
public ObservableCollection<Tab> 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<Configuration>(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<ServerMessage> Messages { get; } = new ObservableCollection<ServerMessage>();
public event PropertyChangedEventHandler? PropertyChanged;
public void RepopulateMessages(ConcurrentStack<ServerMessage> 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<Tab> Defaults() {
var battleFilters = FilterCategory.Battle.Types()
.Append(FilterType.OwnBattleSystem)
.Append(FilterType.OthersBattleSystem)
.ToHashSet();
return new ObservableCollection<Tab> {
new Tab("General") {
Filter = GeneralFilter(),
},
new Tab("Battle") {
Filter = new Filter {
Types = battleFilters,
},
},
};
}
}
[JsonObject]
public class Filter {
public HashSet<FilterType> Types { get; set; } = new HashSet<FilterType>();
public bool Allowed(ServerMessage message) {
return this.Types
.SelectMany(type => type.Types())
.Contains(new ChatCode((ushort)message.Channel).Type);
}
}
}

View File

@ -0,0 +1,43 @@
<Window x:Class="XIVChat_Desktop.ConnectDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
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:cc="clr-namespace:XIVChat_Desktop.Controls"
xmlns:ui="http://schemas.modernwpf.com/2019"
ui:WindowHelper.UseModernWindowStyle="True"
mc:Ignorable="d"
ContentRendered="ConnectDialog_OnContentRendered"
SizeToContent="WidthAndHeight"
WindowStartupLocation="CenterOwner"
ResizeMode="CanResizeWithGrip"
ShowInTaskbar="False"
Title="Connect"
d:DataContext="{d:DesignInstance local:ConnectDialog}">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<cc:SavedServers x:Name="Servers"
ItemsSource="{Binding App.Config.Servers}"
ItemDoubleClick="Servers_ItemDoubleClick" />
<WrapPanel Grid.Row="1"
Grid.ColumnSpan="2"
Margin="0,16,0,0"
HorizontalAlignment="Right">
<Button Margin="0,0,8,0"
IsCancel="True"
Click="Cancel_Click">
Cancel
</Button>
<Button IsDefault="True"
Click="Connect_Clicked">
Connect
</Button>
</WrapPanel>
</Grid>
</Window>

View File

@ -0,0 +1,42 @@
using System;
using System.Windows;
namespace XIVChat_Desktop {
/// <summary>
/// Interaction logic for ConnectDialog.xaml
/// </summary>
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();
}
}
}

View File

@ -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<string> outgoing = Channel.CreateUnbounded<string>();
private readonly Channel<byte[]> incoming = Channel.CreateUnbounded<byte[]>();
private readonly Channel<byte> cancelChannel = Channel.CreateBounded<byte>(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<bool>(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);
}
}
}

View File

@ -0,0 +1,51 @@
<UserControl x:Class="XIVChat_Desktop.Controls.SavedServers"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:XIVChat_Desktop"
mc:Ignorable="d"
x:Name="SavedServersControl"
d:DesignHeight="450"
d:DesignWidth="800">
<Grid Margin="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ListView x:Name="Servers"
Grid.Column="0"
DataContext="{Binding ElementName=SavedServersControl}"
ItemsSource="{Binding ItemsSource}"
MouseDoubleClick="Item_MouseDoubleClick">
<ListView.View>
<GridView>
<GridViewColumn Header="Name"
DisplayMemberBinding="{Binding Name}" />
<GridViewColumn Header="Host"
DisplayMemberBinding="{Binding Host}" />
<GridViewColumn Header="Port"
DisplayMemberBinding="{Binding Port}" />
</GridView>
</ListView.View>
</ListView>
<WrapPanel Grid.Column="1"
Orientation="Vertical"
Margin="8,0,0,0">
<Button HorizontalAlignment="Stretch"
Margin="0,0,0,4"
Click="AddServer_Click">
Add
</Button>
<Button HorizontalAlignment="Stretch"
Margin="0,0,0,4"
Click="EditServer_Click">
Edit
</Button>
<Button HorizontalAlignment="Stretch"
Click="DeleteServer_Click">
Delete
</Button>
</WrapPanel>
</Grid>
</UserControl>

View File

@ -0,0 +1,73 @@
using System.Collections.Generic;
using System.Windows;
namespace XIVChat_Desktop.Controls {
/// <summary>
/// Interaction logic for SavedServers.xaml
/// </summary>
public partial class SavedServers {
public App App => (App)Application.Current;
private Configuration Config => this.App.Config;
private Window Window => Window.GetWindow(this)!;
public IEnumerable<SavedServer> ItemsSource {
get { return (IEnumerable<SavedServer>)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<SavedServer>),
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;
}
}

View File

@ -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;
}
}
}

347
XIVChat Desktop/Filters.cs Normal file
View File

@ -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<CategoryAttribute>(false);
public static string? Name(this FilterCategory category) => category.Info()?.Name;
public static IEnumerable<FilterType> 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<FilterAttribute>(false);
public static string? Name(this FilterType filter) => filter.Info()?.Name;
public static IEnumerable<ChatType> 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],
};
}
}

View File

@ -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}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Menu Grid.Row="1">
<Menu Grid.Row="0">
<MenuItem Header="XIVChat">
<MenuItem Header="Connect"/>
<MenuItem Header="Disonnect" IsEnabled="False"/>
<MenuItem Header="Connect"
Click="Connect_Click"
IsEnabled="{Binding App.Disconnected, UpdateSourceTrigger=PropertyChanged}" />
<MenuItem Header="Disconnect"
Click="Disconnect_Click"
IsEnabled="{Binding App.Connected, UpdateSourceTrigger=PropertyChanged}" />
<Separator />
<MenuItem Header="Configuration"
Click="Configuration_Click" />
</MenuItem>
<MenuItem Header="Tabs">
<MenuItem Header="Manage"
Click="ManageTabs_Click" />
</MenuItem>
</Menu>
<ListView Grid.Row="2"></ListView>
<TextBox Grid.Row="3" TextWrapping="Wrap"/>
<TabControl x:Name="Tabs"
Margin="8,0,8,8"
TabStripPlacement="Bottom"
Grid.Row="1"
Loaded="Tabs_Loaded"
SelectionChanged="Tabs_SelectionChanged"
ItemsSource="{Binding App.Config.Tabs}">
<TabControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}" />
</DataTemplate>
</TabControl.ItemTemplate>
<TabControl.ContentTemplate>
<DataTemplate>
<Grid d:DataContext="{d:DesignInstance local:Tab}">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ScrollViewer Grid.Row="0"
x:Name="scroller">
<ItemsControl Background="#333"
x:Name="items"
Padding="4"
ItemsSource="{Binding Messages, UpdateSourceTrigger=PropertyChanged}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel IsVirtualizing="True"
VirtualizationMode="Recycling"
VerticalAlignment="Bottom" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock FontFamily="Global User Interface, /fonts/#XIV AXIS Std ATK"
TextWrapping="Wrap"
FontSize="{Binding App.Config.FontSize, ElementName=Main, UpdateSourceTrigger=PropertyChanged}"
local:MessageFormatter.FormattedText="{Binding .}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<TextBlock Margin="8,4,0,0"
Grid.Row="1"
Text="{Binding App.Connection.CurrentChannel, ElementName=Main, UpdateSourceTrigger=PropertyChanged}" />
<TextBox ui:ControlHelper.PlaceholderText="Send a message..."
Grid.Row="2"
Margin="0,0,0,8"
TextWrapping="Wrap"
SpellCheck.IsEnabled="True"
KeyDown="Input_Submit" />
</Grid>
</DataTemplate>
</TabControl.ContentTemplate>
</TabControl>
<StatusBar Grid.Row="2">
<StatusBarItem x:Name="LoggedInAs"
Content="Not logged in" />
<Separator x:Name="LoggedInAsSeparator"
Visibility="Collapsed" />
<StatusBarItem x:Name="CurrentWorld"
Visibility="Collapsed" />
<Separator x:Name="CurrentWorldSeparator"
Visibility="Collapsed" />
<StatusBarItem x:Name="Location"
Visibility="Collapsed" />
</StatusBar>
</Grid>
</Window>
</Window>

View File

@ -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 {
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window {
public partial class MainWindow {
public App App => (App)Application.Current;
public ConcurrentStack<ServerMessage> Messages { get; } = new ConcurrentStack<ServerMessage>();
public MainWindow() {
InitializeComponent();
this.InitializeComponent();
this.DataContext = this;
}
private T? FindElementByName<T>(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<T>(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<Chunk> {
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<ScrollViewer>(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<ScrollViewer>(this.Tabs, "scroller");
scroller?.ScrollToBottom();
}
private void ManageTabs_Click(object sender, RoutedEventArgs e) {
new ManageTabs(this).Show();
}
}
}

View File

@ -0,0 +1,78 @@
<Window x:Class="XIVChat_Desktop.ManageServer"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:XIVChat_Desktop"
mc:Ignorable="d"
xmlns:ui="http://schemas.modernwpf.com/2019"
ui:WindowHelper.UseModernWindowStyle="True"
WindowStartupLocation="CenterOwner"
SizeToContent="WidthAndHeight"
ContentRendered="ManageServer_OnContentRendered"
Title="Manage server"
d:DataContext="{d:DesignInstance local:ManageServer}">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition MinWidth="200" />
</Grid.ColumnDefinitions>
<Label VerticalAlignment="Center"
Grid.Row="0"
Grid.Column="0">
Name
</Label>
<TextBox Margin="4,0,0,0"
Grid.Row="0"
Grid.Column="1"
x:Name="ServerName"
Text="{Binding Server.Name}" />
<Label VerticalAlignment="Center"
Grid.Row="1"
Grid.Column="0">
IP Address
</Label>
<TextBox Margin="4,4,0,0"
Grid.Row="1"
Grid.Column="1"
x:Name="ServerHost"
Text="{Binding Server.Host}" />
<Label VerticalAlignment="Center"
Grid.Row="2"
Grid.Column="0">
Port
</Label>
<TextBox Margin="4,4,0,0"
Grid.Row="2"
Grid.Column="1"
x:Name="ServerPort"
Text="{Binding Server.Port}"
ui:ControlHelper.PlaceholderText="14777" />
<WrapPanel Margin="0,8,0,0"
Grid.Row="3"
Grid.ColumnSpan="2"
Grid.Column="0"
HorizontalAlignment="Right">
<Button IsCancel="True"
Click="Cancel_Click">
Cancel
</Button>
<Button Margin="4,0,0,0"
IsDefault="True"
Click="Save_Click">
Save
</Button>
</WrapPanel>
</Grid>
</Window>

View File

@ -0,0 +1,64 @@
using System;
using System.Windows;
namespace XIVChat_Desktop {
/// <summary>
/// Interaction logic for ManageServer.xaml
/// </summary>
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();
}
}
}

View File

@ -0,0 +1,34 @@
<Window x:Class="XIVChat_Desktop.FiltersSelection"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
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"
WindowStartupLocation="CenterOwner"
mc:Ignorable="d"
Title="Manage tab"
Height="450"
Width="400"
d:DataContext="{d:DesignInstance local:FiltersSelection}">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBox Grid.Row="0"
Margin="0,0,0,8"
ui:ControlHelper.PlaceholderText="Name"
ui:ControlHelper.Header="Name"
Text="{Binding Tab.Name}" />
<TabControl Grid.Row="1"
x:Name="Tabs" />
<Button Margin="0,8,0,0"
Grid.Row="2"
Content="Save"
Click="Save_Click" />
</Grid>
</Window>

View File

@ -0,0 +1,116 @@
using System;
using System.Collections.Immutable;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
namespace XIVChat_Desktop {
/// <summary>
/// Interaction logic for FiltersSelection.xaml
/// </summary>
public partial class FiltersSelection {
public App App => (App)Application.Current;
public Tab Tab { get; }
private readonly bool isNewTab;
private readonly IImmutableSet<FilterType> 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();
}
}
}

View File

@ -0,0 +1,31 @@
<Window x:Class="XIVChat_Desktop.ManageTabs"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
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"
WindowStartupLocation="CenterOwner"
mc:Ignorable="d"
Title="Manage tabs" Height="250" Width="400"
d:DataContext="{d:DesignInstance local:ManageTabs}">
<Grid Margin="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<ListView x:Name="Tabs" Grid.Column="0" ItemsSource="{Binding App.Config.Tabs}" MouseDoubleClick="Tab_MouseDoubleClick">
<ListView.View>
<GridView>
<GridViewColumn Header="Name" DisplayMemberBinding="{Binding Name}"/>
</GridView>
</ListView.View>
</ListView>
<WrapPanel Grid.Column="1" Orientation="Vertical" Margin="8,0,0,0">
<Button HorizontalAlignment="Stretch" Margin="0,0,0,4" Click="AddTab_Click">Add</Button>
<Button HorizontalAlignment="Stretch" Margin="0,0,0,4" Click="EditTab_Click">Edit</Button>
<Button HorizontalAlignment="Stretch" Click="DeleteTab_Click">Delete</Button>
</WrapPanel>
</Grid>
</Window>

View File

@ -0,0 +1,59 @@
using System.Windows;
using System.Windows.Input;
namespace XIVChat_Desktop {
/// <summary>
/// Interaction logic for ManageTabs.xaml
/// </summary>
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();
}
}
}

View File

@ -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<Inline> ChunksToTextBlock(double lineHeight, ServerMessage message) {
var elements = new List<Inline>();
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,
};
}
}

View File

@ -0,0 +1,7 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.modernwpf.com/2019">
<ResourceDictionary.MergedDictionaries>
<ui:IntellisenseResources Source="/ModernWpf;component/DesignTime/DesignTimeResources.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>

View File

@ -0,0 +1,72 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 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.
// </auto-generated>
//------------------------------------------------------------------------------
namespace XIVChat_Desktop.Properties {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// 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() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[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;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized string similar to XIVChat for Windows.
/// </summary>
public static string AppName {
get {
return ResourceManager.GetString("AppName", resourceCulture);
}
}
}
}

View File

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="AppName" xml:space="preserve">
<value>XIVChat for Windows</value>
</data>
</root>

Binary file not shown.

View File

@ -0,0 +1,116 @@
<Window x:Class="XIVChat_Desktop.TrustDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
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"
SizeToContent="WidthAndHeight"
WindowStartupLocation="CenterOwner"
mc:Ignorable="d"
Title="Key verification">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBlock Grid.Column="0" Grid.Row="0">
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.
</TextBlock>
<Grid Grid.Column="0" Grid.Row="1">
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0">Server:</TextBlock>
<TextBlock x:Name="ServerPublicKey" Grid.Row="1" Grid.Column="0"/>
<Grid Grid.Row="2" Grid.Column="0" Height="24" x:Name="ServerPublicKeyColours">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Rectangle Grid.Column="0"></Rectangle>
<Rectangle Grid.Column="1"></Rectangle>
<Rectangle Grid.Column="2"></Rectangle>
<Rectangle Grid.Column="3"></Rectangle>
<Rectangle Grid.Column="4"></Rectangle>
<Rectangle Grid.Column="5"></Rectangle>
<Rectangle Grid.Column="6"></Rectangle>
<Rectangle Grid.Column="7"></Rectangle>
<Rectangle Grid.Column="8"></Rectangle>
<Rectangle Grid.Column="9"></Rectangle>
<Rectangle Grid.Column="10"></Rectangle>
</Grid>
</Grid>
<Grid Grid.Column="0" Grid.Row="2">
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0">Client:</TextBlock>
<TextBlock x:Name="ClientPublicKey" Grid.Row="1" Grid.Column="0"/>
<Grid Grid.Row="2" Grid.Column="0" Height="24" x:Name="ClientPublicKeyColours">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Rectangle Grid.Column="0"></Rectangle>
<Rectangle Grid.Column="1"></Rectangle>
<Rectangle Grid.Column="2"></Rectangle>
<Rectangle Grid.Column="3"></Rectangle>
<Rectangle Grid.Column="4"></Rectangle>
<Rectangle Grid.Column="5"></Rectangle>
<Rectangle Grid.Column="6"></Rectangle>
<Rectangle Grid.Column="7"></Rectangle>
<Rectangle Grid.Column="8"></Rectangle>
<Rectangle Grid.Column="9"></Rectangle>
<Rectangle Grid.Column="10"></Rectangle>
</Grid>
</Grid>
<WrapPanel Grid.Row="3" Grid.Column="0" Orientation="Vertical">
<TextBlock>Give the key a name to remember it.</TextBlock>
<TextBox x:Name="KeyName">No name</TextBox>
</WrapPanel>
<TextBlock Grid.Row="4" Grid.Column="0">Do both keys match?</TextBlock>
<WrapPanel Grid.Row="5" Grid.Column="0">
<Button Click="Yes_Click">Yes</Button>
<Button Click="No_Click">No</Button>
</WrapPanel>
</Grid>
</Window>

View File

@ -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 {
/// <summary>
/// Interaction logic for TrustDialog.xaml
/// </summary>
public partial class TrustDialog {
private readonly ChannelWriter<bool> trustChannel;
private readonly byte[] remoteKey;
private App App => (App)Application.Current;
public TrustDialog(Window owner, ChannelWriter<bool> 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<Color> BreakIntoColours(IEnumerable<byte> key) {
var colours = new List<Color>();
// 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<List<T>> SplitList<T>(List<T> 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<byte> 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();
}
}
}

View File

@ -1,10 +1,59 @@
<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
<RootNamespace>XIVChat_Desktop</RootNamespace>
<UseWPF>true</UseWPF>
</PropertyGroup>
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
<RootNamespace>XIVChat_Desktop</RootNamespace>
<UseWPF>true</UseWPF>
<AssemblyName>XIVChat Desktop</AssemblyName>
<Nullable>enable</Nullable>
<Company>XIVChat</Company>
<AssemblyVersion>1.0.0</AssemblyVersion>
<FileVersion>1.0.0</FileVersion>
</PropertyGroup>
<ItemGroup>
<None Remove="fonts\ffxiv.ttf"/>
<None Remove="ic_launcher-playstore.png"/>
<None Remove="Resources\fonticon_ps4.tex.png"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="ModernWpfUI" Version="0.9.2"/>
<PackageReference Include="Newtonsoft.Json" Version="12.0.3"/>
<PackageReference Include="Sodium.Core" Version="1.2.3"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\XIVChatCommon\XIVChatCommon.csproj"/>
</ItemGroup>
<ItemGroup>
<Resource Include="fonts\ffxiv.ttf"/>
<Resource Include="ic_launcher-playstore.png"/>
<Resource Include="Resources\fonticon_ps4.tex.png"/>
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<Page Update="Properties\DesignTimeResources.xaml">
<SubType>Designer</SubType>
<ContainsDesignTimeResources>true</ContainsDesignTimeResources>
</Page>
</ItemGroup>
</Project>

Binary file not shown.

Binary file not shown.

View File

@ -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