mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-08-19 13:23:25 +08:00
feat.RSS
This commit is contained in:
318
LanMountainDesktop.AirAppHost/RssReaderAirAppView.axaml.cs
Normal file
318
LanMountainDesktop.AirAppHost/RssReaderAirAppView.axaml.cs
Normal file
@@ -0,0 +1,318 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.RssReader;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
|
||||
namespace LanMountainDesktop.AirAppHost;
|
||||
|
||||
public sealed partial class RssReaderAirAppView : UserControl, IDisposable
|
||||
{
|
||||
private readonly AirAppLaunchOptions _options;
|
||||
private readonly RssReaderService _service = new();
|
||||
private readonly LocalizationService _localization = new();
|
||||
private readonly string _languageCode;
|
||||
private string? _sourceId;
|
||||
private bool _unreadOnly;
|
||||
private bool _favoritesOnly;
|
||||
private RssEntry? _selectedEntry;
|
||||
private string? _lastHandledTargetEntryId;
|
||||
private readonly DispatcherTimer _refreshTimer = new();
|
||||
private NativeWebView? _articleWebView;
|
||||
|
||||
public RssReaderAirAppView() : this(AirAppLaunchOptions.Parse([])) { }
|
||||
|
||||
public RssReaderAirAppView(AirAppLaunchOptions options)
|
||||
{
|
||||
_options = options;
|
||||
try
|
||||
{
|
||||
_languageCode = _localization.NormalizeLanguageCode(new AppSettingsService().Load().LanguageCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_languageCode = "zh-CN";
|
||||
}
|
||||
InitializeComponent();
|
||||
ApplyLocalization();
|
||||
AddButton.Click += OnAdd;
|
||||
RefreshButton.Click += OnRefresh;
|
||||
AllButton.Click += (_, _) => SetFilter(null, false, false, L("rss.all_articles", "All articles"));
|
||||
UnreadButton.Click += (_, _) => SetFilter(null, true, false, L("rss.unread", "Unread"));
|
||||
FavoritesButton.Click += (_, _) => SetFilter(null, false, true, L("rss.favorites", "Favorites"));
|
||||
SourcesList.SelectionChanged += OnSourceSelected;
|
||||
EditSourceButton.Click += OnEditSource;
|
||||
DeleteSourceButton.Click += OnDeleteSource;
|
||||
EntriesList.SelectionChanged += OnEntrySelected;
|
||||
MarkAllReadButton.Click += OnMarkAllRead;
|
||||
FavoriteButton.Click += OnFavorite;
|
||||
OpenOriginalButton.Click += OnOpenOriginal;
|
||||
ImportButton.Click += OnImport;
|
||||
ExportButton.Click += OnExport;
|
||||
SettingsButton.Click += OnSettings;
|
||||
_service.Changed += OnChanged;
|
||||
_refreshTimer.Tick += async (_, _) => await RefreshAsync(force: false);
|
||||
DetachedFromVisualTree += (_, _) => Dispose();
|
||||
Reload();
|
||||
ConfigureRefreshTimer();
|
||||
_ = RefreshAsync(force: true);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_service.Changed -= OnChanged;
|
||||
_refreshTimer.Stop();
|
||||
if (_articleWebView is not null)
|
||||
{
|
||||
ArticleHtmlHost.Children.Remove(_articleWebView);
|
||||
_articleWebView = null;
|
||||
}
|
||||
_service.Dispose();
|
||||
}
|
||||
|
||||
private void OnChanged(object? sender, EventArgs e) => Dispatcher.UIThread.Post(Reload);
|
||||
|
||||
private void Reload()
|
||||
{
|
||||
var selectedSource = _sourceId;
|
||||
SourcesList.ItemsSource = _service.GetSources().Select(source => new SourceItem(source)).ToArray();
|
||||
var entries = _service.GetEntries(_sourceId, _unreadOnly, _favoritesOnly, 500);
|
||||
EntriesList.ItemsSource = entries.Select(entry => new EntryItem(entry)).ToArray();
|
||||
var targetEntryId = _options.TargetEntryId ?? _service.GetPendingEntryId();
|
||||
if (!string.IsNullOrWhiteSpace(targetEntryId) && targetEntryId != _lastHandledTargetEntryId)
|
||||
{
|
||||
var target = entries.FirstOrDefault(entry => entry.Id == targetEntryId) ?? _service.GetEntry(targetEntryId);
|
||||
if (target is not null) { _lastHandledTargetEntryId = targetEntryId; ShowEntry(target); }
|
||||
}
|
||||
StatusText.Text = string.Format(L("rss.status_counts", "{0} articles · {1} sources"), entries.Count, _service.GetSources().Count);
|
||||
}
|
||||
|
||||
private void SetFilter(string? sourceId, bool unread, bool favorites, string title)
|
||||
{
|
||||
_sourceId = sourceId; _unreadOnly = unread; _favoritesOnly = favorites; ListTitle.Text = title; Reload();
|
||||
}
|
||||
|
||||
private void OnSourceSelected(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
var hasSource = SourcesList.SelectedItem is SourceItem;
|
||||
EditSourceButton.IsEnabled = hasSource;
|
||||
DeleteSourceButton.IsEnabled = hasSource;
|
||||
if (SourcesList.SelectedItem is SourceItem item) SetFilter(item.Source.Id, false, false, item.Source.Title);
|
||||
}
|
||||
|
||||
private async void OnEditSource(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SourcesList.SelectedItem is not SourceItem item) return;
|
||||
var owner = TopLevel.GetTopLevel(this) as Window; if (owner is null) return;
|
||||
var title = new TextBox { Text = item.Source.Title, Width = 390 };
|
||||
var folder = new TextBox { Text = item.Source.Folder, Width = 390 };
|
||||
var enabled = new CheckBox { Content = L("rss.enabled", "Enabled"), IsChecked = item.Source.IsEnabled };
|
||||
var interval = new NumericUpDown { Minimum = 15, Maximum = 1440, Value = item.Source.RefreshIntervalMinutes ?? 30 };
|
||||
var save = new Button { Content = L("rss.save", "Save"), HorizontalAlignment = HorizontalAlignment.Right };
|
||||
var dialog = new Window { Title = L("rss.edit_source", "Edit RSS source"), Width = 460, Height = 360, CanResize = false };
|
||||
save.Click += (_, _) =>
|
||||
{
|
||||
_service.UpdateSource(item.Source.Id, title.Text ?? item.Source.Title, folder.Text, enabled.IsChecked == true, (int)(interval.Value ?? 30));
|
||||
dialog.Close();
|
||||
};
|
||||
dialog.Content = new StackPanel { Margin = new Avalonia.Thickness(24), Spacing = 10, Children = { new TextBlock { Text = L("rss.name", "Name") }, title, new TextBlock { Text = L("rss.folder", "Folder") }, folder, enabled, new TextBlock { Text = L("rss.refresh_interval_minutes", "Refresh interval (minutes)") }, interval, save } };
|
||||
await dialog.ShowDialog(owner);
|
||||
}
|
||||
|
||||
private async void OnDeleteSource(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SourcesList.SelectedItem is not SourceItem item) return;
|
||||
var owner = TopLevel.GetTopLevel(this) as Window; if (owner is null) return;
|
||||
var preserve = new CheckBox { Content = L("rss.keep_favorites", "Keep favorited articles"), IsChecked = true };
|
||||
var delete = new Button { Content = L("rss.delete", "Delete"), HorizontalAlignment = HorizontalAlignment.Right };
|
||||
var cancel = new Button { Content = L("rss.cancel", "Cancel") };
|
||||
var dialog = new Window { Title = L("rss.delete_source", "Delete RSS source"), Width = 420, Height = 220, CanResize = false };
|
||||
delete.Click += (_, _) => { _service.DeleteSource(item.Source.Id, preserve.IsChecked == true); _sourceId = null; dialog.Close(); };
|
||||
cancel.Click += (_, _) => dialog.Close();
|
||||
dialog.Content = new StackPanel { Margin = new Avalonia.Thickness(24), Spacing = 14, Children = { new TextBlock { Text = string.Format(L("rss.delete_source_confirm", "Delete {0}?"), item.Source.Title), FontSize = 18, TextWrapping = TextWrapping.Wrap }, preserve, new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Right, Children = { cancel, delete } } } };
|
||||
await dialog.ShowDialog(owner);
|
||||
}
|
||||
|
||||
private void OnEntrySelected(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (EntriesList.SelectedItem is EntryItem item) ShowEntry(item.Entry);
|
||||
}
|
||||
|
||||
private void ShowEntry(RssEntry entry)
|
||||
{
|
||||
if (!entry.IsRead) _service.MarkRead(entry.Id);
|
||||
_selectedEntry = entry with { IsRead = true };
|
||||
ArticleTitle.Text = entry.Title;
|
||||
ArticleMeta.Text = $"{entry.SourceTitle} · {entry.PublishedAt.ToLocalTime():g}{(string.IsNullOrWhiteSpace(entry.Author) ? string.Empty : " · " + entry.Author)}";
|
||||
var html = string.IsNullOrWhiteSpace(entry.Content) ? entry.Summary : entry.Content;
|
||||
ShowArticleContent(html);
|
||||
FavoriteButton.IsEnabled = true;
|
||||
FavoriteButton.Content = entry.IsFavorite ? $"★ {L("rss.favorited", "Favorited")}" : $"☆ {L("rss.favorite", "Favorite")}";
|
||||
OpenOriginalButton.IsEnabled = !string.IsNullOrWhiteSpace(entry.Link);
|
||||
}
|
||||
|
||||
private async void OnRefresh(object? sender, RoutedEventArgs e) => await RefreshAsync(force: true);
|
||||
private async Task RefreshAsync(bool force)
|
||||
{
|
||||
RefreshButton.IsEnabled = false; StatusText.Text = L("rss.refreshing", "Refreshing…");
|
||||
try { await _service.RefreshAllAsync(force); StatusText.Text = L("rss.updated", "Updated"); }
|
||||
catch (Exception ex) { StatusText.Text = $"{L("rss.refresh_failed", "Refresh failed")}: {ex.Message}"; }
|
||||
finally { RefreshButton.IsEnabled = true; Reload(); }
|
||||
}
|
||||
|
||||
private void OnMarkAllRead(object? sender, RoutedEventArgs e) => _service.MarkAllRead(_sourceId);
|
||||
private void OnFavorite(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_selectedEntry is null) return;
|
||||
var favorite = !_selectedEntry.IsFavorite;
|
||||
_service.SetFavorite(_selectedEntry.Id, favorite);
|
||||
ShowEntry(_selectedEntry with { IsFavorite = favorite });
|
||||
}
|
||||
|
||||
private void OnOpenOriginal(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!Uri.TryCreate(_selectedEntry?.Link, UriKind.Absolute, out var uri)) return;
|
||||
Process.Start(new ProcessStartInfo(uri.AbsoluteUri) { UseShellExecute = true });
|
||||
}
|
||||
|
||||
private void ShowArticleContent(string html)
|
||||
{
|
||||
var settings = _service.GetSettings();
|
||||
var sanitized = RssReaderService.SanitizeHtml(html, settings.LoadRemoteImages);
|
||||
ArticleBody.Text = RssReaderService.ToPlainText(sanitized);
|
||||
try
|
||||
{
|
||||
var availability = WebView2RuntimeProbe.GetAvailability();
|
||||
if (!availability.IsAvailable || string.IsNullOrWhiteSpace(sanitized))
|
||||
{
|
||||
ArticleHtmlHost.IsVisible = false;
|
||||
ArticleTextScroller.IsVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_articleWebView ??= CreateArticleWebView();
|
||||
var imagePolicy = settings.LoadRemoteImages ? "https: http: data:" : "data:";
|
||||
var document = $$"""
|
||||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src {{imagePolicy}}; style-src 'unsafe-inline'">
|
||||
<style>body{font-family:system-ui,sans-serif;margin:24px;color:#202124;line-height:1.65;font-size:16px}img{max-width:100%;height:auto}a{color:#0067c0}pre{white-space:pre-wrap}</style>
|
||||
</head><body>{{sanitized}}</body></html>
|
||||
""";
|
||||
var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(document));
|
||||
_articleWebView.Navigate(new Uri($"data:text/html;base64,{encoded}"));
|
||||
ArticleTextScroller.IsVisible = false;
|
||||
ArticleHtmlHost.IsVisible = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
ArticleHtmlHost.IsVisible = false;
|
||||
ArticleTextScroller.IsVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
private NativeWebView CreateArticleWebView()
|
||||
{
|
||||
var webView = new NativeWebView();
|
||||
ArticleHtmlHost.Children.Add(webView);
|
||||
return webView;
|
||||
}
|
||||
|
||||
private async void OnAdd(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return;
|
||||
var url = new TextBox { PlaceholderText = "https://example.com/feed.xml", Width = 430 };
|
||||
var title = new TextBox { PlaceholderText = L("rss.optional_name", "Optional custom name"), Width = 430 };
|
||||
var folder = new TextBox { PlaceholderText = L("rss.optional_folder", "Optional folder"), Width = 430 };
|
||||
var message = new TextBlock { TextWrapping = TextWrapping.Wrap };
|
||||
var add = new Button { Content = L("rss.probe_add", "Probe and add"), HorizontalAlignment = HorizontalAlignment.Right };
|
||||
var dialog = new Window { Title = L("rss.add_source", "Add RSS source"), Width = 500, Height = 330, CanResize = false };
|
||||
add.Click += async (_, _) =>
|
||||
{
|
||||
add.IsEnabled = false; message.Text = L("rss.checking_feed", "Checking feed…");
|
||||
try
|
||||
{
|
||||
var probe = await _service.ProbeAsync(url.Text ?? string.Empty);
|
||||
message.Text = $"{probe.Title} · {probe.Format}";
|
||||
await _service.AddSourceAsync(url.Text ?? string.Empty, title.Text, folder.Text);
|
||||
dialog.Close();
|
||||
}
|
||||
catch (Exception ex) { message.Text = ex.Message; add.IsEnabled = true; }
|
||||
};
|
||||
dialog.Content = new StackPanel { Margin = new Avalonia.Thickness(24), Spacing = 12, Children = { new TextBlock { Text = L("rss.feed_url", "Feed URL") }, url, new TextBlock { Text = L("rss.name", "Name") }, title, new TextBlock { Text = L("rss.folder", "Folder") }, folder, message, add } };
|
||||
await dialog.ShowDialog(owner);
|
||||
}
|
||||
|
||||
private async void OnImport(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var top = TopLevel.GetTopLevel(this); if (top is null) return;
|
||||
var files = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = L("rss.import_opml", "Import OPML"), AllowMultiple = false, FileTypeFilter = [new FilePickerFileType("OPML") { Patterns = ["*.opml", "*.xml"] }] });
|
||||
var path = files.FirstOrDefault()?.TryGetLocalPath(); if (path is null) return;
|
||||
var result = await _service.ImportOpmlAsync(path); StatusText.Text = string.Format(L("rss.import_result", "Imported {0}, skipped {1}, failed {2}."), result.Added, result.Skipped, result.Failed);
|
||||
}
|
||||
|
||||
private async void OnExport(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var top = TopLevel.GetTopLevel(this); if (top is null) return;
|
||||
var file = await top.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = L("rss.export_opml", "Export OPML"), SuggestedFileName = "subscriptions.opml", FileTypeChoices = [new FilePickerFileType("OPML") { Patterns = ["*.opml"] }] });
|
||||
var path = file?.TryGetLocalPath(); if (path is null) return; _service.ExportOpml(path); StatusText.Text = L("rss.exported", "OPML exported.");
|
||||
}
|
||||
|
||||
private async void OnSettings(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window; if (owner is null) return;
|
||||
var settings = _service.GetSettings();
|
||||
var interval = new ComboBox { ItemsSource = new[] { new Choice(15, string.Format(L("rss.minutes", "{0} minutes"), 15)), new Choice(30, string.Format(L("rss.minutes", "{0} minutes"), 30)), new Choice(60, string.Format(L("rss.minutes", "{0} minutes"), 60)), new Choice(0,L("rss.manual_only", "Manual only")) }, SelectedIndex = settings.RefreshIntervalMinutes switch { 15 => 0, 60 => 2, 0 => 3, _ => 1 } };
|
||||
var images = new CheckBox { Content = L("rss.load_remote_images", "Load remote images"), IsChecked = settings.LoadRemoteImages };
|
||||
var save = new Button { Content = L("rss.save", "Save"), HorizontalAlignment = HorizontalAlignment.Right };
|
||||
var dialog = new Window { Title = L("rss.settings", "RSS settings"), Width = 420, Height = 240, CanResize = false };
|
||||
save.Click += (_, _) => { var value = (interval.SelectedItem as Choice)?.Value ?? 30; _service.SaveSettings(settings with { RefreshIntervalMinutes = value, LoadRemoteImages = images.IsChecked == true }); ConfigureRefreshTimer(); dialog.Close(); };
|
||||
dialog.Content = new StackPanel { Margin = new Avalonia.Thickness(24), Spacing = 14, Children = { new TextBlock { Text = L("rss.refresh_interval", "Refresh interval") }, interval, images, save } };
|
||||
await dialog.ShowDialog(owner);
|
||||
}
|
||||
|
||||
private sealed record SourceItem(RssSource Source) { public override string ToString() => string.IsNullOrWhiteSpace(Source.Folder) ? Source.Title : $"{Source.Folder} / {Source.Title}"; }
|
||||
|
||||
private void ConfigureRefreshTimer()
|
||||
{
|
||||
var minutes = _service.GetSettings().RefreshIntervalMinutes;
|
||||
_refreshTimer.Stop();
|
||||
if (minutes <= 0) return;
|
||||
_refreshTimer.Interval = TimeSpan.FromMinutes(minutes);
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
|
||||
private void ApplyLocalization()
|
||||
{
|
||||
AddButton.Content = L("rss.add_source", "Add source");
|
||||
RefreshButton.Content = L("rss.refresh", "Refresh");
|
||||
AllButton.Content = L("rss.all_articles", "All articles");
|
||||
UnreadButton.Content = L("rss.unread", "Unread");
|
||||
FavoritesButton.Content = L("rss.favorites", "Favorites");
|
||||
EditSourceButton.Content = L("rss.edit", "Edit");
|
||||
DeleteSourceButton.Content = L("rss.delete", "Delete");
|
||||
ImportButton.Content = L("rss.import_opml", "Import OPML");
|
||||
ExportButton.Content = L("rss.export_opml", "Export OPML");
|
||||
SettingsButton.Content = L("rss.settings", "Settings");
|
||||
ListTitle.Text = L("rss.all_articles", "All articles");
|
||||
MarkAllReadButton.Content = L("rss.mark_all_read", "Mark all read");
|
||||
ArticleTitle.Text = L("rss.select_article", "Select an article");
|
||||
ArticleBody.Text = L("rss.article_placeholder", "Your RSS articles will appear here.");
|
||||
FavoriteButton.Content = $"☆ {L("rss.favorite", "Favorite")}";
|
||||
OpenOriginalButton.Content = L("rss.open_original", "Open original");
|
||||
}
|
||||
|
||||
private string L(string key, string fallback) => _localization.GetString(_languageCode, key, fallback);
|
||||
private sealed record EntryItem(RssEntry Entry)
|
||||
{
|
||||
public string DisplayTitle => (Entry.IsRead ? string.Empty : "● ") + Entry.Title;
|
||||
public FontWeight Weight => Entry.IsRead ? FontWeight.Normal : FontWeight.SemiBold;
|
||||
public string Metadata => $"{Entry.SourceTitle} · {Entry.PublishedAt.ToLocalTime():g}{(Entry.IsFavorite ? " · ★" : string.Empty)}";
|
||||
}
|
||||
private sealed record Choice(int Value, string Label) { public override string ToString() => Label; }
|
||||
}
|
||||
Reference in New Issue
Block a user