feat(desktop): add export feature

This commit is contained in:
Anna 2020-11-10 21:56:04 -05:00
parent a48beae786
commit 900fa642a0
16 changed files with 1357 additions and 21 deletions

View File

@ -8,6 +8,7 @@
<ResourceDictionary>
<local:DoubleConverter x:Key="DoubleConverter" />
<local:UShortConverter x:Key="UShortConverter" />
<local:SenderPlayerConverter x:Key="SenderPlayerConverter" />
<ResourceDictionary.MergedDictionaries>
<ui:ThemeResources AccentColor="#02ccee" />
@ -17,4 +18,4 @@
<!-- Other app resources here -->
</ResourceDictionary>
</Application.Resources>
</Application>
</Application>

View File

@ -139,6 +139,8 @@ namespace XIVChat_Desktop {
public Filter Filter { get; set; } = new Filter();
public bool ProcessMarkdown { get; set; }
[JsonIgnore]
public List<ServerMessage> Messages { get; } = new List<ServerMessage>();
@ -243,7 +245,7 @@ namespace XIVChat_Desktop {
public class Filter {
public HashSet<FilterType> Types { get; set; } = new HashSet<FilterType>();
public bool Allowed(ServerMessage message) {
public virtual bool Allowed(ServerMessage message) {
var code = new ChatCode((ushort)message.Channel);
return this.Types.Any(type => type.Allowed(code));
}

View File

@ -28,6 +28,18 @@ namespace XIVChat_Desktop.Controls {
set => this.SetValue(TabProperty, value);
}
public static readonly DependencyProperty ShowTimestampsProperty = DependencyProperty.Register(
"ShowTimestamps",
typeof(bool),
typeof(MessageTextBlock),
new PropertyMetadata(true, PropertyChanged)
);
public bool ShowTimestamps {
get => (bool)this.GetValue(ShowTimestampsProperty);
set => this.SetValue(ShowTimestampsProperty, value);
}
public static void PropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) {
// Clear current textBlock
if (!(d is MessageTextBlock textBlock)) {
@ -41,12 +53,12 @@ namespace XIVChat_Desktop.Controls {
return;
}
textBlock.ClearValue(TextBlock.TextProperty);
textBlock.ClearValue(TextProperty);
textBlock.Inlines.Clear();
// Create new formatted text
var lineHeight = textBlock.FontFamily.LineSpacing * textBlock.FontSize;
foreach (var inline in MessageFormatter.ChunksToTextBlock(lineHeight, message, tab)) {
foreach (var inline in MessageFormatter.ChunksToTextBlock(lineHeight, message, tab.ProcessMarkdown, textBlock.ShowTimestamps)) {
textBlock.Inlines.Add(inline);
}
}

View File

@ -1,6 +1,8 @@
using System;
using System.Globalization;
using System.Text;
using System.Windows.Data;
using XIVChatCommon;
namespace XIVChat_Desktop {
public class DoubleConverter : IValueConverter {
@ -30,4 +32,29 @@ namespace XIVChat_Desktop {
return null;
}
}
public class SenderPlayerConverter : IValueConverter {
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) {
if (!(value is ServerMessage.SenderPlayer sender)) {
return null;
}
var s = new StringBuilder();
s.Append(sender.Name);
var worldName = Util.WorldName(sender.Server);
if (worldName != null) {
s.Append(" (");
s.Append(worldName);
s.Append(")");
}
return s.ToString();
}
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) {
throw new NotImplementedException();
}
}
}

154
XIVChat Desktop/Export.xaml Normal file
View File

@ -0,0 +1,154 @@
<Window x:Class="XIVChat_Desktop.Export"
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"
xmlns:cc="clr-namespace:XIVChat_Desktop.Controls"
xmlns:xiv="clr-namespace:XIVChatCommon;assembly=XIVChatCommon"
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:sdl="http://schemas.sdl.com/xaml"
ui:WindowHelper.UseModernWindowStyle="True"
mc:Ignorable="d"
Title="Export"
x:Name="Main"
d:DataContext="{d:DesignInstance local:Export}"
Height="600"
Width="800">
<Grid Margin="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ItemsControl Grid.Row="0"
Grid.Column="0"
Padding="4"
VirtualizingPanel.ScrollUnit="Pixel"
ItemsSource="{Binding ExportTab}">
<ItemsControl.Template>
<ControlTemplate TargetType="ItemsControl">
<ScrollViewer x:Name="scroller"
CanContentScroll="True"
Background="#333">
<ItemsPresenter Margin="0,0,16,0" />
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel IsVirtualizing="True"
VirtualizationMode="Recycling"
VerticalAlignment="Bottom" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<cc:MessageTextBlock FontFamily="Global User Interface, /fonts/#XIV AXIS Std ATK"
TextWrapping="Wrap"
FontSize="{Binding App.Config.FontSize, ElementName=Main, UpdateSourceTrigger=PropertyChanged}"
Tab="{Binding ExportTab, ElementName=Main}"
Message="{Binding .}"
ShowTimestamps="{Binding ShowTimestamps, ElementName=Main}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid Grid.Row="0"
Grid.Column="1"
Margin="8, 0, 0, 0">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ScrollViewer Grid.Row="0"
Margin="8,0,0, 8">
<StackPanel Margin="0,0,24,0">
<CheckBox Content="Process messages as Markdown"
Checked="Markdown_Checked"
Unchecked="Markdown_Unchecked"
IsChecked="{Binding ExportTab.ProcessMarkdown}" />
<CheckBox Content="Show timestamps"
IsChecked="{Binding ShowTimestamps}" />
<DatePicker x:Name="AfterDatePicker"
ui:ControlHelper.Header="On or after"
SelectedDateChanged="AfterDatePicker_OnSelectedDateChanged" />
<ui:SimpleTimePicker x:Name="AfterTimePicker"
SelectedDateTimeChanged="AfterTimePicker_OnSelectedDateTimeChanged" />
<Button Margin="0,4,0,8"
Content="Clear"
Click="AfterClear_Click" />
<DatePicker x:Name="BeforeDatePicker"
ui:ControlHelper.Header="On or before"
SelectedDateChanged="BeforeDatePicker_OnSelectedDateChanged" />
<ui:SimpleTimePicker x:Name="BeforeTimePicker"
SelectedDateTimeChanged="BeforeTimePicker_OnSelectedDateTimeChanged" />
<Button Margin="0,4,0,8"
Content="Clear"
Click="BeforeClear_Click" />
<Button Margin="0,8,0,8"
Content="Configure senders">
<ui:FlyoutService.Flyout>
<ui:Flyout>
<Grid Height="300">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ListBox MinWidth="100"
ScrollViewer.VerticalScrollBarVisibility="Visible"
x:Name="SendersFilterSource"
ItemsSource="{Binding Senders, Mode=OneWay}">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Margin="0,0,16,0"
Text="{Binding ., Converter={StaticResource SenderPlayerConverter}}" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Column="1"
VerticalAlignment="Center"
Margin="8,0,8,0">
<Button Click="RightArrow_Click"
Content="→" />
<Button Click="LeftArrow_Click"
Margin="0,4,0,0"
Content="←" />
</StackPanel>
<ListBox Grid.Column="2"
MinWidth="100"
ScrollViewer.VerticalScrollBarVisibility="Visible"
x:Name="SenderFiltersDest"
ItemsSource="{Binding ExportTab.Filter.Senders, Mode=OneWay}">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Margin="0,0,16,0"
Text="{Binding ., Converter={StaticResource SenderPlayerConverter}}" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</ui:Flyout>
</ui:FlyoutService.Flyout>
</Button>
<TabControl x:Name="Tabs" />
</StackPanel>
</ScrollViewer>
<Button Grid.Row="1"
Content="Save"
Click="Save_Click"
HorizontalAlignment="Right" />
</Grid>
</Grid>
</Window>

View File

@ -0,0 +1,395 @@
using System;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Threading;
using Microsoft.Win32;
using XIVChatCommon;
namespace XIVChat_Desktop {
public partial class Export : INotifyPropertyChanged {
public App App => (App)Application.Current;
public Tab ExportTab { get; }
public ExportFilter Filter => (ExportFilter)this.ExportTab.Filter;
public ObservableCollection<ServerMessage.SenderPlayer> Senders { get; } = new ObservableCollection<ServerMessage.SenderPlayer>();
private bool showTimestamps = true;
public bool ShowTimestamps {
get => this.showTimestamps;
set {
this.showTimestamps = value;
this.OnPropertyChanged(nameof(this.ShowTimestamps));
}
}
public Export(Window owner) {
this.Owner = owner;
this.ExportTab = new Tab("Export") {
Filter = new ExportFilter {
Types = Tab.GeneralFilter().Types,
},
};
this.Repopulate();
this.InitializeComponent();
this.DataContext = this;
this.SetUpFilters();
}
private void SetUpFilters() {
foreach (var category in (FilterCategory[])Enum.GetValues(typeof(FilterCategory))) {
var tabContent = new WrapPanel {
Margin = new Thickness(8),
Orientation = Orientation.Vertical,
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);
var doingMultiple = false;
void SetAllChecked(bool isChecked) {
doingMultiple = true;
foreach (var child in tabContent.Children) {
if (!(child is CheckBox)) {
continue;
}
var check = (CheckBox)child;
check.IsChecked = isChecked;
}
this.Repopulate();
doingMultiple = false;
}
buttonsPanel.Children.Add(selectButton);
buttonsPanel.Children.Add(deselectButton);
tabContent.Children.Add(buttonsPanel);
tabContent.Children.Add(new Separator());
foreach (var type in category.Types()) {
var check = new CheckBox {
Content = type.Name(),
IsChecked = this.ExportTab.Filter.Types.Contains(type),
};
check.Checked += (sender, e) => {
this.ExportTab.Filter.Types.Add(type);
if (!doingMultiple) {
this.Repopulate();
}
};
check.Unchecked += (sender, e) => {
this.ExportTab.Filter.Types.Remove(type);
if (!doingMultiple) {
this.Repopulate();
}
};
tabContent.Children.Add(check);
}
var tabItem = new TabItem {
Header = new TextBlock(new Run(category.Name())),
Content = tabContent,
};
this.Tabs.Items.Add(tabItem);
}
}
private void Repopulate() {
this.ExportTab.RepopulateMessages(this.App.Window.Messages);
this.SetUpSenders();
}
private void SetUpSenders() {
// var senders = this.ExportTab.Messages
var senders = this.App.Window.Messages
.Where(msg => ((ExportFilter)this.ExportTab.Filter).AllowedMinusSenders(msg))
.Select(msg => msg.GetSenderPlayer())
.Where(sender => sender != null)
.ToImmutableSortedSet();
this.Senders.Clear();
foreach (var sender in senders) {
this.Senders.Add(sender!);
}
}
public class ExportFilter : Filter {
public ObservableCollection<ServerMessage.SenderPlayer> Senders { get; } = new ObservableCollection<ServerMessage.SenderPlayer>();
public DateTime? Before { get; set; }
public DateTime? After { get; set; }
public void AddSender(ServerMessage.SenderPlayer sender) {
if (this.Senders.Contains(sender)) {
return;
}
this.Senders.Add(sender);
}
public bool AllowedMinusSenders(ServerMessage message) {
if (!base.Allowed(message)) {
return false;
}
if (this.Before != null && message.Timestamp > this.Before) {
return false;
}
if (this.After != null && message.Timestamp < this.After) {
return false;
}
return true;
}
public override bool Allowed(ServerMessage message) {
if (!this.AllowedMinusSenders(message)) {
return false;
}
// check sender if any senders are selected
var sender = message.GetSenderPlayer();
if (this.Senders.Count != 0 && sender != null && !this.Senders.Contains(sender)) {
return false;
}
// our stuff
return true;
}
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged1([CallerMemberName] string? propertyName = null) {
this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
private void Markdown_Checked(object sender, RoutedEventArgs e) => this.SetMarkdownProcessing(true);
private void Markdown_Unchecked(object sender, RoutedEventArgs e) => this.SetMarkdownProcessing(false);
private void SetMarkdownProcessing(bool on) {
this.ExportTab.ProcessMarkdown = on;
this.Repopulate();
}
private void RightArrow_Click(object sender, RoutedEventArgs e) {
var idx = this.SendersFilterSource.SelectedIndex;
if (idx == -1) {
return;
}
var player = this.Senders[idx];
var filter = (ExportFilter)this.ExportTab.Filter;
filter.AddSender(player);
this.Repopulate();
}
private void LeftArrow_Click(object sender, RoutedEventArgs e) {
var idx = this.SenderFiltersDest.SelectedIndex;
if (idx == -1) {
return;
}
var filter = (ExportFilter)this.ExportTab.Filter;
var player = filter.Senders.ElementAt(idx);
filter.Senders.Remove(player);
this.Repopulate();
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) {
this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private async void Save_Click(object sender, RoutedEventArgs e) {
// create a new flowdocument for saving
var flow = new FlowDocument();
// turn every message in the tab into a paragraph in the flowdocument
foreach (var message in this.ExportTab.Messages) {
this.Dispatch(DispatcherPriority.Background, () => {
var paragraph = new Paragraph();
// this has to be done on the main thread
var inlines = MessageFormatter.ChunksToTextBlock(this.App.Config.FontSize, message, this.ExportTab.ProcessMarkdown, this.ShowTimestamps);
paragraph.Inlines.AddRange(inlines);
flow.Blocks.Add(paragraph);
});
}
// ask the user where to save
var saveDialog = new SaveFileDialog {
Filter = $"{DataFormats.Text} (*.txt)|*.txt|{DataFormats.Rtf} (*.rtf)|*.rtf",
};
if (saveDialog.ShowDialog(this) != true) {
return;
}
var ext = saveDialog.FileName.Split('.').Last().ToLowerInvariant();
string dataFormat = ext switch {
"rtf" => DataFormats.Rtf,
_ => DataFormats.Text,
};
// save the data into memory (this apparently has to happen on the main thread or we'd save directly into a
// file)
await using var memoryStream = new MemoryStream();
new TextRange(flow.ContentStart, flow.ContentEnd).Save(memoryStream, dataFormat);
// write the saved data to a file on another thread
await Task.Run(async () => {
await using var stream = new FileStream(saveDialog.FileName, FileMode.Create);
memoryStream.Position = 0;
await memoryStream.CopyToAsync(stream);
});
// show completion box
MessageBox.Show("Exported successfully.");
}
private bool ignoreDateChanges;
private void AfterDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) {
if (this.ignoreDateChanges) {
return;
}
var datePicker = (DatePicker)sender!;
this.Filter.After = UpdateDate(this.Filter.After?.ToLocalTime(), datePicker.SelectedDate)?.ToUniversalTime();
this.ignoreDateChanges = true;
this.AfterTimePicker.SelectedDateTime = this.Filter.After?.ToLocalTime();
this.ignoreDateChanges = false;
this.Repopulate();
}
private void AfterTimePicker_OnSelectedDateTimeChanged(object sender, RoutedPropertyChangedEventArgs<DateTime?> e) {
if (this.ignoreDateChanges) {
return;
}
this.Filter.After = UpdateTime(this.Filter.After?.ToLocalTime(), e.NewValue)?.ToUniversalTime();
this.AfterDatePicker.SelectedDate = this.Filter.After?.ToLocalTime();
}
private void BeforeDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) {
if (this.ignoreDateChanges) {
return;
}
var datePicker = (DatePicker)sender!;
this.Filter.Before = UpdateDate(this.Filter.Before?.ToLocalTime(), datePicker.SelectedDate)?.ToUniversalTime();
this.ignoreDateChanges = true;
this.BeforeTimePicker.SelectedDateTime = this.Filter.Before?.ToLocalTime();
this.ignoreDateChanges = false;
this.Repopulate();
}
private void BeforeTimePicker_OnSelectedDateTimeChanged(object sender, RoutedPropertyChangedEventArgs<DateTime?> e) {
if (this.ignoreDateChanges) {
return;
}
this.Filter.Before = UpdateTime(this.Filter.Before?.ToLocalTime(), e.NewValue)?.ToUniversalTime();
this.BeforeDatePicker.SelectedDate = this.Filter.Before?.ToLocalTime();
}
private static DateTime? UpdateTime(DateTime? dest, DateTime? source) {
switch (dest) {
case null when source == null:
return null;
case null:
return source;
}
if (source == null) {
return dest;
}
var newValue = source.Value;
return new DateTime(dest.Value.Year, dest.Value.Month, dest.Value.Day, newValue.Hour, newValue.Minute, newValue.Second);
}
private static DateTime? UpdateDate(DateTime? dest, DateTime? source) {
switch (dest) {
case null when source == null:
return null;
case null:
return source;
}
if (source == null) {
return dest;
}
var newValue = source.Value;
return new DateTime(newValue.Year, newValue.Month, newValue.Day, dest.Value.Hour, dest.Value.Minute, dest.Value.Second);
}
private void BeforeClear_Click(object sender, RoutedEventArgs e) {
this.ignoreDateChanges = true;
this.BeforeDatePicker.SelectedDate = null;
this.ignoreDateChanges = false;
this.BeforeTimePicker.SelectedDateTime = null;
}
private void AfterClear_Click(object sender, RoutedEventArgs e) {
this.ignoreDateChanges = true;
this.AfterDatePicker.SelectedDate = null;
this.ignoreDateChanges = false;
this.AfterTimePicker.SelectedDateTime = null;
}
}
}

View File

@ -31,6 +31,9 @@
<MenuItem Header="Scan"
Click="Scan_Click" />
<Separator />
<MenuItem Header="Export"
Click="Export_Click" />
<Separator />
<MenuItem Header="Configuration"
Click="Configuration_Click" />
</MenuItem>

View File

@ -171,5 +171,9 @@ namespace XIVChat_Desktop {
internal virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) {
this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private void Export_Click(object sender, RoutedEventArgs e) {
new Export(this).Show();
}
}
}

View File

@ -1,4 +1,4 @@
<Window x:Class="XIVChat_Desktop.FiltersSelection"
<Window x:Class="XIVChat_Desktop.ManageTab"
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"
@ -11,7 +11,7 @@
Title="Manage tab"
Height="450"
Width="400"
d:DataContext="{d:DesignInstance local:FiltersSelection}">
d:DataContext="{d:DesignInstance local:ManageTab}">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />

View File

@ -8,7 +8,7 @@ namespace XIVChat_Desktop {
/// <summary>
/// Interaction logic for FiltersSelection.xaml
/// </summary>
public partial class FiltersSelection {
public partial class ManageTab {
public App App => (App)Application.Current;
public Tab Tab { get; }
@ -16,7 +16,7 @@ namespace XIVChat_Desktop {
private readonly bool isNewTab;
private readonly IImmutableSet<FilterType> oldFilters;
public FiltersSelection(Window owner, Tab? tab) {
public ManageTab(Window owner, Tab? tab) {
this.Owner = owner;
this.isNewTab = tab == null;
this.Tab = tab ?? new Tab("") {
@ -100,7 +100,7 @@ namespace XIVChat_Desktop {
MessageBox.Show("Tab must have a name.");
return;
}
if (this.isNewTab) {
this.App.Config.Tabs.Add(this.Tab);
}

View File

@ -23,7 +23,7 @@ namespace XIVChat_Desktop {
}
private void AddTab_Click(object sender, RoutedEventArgs e) {
new FiltersSelection(this, null).ShowDialog();
new ManageTab(this, null).ShowDialog();
}
private void EditTab_Click(object sender, RoutedEventArgs e) {
@ -31,7 +31,7 @@ namespace XIVChat_Desktop {
if (tab == null) {
return;
}
new FiltersSelection(this, tab).ShowDialog();
new ManageTab(this, tab).ShowDialog();
}
private void DeleteTab_Click(object sender, RoutedEventArgs e) {
@ -53,7 +53,7 @@ namespace XIVChat_Desktop {
var tab = item as Tab;
new FiltersSelection(this, tab).ShowDialog();
new ManageTab(this, tab).ShowDialog();
}
}
}

View File

@ -45,13 +45,15 @@ namespace XIVChat_Desktop {
// }
// }
public static IEnumerable<Inline> ChunksToTextBlock(double lineHeight, ServerMessage message, Tab tab) {
public static IEnumerable<Inline> ChunksToTextBlock(double lineHeight, ServerMessage message, bool processMarkdown, bool showTimestamp) {
var elements = new List<Inline>();
var timestampString = message.Timestamp.ToLocalTime().ToString("t", CultureInfo.CurrentUICulture);
elements.Add(new Run($"[{timestampString}]") {
Foreground = new SolidColorBrush(Colors.White),
});
if (showTimestamp) {
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) {
@ -66,7 +68,7 @@ namespace XIVChat_Desktop {
var brush = new SolidColorBrush(Color.FromArgb(a, r, g, b));
var style = textChunk.Italic ? FontStyles.Italic : FontStyles.Normal;
if (tab.ProcessMarkdown) {
if (processMarkdown) {
var inlines = Markdown.MarkdownToInlines(textChunk.Content);
foreach (var inline in inlines) {

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Windows.Threading;
using Windows.UI.Xaml.Data;
namespace XIVChat_Desktop {
public static class Util {
@ -65,5 +66,613 @@ namespace XIVChat_Desktop {
// A whitespace character is a space(U + 0020), tab(U + 0009), newline(U + 000A), line tabulation (U + 000B), form feed (U + 000C), or carriage return (U + 000D).
return c <= ' ' && (c == ' ' || c == '\t' || c == '\n' || c == '\v' || c == '\f' || c == '\r');
}
public static string? WorldName(ushort id) {
return id switch {
1 => "reserved1",
2 => "c-contents",
3 => "c-whiteae",
4 => "c-baudinii",
5 => "c-contents2",
6 => "c-funereus",
16 => "konconv",
23 => "Asura",
24 => "Belias",
25 => "Chaos",
26 => "Hecatoncheir",
27 => "Moomba",
28 => "Pandaemonium",
29 => "Shinryu",
30 => "Unicorn",
31 => "Yojimbo",
32 => "Zeromus",
33 => "Twintania",
34 => "Brynhildr",
35 => "Famfrit",
36 => "Lich",
37 => "Mateus",
38 => "Shemhazai",
39 => "Omega",
40 => "Jenova",
41 => "Zalera",
42 => "Zodiark",
43 => "Alexander",
44 => "Anima",
45 => "Carbuncle",
46 => "Fenrir",
47 => "Hades",
48 => "Ixion",
49 => "Kujata",
50 => "Typhon",
51 => "Ultima",
52 => "Valefor",
53 => "Exodus",
54 => "Faerie",
55 => "Lamia",
56 => "Phoenix",
57 => "Siren",
58 => "Garuda",
59 => "Ifrit",
60 => "Ramuh",
61 => "Titan",
62 => "Diabolos",
63 => "Gilgamesh",
64 => "Leviathan",
65 => "Midgardsormr",
66 => "Odin",
67 => "Shiva",
68 => "Atomos",
69 => "Bahamut",
70 => "Chocobo",
71 => "Moogle",
72 => "Tonberry",
73 => "Adamantoise",
74 => "Coeurl",
75 => "Malboro",
76 => "Tiamat",
77 => "Ultros",
78 => "Behemoth",
79 => "Cactuar",
80 => "Cerberus",
81 => "Goblin",
82 => "Mandragora",
83 => "Louisoix",
84 => "Syldra",
85 => "Spriggan",
90 => "Aegis",
91 => "Balmung",
92 => "Durandal",
93 => "Excalibur",
94 => "Gungnir",
95 => "Hyperion",
96 => "Masamune",
97 => "Ragnarok",
98 => "Ridill",
99 => "Sargatanas",
100 => "dev_test",
101 => "zone_test",
102 => "trs_test",
103 => "contents_test",
110 => "b-tirica",
111 => "b-contents",
112 => "b-chiriri",
113 => "b-contents2",
114 => "b-jugularis",
115 => "e-regia",
116 => "e-pialii",
117 => "e-contents",
118 => "e-contents2",
119 => "e-coloria",
120 => "e-gouldiae",
121 => "e-contents3",
123 => "e-trichroa",
124 => "e-hyperion",
126 => "e-ragnarok",
127 => "e-ridill",
128 => "e-sargatanas",
129 => "a-militaris",
130 => "a-aegis",
131 => "a-balmung",
132 => "a-durandal",
133 => "a-excalibur",
134 => "a-gungnir",
135 => "a-hyperion",
136 => "a-masamune",
137 => "a-ragnarok",
138 => "a-ridill",
139 => "a-sargatanas",
140 => "a-contents",
141 => "b-contents3",
142 => "b-cyanoptera",
144 => "a-contentsx",
145 => "a-contents2",
156 => "z-japonicus",
157 => "z-dauma",
158 => "z-contents",
159 => "z-contents2",
160 => "s-bella",
161 => "Chocobo",
162 => "s-contents",
163 => "chs-contents",
164 => "s-contents2",
165 => "s-guttata",
166 => "Moogle",
167 => "chs-contents2",
168 => "Namazu",
169 => "chs-contents3",
170 => "s-poliogenys",
171 => "s-haematina",
172 => "s-contents3",
173 => "s-contents4",
174 => "s-public1",
175 => "s-public2",
176 => "s-public3",
177 => "s-public4",
178 => "s-public5",
179 => "s-public6",
180 => "s-public7",
181 => "s-public8",
182 => "s-macropus",
183 => "s-contents7",
184 => "s-latrans",
185 => "s-contents8",
186 => "s-cucullatus",
187 => "s-bicolor",
188 => "s-contents5",
189 => "s-contents6",
201 => "Nacontents01",
202 => "Nacontents02",
203 => "Nacontents03",
204 => "Nacontents04",
205 => "Nacontents05",
206 => "Nacontents06",
207 => "Nacontents07",
208 => "Nacontents08",
209 => "Nacontents09",
210 => "Nacontents10",
211 => "Nacontents11",
212 => "Nacontents12",
213 => "Nacontents13",
214 => "Nacontents14",
215 => "Nacontents15",
216 => "Nacontents16",
217 => "Nacontents17",
218 => "Nacontents18",
219 => "Nacontents19",
220 => "Nacontents20",
221 => "Nacontents21",
222 => "Nacontents22",
223 => "Nacontents23",
224 => "Nacontents24",
225 => "Jpcontents01",
226 => "Jpcontents02",
227 => "Jpcontents03",
228 => "Jpcontents04",
229 => "Jpcontents05",
230 => "Jpcontents06",
231 => "Jpcontents07",
232 => "Jpcontents08",
233 => "Jpcontents09",
234 => "Jpcontents10",
235 => "Jpcontents11",
236 => "Jpcontents12",
237 => "Jpcontents13",
238 => "Jpcontents14",
239 => "Jpcontents15",
240 => "Jpcontents16",
241 => "Jpcontents17",
242 => "Jpcontents18",
243 => "Jpcontents19",
244 => "Jpcontents20",
245 => "Jpcontents21",
246 => "Jpcontents22",
247 => "Jpcontents23",
248 => "Jpcontents24",
249 => "Jpcontents25",
250 => "Jpcontents26",
251 => "Jpcontents27",
252 => "Jpcontents28",
253 => "Jpcontents29",
254 => "Jpcontents30",
255 => "Nacontents25",
256 => "Nacontents26",
257 => "Nacontents27",
258 => "Nacontents28",
259 => "Nacontents29",
260 => "Nacontents30",
261 => "Nacontents31",
262 => "Nacontents32",
263 => "Nacontents33",
264 => "Nacontents34",
265 => "Jpcontents31",
266 => "Jpcontents32",
267 => "Jpcontents33",
268 => "Jpcontents34",
269 => "Jpcontents35",
270 => "Jpcontents36",
271 => "Nacontents36",
272 => "Nacontents37",
273 => "Nacontents38",
274 => "Nacontents39",
275 => "Nacontents40",
276 => "Nacontents41",
277 => "Nacontents42",
278 => "Nacontents43",
279 => "Nacontents44",
280 => "Nacontents45",
576 => "h-collaris",
577 => "h-metallica",
578 => "h-contents",
579 => "h-contents2",
580 => "h-cauta",
581 => "h-contents3",
582 => "h-picatus",
583 => "h-contents4",
584 => "h-sordidus",
585 => "h-canente",
586 => "h-contents5",
630 => "a-contents3",
631 => "a-contents4",
632 => "a-contents5",
633 => "a-contents6",
634 => "a-contents7",
635 => "a-contents8",
636 => "a-contents9",
637 => "a-contents10",
638 => "a-contents20",
640 => "a-public1",
641 => "a-public2",
642 => "a-public3",
643 => "a-public4",
644 => "a-public5",
645 => "a-public6",
646 => "a-public7",
647 => "a-public8",
648 => "a-public9",
649 => "a-public10",
650 => "a-public11",
651 => "a-public12",
652 => "a-public13",
653 => "a-public14",
654 => "a-public15",
655 => "a-public16",
656 => "a-public17",
657 => "a-public18",
1024 => "test1",
1025 => "test2",
1040 => "DiPingGuan",
1041 => "MiWuShiDi",
1042 => "LaNuoXiYa",
1043 => "ZiShuiZhanQiao",
1044 => "HuanYingQunDao",
1045 => "MoDuNa",
1046 => "MoShouLingYu",
1047 => "FengMoDong",
1048 => "TaiyangHaiAn",
1049 => "XiaoMaiJiuGang",
1050 => "YinLeiHu",
1051 => "ShengXiaTan",
1052 => "PuTaoJiuGang",
1053 => "HeiYiSenLin",
1054 => "QingLinQuan",
1055 => "JinChuiTaiDi",
1056 => "HongChaChuan",
1057 => "YiXiuJiaDe",
1058 => "XueSongYuan",
1059 => "YaoJingLing",
1060 => "MengYaChi",
1061 => "ZhiZhangXiaGu",
1062 => "MiYueZhiTa",
1063 => "MoLaBiWan",
1064 => "YueYaWan",
1065 => "YaoLanShu",
1066 => "QiaoMingDong",
1067 => "NiMuHe",
1068 => "HuangJinGu",
1069 => "BaiLingTi",
1070 => "TianLangXingDengTa",
1071 => "ZhuoReZouLang",
1072 => "SiYuJuMu",
1073 => "YueYingDao",
1074 => "ShuiJingTa",
1075 => "MengXiangGong",
1076 => "BaiJinHuanXiang",
1077 => "HeiJinHu",
1078 => "LongXiPuBu",
1079 => "ShiWeiTa",
1080 => "TongLingTongShan",
1081 => "ShenYiZhiDi",
1082 => "ShiJiuDaQiao",
1083 => "YongHengChuan",
1084 => "HaiWuShaTan",
1085 => "HeFengLiuDi",
1086 => "ZeMeiErYaoSai",
1087 => "JuShiQiu",
1088 => "JianDouLingYu",
1089 => "HeiChenYiZhan",
1090 => "ShuiLianYan",
1091 => "LingHangMingDeng",
1092 => "HaiCiShiKu",
1093 => "FeiCuiHu",
1094 => "XiongXinGuangChang",
1095 => "KuErZhaSi",
1096 => "LiuShaMiGong",
1097 => "FangXiangTang",
1098 => "HuaMiZhanQiao",
1099 => "LanWuYongQuan",
1100 => "ShenLingShengYu",
1101 => "BaiYunYa",
1102 => "HaiJiangShuiQu",
1103 => "YuanLingYouShu",
1104 => "ShaZhongLuTing",
1105 => "JieMeiQiu",
1106 => "JingYuZhuangYuan",
1107 => "ZuJiGu",
1108 => "ShanHuTa",
1109 => "HengDuanYa",
1110 => "ShuiShangTingYuan",
1111 => "WuXianHuiLang",
1112 => "JinDingChi",
1113 => "LvRenZhanQiao",
1114 => "LongWenYan",
1115 => "HaiManQiaoLang",
1116 => "YuanQuanZhiTi",
1117 => "MiShiTa",
1118 => "RiShengMen",
1119 => "XiFengJia",
1120 => "ShenLiZhiMen",
1121 => "FuXiaoZhiJian",
1122 => "HaiLangDong",
1123 => "XiangShuYuan",
1124 => "MoNvKaFeiGuan",
1125 => "JuLongShouYingDi",
1126 => "DiYuHeGu",
1127 => "FuRongYuanZhuo",
1128 => "ShenWoJiao",
1129 => "HuangJinGuangChang",
1130 => "WanZhiMuChang",
1131 => "QiMuQuan",
1132 => "JingChiZhanQiao",
1133 => "BaiOuTA",
1134 => "XiaoShiWangDu",
1135 => "KuaTianQiao",
1136 => "ShengRenLei",
1137 => "JianFeng",
1138 => "HouWeiTa",
1139 => "BaiYinJiShi",
1140 => "LaiShengHuiLang",
1141 => "BaoFengLuMen",
1142 => "YouLingHu",
1143 => "ShiLvHu",
1144 => "HuangHunWan",
1145 => "XiaoALaMiGe",
1146 => "FangLangShenLiTang",
1147 => "JingJiSen",
1148 => "LangYanQiu",
1149 => "ShengRenLvDao",
1150 => "BuHuiZhanQuan",
1151 => "JiuTeng",
1152 => "RongYaoXi",
1153 => "JingShu",
1154 => "YuMenYiXue",
1155 => "YiWangLvZhou",
1156 => "YanDiLing",
1157 => "GeYongLieGu",
1158 => "LiuShaWu",
1159 => "BaJianShiQianTing",
1160 => "Bahamute",
1161 => "Zhushenhuanghun",
1162 => "Wangzhezhijian",
1163 => "Luxingniao",
1164 => "Shenshengcaipansuo",
1165 => "Bingtiangong",
1166 => "Longchaoshendian",
1167 => "HongYuHai",
1168 => "HuangJinGang",
1169 => "YanXia",
1170 => "ChaoFengTing",
1171 => "ShenQuanHen",
1172 => "BaiYinXiang",
1173 => "YuZhouHeYin",
1174 => "WoXianXiRan",
1175 => "ChenXiWangZuo",
1176 => "MengYuBaoJing",
1177 => "HaiMaoChaWu",
1178 => "RouFengHaiWan",
1179 => "HuPoYuan",
1536 => "contentstest1",
1537 => "contentstest2",
1552 => "sdocontents1",
1553 => "sdocontents2",
1554 => "sdocontents3",
1555 => "sdocontents4",
1556 => "sdocontents5",
1557 => "sdocontents6",
1558 => "sdocontents7",
1559 => "sdocontents8",
1560 => "sdocontents9",
1561 => "sdocontents10",
1562 => "sdocontents11",
1563 => "sdocontents12",
1564 => "sdocontents13",
1565 => "sdocontents14",
1566 => "sdocontents15",
1567 => "sdocontents16",
1568 => "sdocontents17",
1569 => "sdocontents18",
1570 => "sdocontents19",
1571 => "sdocontents20",
1572 => "sdocontents21",
1573 => "sdocontents22",
1574 => "sdocontents23",
1575 => "sdocontents24",
1576 => "sdocontents25",
1577 => "sdocontents26",
1578 => "sdocontents27",
1579 => "sdocontents28",
1580 => "sdocontents29",
1581 => "sdocontents30",
1582 => "sdocontents31",
1583 => "sdocontents32",
1584 => "sdocontents33",
1585 => "sdocontents34",
1586 => "sdocontents35",
1587 => "sdocontents36",
1588 => "sdocontents37",
1589 => "sdocontents38",
1590 => "sdocontents39",
1591 => "sdocontents40",
1592 => "sdocontents41",
1593 => "sdocontents42",
1594 => "sdocontents43",
1595 => "sdocontents44",
1596 => "sdocontents45",
1597 => "sdocontents46",
1598 => "sdocontents47",
1599 => "sdocontents48",
1600 => "sdocontents49",
1601 => "sdocontents50",
1602 => "sdocontents51",
1603 => "sdocontents52",
1604 => "sdocontents53",
1605 => "sdocontents54",
1606 => "sdocontents55",
1607 => "sdocontents56",
1608 => "sdocontents57",
1609 => "sdocontents58",
1610 => "sdocontents59",
1611 => "sdocontents60",
1612 => "sdocontents61",
1613 => "sdocontents62",
1614 => "sdocontents63",
1615 => "sdocontents64",
1616 => "sdocontents65",
1617 => "sdocontents66",
1618 => "sdocontents67",
1619 => "sdocontents68",
1620 => "sdocontents69",
1621 => "sdocontents70",
1622 => "sdocontents71",
1623 => "sdocontents72",
1624 => "sdocontents73",
1625 => "sdocontents74",
1626 => "sdocontents75",
1627 => "sdocontents76",
1628 => "sdocontents77",
1629 => "sdocontents78",
1630 => "sdocontents79",
1631 => "sdocontents80",
1632 => "sdocontents81",
1633 => "sdocontents82",
1634 => "sdocontents83",
1635 => "sdocontents84",
1636 => "sdocontents85",
1637 => "sdocontents86",
1638 => "sdocontents87",
1639 => "sdocontents88",
1640 => "sdocontents89",
1641 => "sdocontents90",
1642 => "sdocontents91",
1643 => "sdocontents92",
1644 => "sdocontents93",
1645 => "sdocontents94",
1646 => "sdocontents95",
1647 => "sdocontents96",
1648 => "sdocontents97",
1649 => "sdocontents98",
1650 => "sdocontents99",
1651 => "sdocontents100",
1652 => "sdocontents101",
1653 => "sdocontents102",
1654 => "sdocontents103",
1655 => "sdocontents104",
1656 => "sdocontents105",
1657 => "sdocontents106",
1658 => "sdocontents107",
1659 => "sdocontents108",
1660 => "sdocontents109",
1661 => "sdocontents110",
1662 => "sdocontents111",
1663 => "sdocontents112",
1664 => "sdocontents113",
1665 => "sdocontents114",
1666 => "sdocontents115",
1667 => "sdocontents116",
1668 => "sdocontents117",
1669 => "sdocontents118",
1670 => "sdocontents119",
1671 => "sdocontents120",
2048 => "Unei",
2049 => "Doga",
2050 => "KrBahamut",
2051 => "KrIfrit",
2052 => "KrGaruda",
2053 => "KrRamuh",
2054 => "KrOdin",
2055 => "KrUltima",
2056 => "KrMandragora",
2057 => "KrTonberry2",
2058 => "KrExcalibur",
2059 => "KrPhoenix",
2060 => "KrAlexander",
2061 => "KrTitan",
2062 => "KrLeviathan",
2063 => "KrShiva",
2064 => "KrBehemoth",
2065 => "KrGilgamesh",
2066 => "KrSabotender",
2067 => "KrUnicorn",
2068 => "KrRagnarok",
2069 => "KrRamia",
2070 => "KrNewPublic1",
2071 => "KrNewPublic2",
2072 => "KrNewPublic3",
2073 => "KrNewPublic4",
2074 => "KrNewPublic5",
2075 => "KrCarbuncle",
2076 => "KrChocobo",
2077 => "KrMoogle",
2078 => "KrTonberry",
2079 => "KrCaitsith",
2560 => "koreacontents1",
2561 => "koreacontents2",
2562 => "krcontents1",
2563 => "krcontents2",
2564 => "krcontents3",
2565 => "krcontents4",
2566 => "krcontents5",
2567 => "krcontents6",
2568 => "krcontents7",
2569 => "krcontents8",
2570 => "krcontents9",
2571 => "krcontents10",
2572 => "krcontents11",
2573 => "krcontents12",
2574 => "krcontents13",
2575 => "krcontents14",
2576 => "krcontents15",
2577 => "krcontents16",
2578 => "krcontents17",
2579 => "krcontents18",
2580 => "krcontents19",
2581 => "krcontents20",
2582 => "krcontents21",
2583 => "krcontents22",
10000 => "crossworld_range",
10001 => "crossworld",
10002 => "crossworld",
10003 => "crossworld",
10004 => "crossworld",
10005 => "crossworld",
10006 => "crossworld",
10007 => "crossworld",
10008 => "crossworld",
10009 => "crossworld",
10010 => "crossworld",
65534 => "outofrange1",
65535 => "outofrange2",
_ => null,
};
}
}
}

View File

@ -20,6 +20,7 @@
<ItemGroup>
<PackageReference Include="ModernWpfUI" Version="0.9.2" />
<PackageReference Include="ModernWpfUI.MahApps" Version="0.9.2" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="Sodium.Core" Version="1.2.3" />
</ItemGroup>

View File

@ -2,7 +2,10 @@
using MessagePack.Formatters;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
// ReSharper disable UnusedAutoPropertyAccessor.Global
// ReSharper disable UnusedMember.Global
@ -54,6 +57,124 @@ namespace XIVChatCommon {
protected override byte[] PayloadEncode() {
return MessagePackSerializer.Serialize(this);
}
public SenderPlayer? GetSenderPlayer() {
using var stream = new MemoryStream(this.Sender);
using var reader = new BinaryReader(stream);
var text = new List<byte>();
while (reader.BaseStream.Position < reader.BaseStream.Length) {
var b = reader.ReadByte();
// read payloads
if (b == 2) {
var chunkType = reader.ReadByte();
var chunkLen = XivString.GetInteger(reader);
// interactive
if (chunkType == 0x27) {
var subType = reader.ReadByte();
// player name
if (subType == 0x01) {
// unk
reader.ReadByte();
var serverId = (ushort)XivString.GetInteger(reader);
// unk
reader.ReadBytes(2);
var nameLen = (int)XivString.GetInteger(reader);
var playerName = Encoding.UTF8.GetString(reader.ReadBytes(nameLen));
return new SenderPlayer(playerName, serverId);
}
}
reader.ReadBytes((int)chunkLen);
continue;
}
// read text
text.Add(b);
}
if (text.Count == 0) {
return null;
}
var name = Encoding.UTF8.GetString(text.ToArray());
// remove the party position if present
var chars = name.ToCharArray();
if (chars.Length > 0 && PartyChars.Contains((chars[0]))) {
name = name.Substring(1);
}
return new SenderPlayer(name, 0);
}
private static readonly char[] PartyChars = {
'\ue090', '\ue091', '\ue092', '\ue093', '\ue094', '\ue095', '\ue096', '\ue097',
};
public class SenderPlayer : IComparable<SenderPlayer>, IComparable {
public string Name { get; }
public ushort Server { get; }
public SenderPlayer(string name, ushort server) {
this.Name = name;
this.Server = server;
}
protected bool Equals(SenderPlayer other) {
return this.Name == other.Name && this.Server == other.Server;
}
public override bool Equals(object? obj) {
if (ReferenceEquals(null, obj)) {
return false;
}
if (ReferenceEquals(this, obj)) {
return true;
}
return obj.GetType() == this.GetType() && this.Equals((SenderPlayer)obj);
}
public override int GetHashCode() {
unchecked {
return (this.Name.GetHashCode() * 397) ^ (int)this.Server;
}
}
public int CompareTo(SenderPlayer? other) {
if (ReferenceEquals(this, other)) {
return 0;
}
if (ReferenceEquals(null, other)) {
return 1;
}
var nameComparison = string.Compare(this.Name, other.Name, StringComparison.Ordinal);
return nameComparison != 0 ? nameComparison : this.Server.CompareTo(other.Server);
}
public int CompareTo(object? obj) {
if (ReferenceEquals(null, obj)) {
return 1;
}
if (ReferenceEquals(this, obj)) {
return 0;
}
return obj is SenderPlayer other ? this.CompareTo(other) : throw new ArgumentException($"Object must be of type {nameof(SenderPlayer)}");
}
}
}
[Union(1, typeof(TextChunk))]
@ -528,6 +649,7 @@ namespace XIVChatCommon {
CustomEmote = 28,
StandardEmote = 29,
Yell = 30,
// 31 - also party?
CrossParty = 32,
PvpTeam = 36,
@ -630,10 +752,12 @@ namespace XIVChatCommon {
/// Sent in response to a client ping. Has no payload.
/// </summary>
Pong = 1,
/// <summary>
/// A message was sent in game and is being relayed to the client.
/// </summary>
Message = 2,
/// <summary>
/// The server is shutting down. Clients should send no response and close their sockets. Has no payload.
/// </summary>
@ -747,6 +871,7 @@ namespace XIVChatCommon {
CrossLinkshell6 = 14,
CrossLinkshell7 = 15,
CrossLinkshell8 = 16,
// 17 - unused?
// 18 - unused?
Linkshell1 = 19,
@ -971,8 +1096,7 @@ namespace XIVChatCommon {
}
public enum ClientPreference {
[Preference(typeof(bool))]
BacklogNewestMessagesFirst,
[Preference(typeof(bool))] BacklogNewestMessagesFirst,
}
public class PreferenceAttribute : Attribute {
@ -1030,10 +1154,12 @@ namespace XIVChatCommon {
/// The client is sending data to the server to keep the socket alive. Has no payload.
/// </summary>
Ping = 1,
/// <summary>
/// The client has a message to be sent in the game and is relaying it to the server.
/// </summary>
Message = 2,
/// <summary>
/// The client is shutting down. Clients should send this and close their socket for a clean shutdown.
/// </summary>

View File

@ -121,7 +121,7 @@ namespace XIVChatCommon {
Int32 = 0xFE,
}
private static uint GetInteger(BinaryReader input) {
public static uint GetInteger(BinaryReader input) {
var t = input.ReadByte();
var type = (IntegerType)t;
return GetInteger(input, type);