mirror of
				https://github.com/chylex/Discord-History-Tracker.git
				synced 2025-10-31 02:17:15 +01:00 
			
		
		
		
	Compare commits
	
		
			14 Commits
		
	
	
		
			v47.2
			...
			c3bf7d5dc3
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | c3bf7d5dc3 | ||
|   | 5866d953f8 | ||
|   | 6c0e0ff697 | ||
|   | 7e29c7f837 | ||
|   | 7ba012ef5c | ||
|   | c52572b387 | ||
|   | 5ba80dc12f | ||
|   | 37a0feddcc | ||
|   | 47f448dcde | ||
|   | 0281b49815 | ||
|   | 648b221bb8 | ||
|   | 7d8558ae04 | ||
|   | 41053549ab | ||
|   | f279bb4d16 | 
| @@ -1,17 +0,0 @@ | |||||||
| using System; |  | ||||||
| using System.Threading; |  | ||||||
| using System.Threading.Tasks; |  | ||||||
| using DHT.Utils.Observables; |  | ||||||
|  |  | ||||||
| namespace DHT.Desktop.Common; |  | ||||||
|  |  | ||||||
| static class AvaloniaObsevableValueExtensions { |  | ||||||
| 	public static IDisposable SubscribeLastOnUI<T>(this ObservableValue<T> observable, Action<T> action, TimeSpan delayBetweenRuns) { |  | ||||||
| 		Task Action(T value, CancellationToken cancellationToken) { |  | ||||||
| 			action(value); |  | ||||||
| 			return Task.Delay(delayBetweenRuns, cancellationToken); |  | ||||||
| 		} |  | ||||||
| 		 |  | ||||||
| 		return observable.SubscribeLast(Action, TaskScheduler.FromCurrentSynchronizationContext()); |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| @@ -2,7 +2,9 @@ using System; | |||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using System.ComponentModel; | using System.ComponentModel; | ||||||
| using System.Linq; | using System.Linq; | ||||||
|  | using System.Reactive.Linq; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
|  | using Avalonia.ReactiveUI; | ||||||
| using DHT.Desktop.Common; | using DHT.Desktop.Common; | ||||||
| using DHT.Server; | using DHT.Server; | ||||||
| using DHT.Server.Data.Filters; | using DHT.Server.Data.Filters; | ||||||
| @@ -47,7 +49,7 @@ sealed partial class DownloadItemFilterPanelModel : IAsyncDisposable { | |||||||
| 	private readonly State state; | 	private readonly State state; | ||||||
| 	private readonly string verb; | 	private readonly string verb; | ||||||
| 	 | 	 | ||||||
| 	private readonly ThrottledTask<FilterSettings> saveFilterSettingsTask; | 	private readonly DelayedThrottledTask<FilterSettings> saveFilterSettingsTask; | ||||||
| 	private bool isLoadingFilterSettings; | 	private bool isLoadingFilterSettings; | ||||||
| 	 | 	 | ||||||
| 	private readonly RestartableTask<long> downloadItemCountTask; | 	private readonly RestartableTask<long> downloadItemCountTask; | ||||||
| @@ -63,10 +65,10 @@ sealed partial class DownloadItemFilterPanelModel : IAsyncDisposable { | |||||||
| 		this.state = state; | 		this.state = state; | ||||||
| 		this.verb = verb; | 		this.verb = verb; | ||||||
| 		 | 		 | ||||||
| 		this.saveFilterSettingsTask = new ThrottledTask<FilterSettings>(Log, SaveFilterSettings, TimeSpan.FromSeconds(5), TaskScheduler.Default); | 		this.saveFilterSettingsTask = new DelayedThrottledTask<FilterSettings>(Log, TimeSpan.FromSeconds(5), SaveFilterSettings); | ||||||
| 		 | 		 | ||||||
| 		this.downloadItemCountTask = new RestartableTask<long>(SetMatchingCount, TaskScheduler.FromCurrentSynchronizationContext()); | 		this.downloadItemCountTask = new RestartableTask<long>(SetMatchingCount, TaskScheduler.FromCurrentSynchronizationContext()); | ||||||
| 		this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.SubscribeLastOnUI(OnDownloadItemCountChanged, TimeSpan.FromMilliseconds(15)); | 		this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadItemCountChanged); | ||||||
| 		 | 		 | ||||||
| 		UpdateFilterStatistics(); | 		UpdateFilterStatistics(); | ||||||
| 		 | 		 | ||||||
|   | |||||||
| @@ -4,8 +4,10 @@ using System.Collections.Immutable; | |||||||
| using System.ComponentModel; | using System.ComponentModel; | ||||||
| using System.Diagnostics.CodeAnalysis; | using System.Diagnostics.CodeAnalysis; | ||||||
| using System.Linq; | using System.Linq; | ||||||
|  | using System.Reactive.Linq; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
| using Avalonia.Controls; | using Avalonia.Controls; | ||||||
|  | using Avalonia.ReactiveUI; | ||||||
| using DHT.Desktop.Common; | using DHT.Desktop.Common; | ||||||
| using DHT.Desktop.Dialogs.CheckBox; | using DHT.Desktop.Dialogs.CheckBox; | ||||||
| using DHT.Desktop.Dialogs.Message; | using DHT.Desktop.Dialogs.Message; | ||||||
| @@ -89,9 +91,9 @@ sealed partial class MessageFilterPanelModel : IDisposable { | |||||||
| 		 | 		 | ||||||
| 		this.exportedMessageCountTask = new RestartableTask<long>(SetExportedMessageCount, TaskScheduler.FromCurrentSynchronizationContext()); | 		this.exportedMessageCountTask = new RestartableTask<long>(SetExportedMessageCount, TaskScheduler.FromCurrentSynchronizationContext()); | ||||||
| 		 | 		 | ||||||
| 		this.messageCountSubscription = state.Db.Messages.TotalCount.SubscribeLastOnUI(OnMessageCountChanged, TimeSpan.FromMilliseconds(15)); | 		this.messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnMessageCountChanged); | ||||||
| 		this.channelCountSubscription = state.Db.Channels.TotalCount.SubscribeLastOnUI(OnChannelCountChanged, TimeSpan.FromMilliseconds(15)); | 		this.channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnChannelCountChanged); | ||||||
| 		this.userCountSubscription = state.Db.Users.TotalCount.SubscribeLastOnUI(OnUserCountChanged, TimeSpan.FromMilliseconds(15)); | 		this.userCountSubscription = state.Db.Users.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnUserCountChanged); | ||||||
| 		 | 		 | ||||||
| 		UpdateFilterStatistics(); | 		UpdateFilterStatistics(); | ||||||
| 		UpdateChannelFilterLabel(); | 		UpdateChannelFilterLabel(); | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| using System; | using System; | ||||||
|  | using System.Reactive.Linq; | ||||||
|  | using Avalonia.ReactiveUI; | ||||||
| using Avalonia.Threading; | using Avalonia.Threading; | ||||||
| using DHT.Desktop.Common; |  | ||||||
| using DHT.Server; | using DHT.Server; | ||||||
| using DHT.Server.Service; | using DHT.Server.Service; | ||||||
| using PropertyChanged.SourceGenerator; | using PropertyChanged.SourceGenerator; | ||||||
| @@ -40,9 +41,9 @@ sealed partial class StatusBarModel : IDisposable { | |||||||
| 	public StatusBarModel(State state) { | 	public StatusBarModel(State state) { | ||||||
| 		this.state = state; | 		this.state = state; | ||||||
| 		 | 		 | ||||||
| 		serverCountSubscription = state.Db.Servers.TotalCount.SubscribeLastOnUI(newServerCount => ServerCount = newServerCount, TimeSpan.FromMilliseconds(15)); | 		serverCountSubscription = state.Db.Servers.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newServerCount => ServerCount = newServerCount); | ||||||
| 		channelCountSubscription = state.Db.Channels.TotalCount.SubscribeLastOnUI(newChannelCount => ChannelCount = newChannelCount, TimeSpan.FromMilliseconds(15)); | 		channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newChannelCount => ChannelCount = newChannelCount); | ||||||
| 		messageCountSubscription = state.Db.Messages.TotalCount.SubscribeLastOnUI(newMessageCount => MessageCount = newMessageCount, TimeSpan.FromMilliseconds(15)); | 		messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newMessageCount => MessageCount = newMessageCount); | ||||||
| 		 | 		 | ||||||
| 		state.Server.StatusChanged += OnStateServerStatusChanged; | 		state.Server.StatusChanged += OnStateServerStatusChanged; | ||||||
| 		serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped; | 		serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped; | ||||||
|   | |||||||
| @@ -80,25 +80,22 @@ sealed class DatabasePageModel { | |||||||
| 		 | 		 | ||||||
| 		const string Title = "Database Merge"; | 		const string Title = "Database Merge"; | ||||||
| 		 | 		 | ||||||
| 		var result = new TaskCompletionSource<ImportResult?>(); | 		ImportResult? result; | ||||||
| 		try { | 		try { | ||||||
| 			var dialog = new ProgressDialog(); | 			result = await ProgressDialog.Show(window, Title, async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback)); | ||||||
| 			dialog.DataContext = new ProgressDialogModel(Title, async callbacks => result.SetResult(await MergeWithDatabaseFromPaths(Db, paths, dialog, callbacks)), progressItems: 2); |  | ||||||
| 			await dialog.ShowProgressDialog(window); |  | ||||||
| 		} catch (Exception e) { | 		} catch (Exception e) { | ||||||
| 			Log.Error("Could not merge databases.", e); | 			Log.Error("Could not merge databases.", e); | ||||||
| 			await Dialog.ShowOk(window, Title, "Could not merge databases: " + e.Message); | 			await Dialog.ShowOk(window, Title, "Could not merge databases: " + e.Message); | ||||||
| 			return; | 			return; | ||||||
| 		} | 		} | ||||||
| 		 | 		 | ||||||
| 		await Dialog.ShowOk(window, Title, GetImportDialogMessage(result.Task.Result, "database file")); | 		await Dialog.ShowOk(window, Title, GetImportDialogMessage(result, "database file")); | ||||||
| 	} | 	} | ||||||
| 	 | 	 | ||||||
| 	private static async Task<ImportResult?> MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IReadOnlyList<IProgressCallback> callbacks) { | 	private static async Task<ImportResult?> MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) { | ||||||
| 		var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, callbacks[1], paths.Length); | 		var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length); | ||||||
| 		var databaseMergeProgressCallback = new DatabaseMergeProgressCallback(callbacks[1]); |  | ||||||
| 		 | 		 | ||||||
| 		return await PerformImport(target, paths, dialog, callbacks[0], "Database Merge", async path => { | 		return await PerformImport(target, paths, dialog, callback, "Database Merge", async path => { | ||||||
| 			IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks); | 			IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks); | ||||||
| 			 | 			 | ||||||
| 			if (db == null) { | 			if (db == null) { | ||||||
| @@ -106,7 +103,7 @@ sealed class DatabasePageModel { | |||||||
| 			} | 			} | ||||||
| 			 | 			 | ||||||
| 			try { | 			try { | ||||||
| 				await target.Merge(db, databaseMergeProgressCallback); | 				await target.AddFrom(db); | ||||||
| 				return true; | 				return true; | ||||||
| 			} finally { | 			} finally { | ||||||
| 				await db.DisposeAsync(); | 				await db.DisposeAsync(); | ||||||
| @@ -114,7 +111,7 @@ sealed class DatabasePageModel { | |||||||
| 		}); | 		}); | ||||||
| 	} | 	} | ||||||
| 	 | 	 | ||||||
| 	private sealed class SchemaUpgradeCallbacks(ProgressDialog dialog, IProgressCallback callback, int total) : ISchemaUpgradeCallbacks { | 	private sealed class SchemaUpgradeCallbacks(ProgressDialog dialog, int total) : ISchemaUpgradeCallbacks { | ||||||
| 		private bool? decision; | 		private bool? decision; | ||||||
| 		 | 		 | ||||||
| 		public Task<InitialDatabaseSettings?> GetInitialDatabaseSettings() { | 		public Task<InitialDatabaseSettings?> GetInitialDatabaseSettings() { | ||||||
| @@ -128,7 +125,6 @@ sealed class DatabasePageModel { | |||||||
| 		} | 		} | ||||||
| 		 | 		 | ||||||
| 		public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) { | 		public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) { | ||||||
| 			callback.UpdateIndeterminate("Upgrading database..."); |  | ||||||
| 			return doUpgrade(new NullReporter()); | 			return doUpgrade(new NullReporter()); | ||||||
| 		} | 		} | ||||||
| 		 | 		 | ||||||
| @@ -147,20 +143,6 @@ sealed class DatabasePageModel { | |||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	 | 	 | ||||||
| 	private sealed class DatabaseMergeProgressCallback(IProgressCallback callback) : DatabaseMerging.IProgressCallback { |  | ||||||
| 		public void OnImportingMetadata() { |  | ||||||
| 			callback.UpdateIndeterminate("Importing metadata..."); |  | ||||||
| 		} |  | ||||||
| 		 |  | ||||||
| 		public void OnMessagesImported(long finished, long total) { |  | ||||||
| 			callback.Update("Importing messages...", finished, total); |  | ||||||
| 		} |  | ||||||
| 		 |  | ||||||
| 		public void OnDownloadsImported(long finished, long total) { |  | ||||||
| 			callback.Update("Importing downloaded files...", finished, total); |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	 |  | ||||||
| 	public async Task ImportLegacyArchive() { | 	public async Task ImportLegacyArchive() { | ||||||
| 		string[] paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions { | 		string[] paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions { | ||||||
| 			Title = "Open Legacy DHT Archive", | 			Title = "Open Legacy DHT Archive", | ||||||
| @@ -241,7 +223,7 @@ sealed class DatabasePageModel { | |||||||
| 		int finished = 0; | 		int finished = 0; | ||||||
| 		 | 		 | ||||||
| 		foreach (string path in paths) { | 		foreach (string path in paths) { | ||||||
| 			await callback.Update("File: " + Path.GetFileName(path), finished, total); | 			await callback.Update(Path.GetFileName(path), finished, total); | ||||||
| 			++finished; | 			++finished; | ||||||
| 			 | 			 | ||||||
| 			if (!File.Exists(path)) { | 			if (!File.Exists(path)) { | ||||||
|   | |||||||
| @@ -2,9 +2,11 @@ using System; | |||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using System.Collections.ObjectModel; | using System.Collections.ObjectModel; | ||||||
| using System.ComponentModel; | using System.ComponentModel; | ||||||
|  | using System.Reactive.Linq; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
| using Avalonia.Controls; | using Avalonia.Controls; | ||||||
| using Avalonia.Platform.Storage; | using Avalonia.Platform.Storage; | ||||||
|  | using Avalonia.ReactiveUI; | ||||||
| using DHT.Desktop.Common; | using DHT.Desktop.Common; | ||||||
| using DHT.Desktop.Dialogs.File; | using DHT.Desktop.Dialogs.File; | ||||||
| using DHT.Desktop.Dialogs.Message; | using DHT.Desktop.Dialogs.Message; | ||||||
| @@ -17,7 +19,6 @@ using DHT.Server.Data.Filters; | |||||||
| using DHT.Server.Data.Settings; | using DHT.Server.Data.Settings; | ||||||
| using DHT.Server.Download; | using DHT.Server.Download; | ||||||
| using DHT.Utils.Logging; | using DHT.Utils.Logging; | ||||||
| using DHT.Utils.Observables; |  | ||||||
| using DHT.Utils.Tasks; | using DHT.Utils.Tasks; | ||||||
| using PropertyChanged.SourceGenerator; | using PropertyChanged.SourceGenerator; | ||||||
|  |  | ||||||
| @@ -81,8 +82,8 @@ sealed partial class DownloadsPageModel : IAsyncDisposable { | |||||||
| 			statisticsSkipped, | 			statisticsSkipped, | ||||||
| 		]; | 		]; | ||||||
| 		 | 		 | ||||||
| 		downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(Log, UpdateStatistics, TimeSpan.FromMilliseconds(100), TaskScheduler.FromCurrentSynchronizationContext()); | 		downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(Log, UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext()); | ||||||
| 		downloadItemCountSubscription = state.Db.Downloads.TotalCount.SubscribeLastOnUI(OnDownloadCountChanged, TimeSpan.FromMilliseconds(15)); | 		downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadCountChanged); | ||||||
| 		 | 		 | ||||||
| 		RecomputeDownloadStatistics(); | 		RecomputeDownloadStatistics(); | ||||||
| 	} | 	} | ||||||
| @@ -144,8 +145,8 @@ sealed partial class DownloadsPageModel : IAsyncDisposable { | |||||||
| 		 | 		 | ||||||
| 		try { | 		try { | ||||||
| 			currentDownloadFilter = FilterModel.CreateFilter(); | 			currentDownloadFilter = FilterModel.CreateFilter(); | ||||||
| 			ObservableValue<DownloadItem> finishedItems = await state.Downloader.Start(currentDownloadFilter); | 			IObservable<DownloadItem> finishedItems = await state.Downloader.Start(currentDownloadFilter); | ||||||
| 			finishedItemsSubscription = finishedItems.SubscribeLastOnUI(OnItemFinished, TimeSpan.FromMilliseconds(15)); | 			finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished); | ||||||
| 		} catch (Exception) { | 		} catch (Exception) { | ||||||
| 			finishedItemsSubscription?.Dispose(); | 			finishedItemsSubscription?.Dispose(); | ||||||
| 			finishedItemsSubscription = null; | 			finishedItemsSubscription = null; | ||||||
| @@ -284,7 +285,7 @@ sealed partial class DownloadsPageModel : IAsyncDisposable { | |||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	 | 	 | ||||||
| 	private Task UpdateStatistics(DownloadStatusStatistics statusStatistics) { | 	private void UpdateStatistics(DownloadStatusStatistics statusStatistics) { | ||||||
| 		statisticsPending.Items = statusStatistics.PendingCount; | 		statisticsPending.Items = statusStatistics.PendingCount; | ||||||
| 		statisticsPending.Size = statusStatistics.PendingTotalSize; | 		statisticsPending.Size = statusStatistics.PendingTotalSize; | ||||||
| 		statisticsPending.HasFilesWithUnknownSize = statusStatistics.PendingWithUnknownSizeCount > 0; | 		statisticsPending.HasFilesWithUnknownSize = statusStatistics.PendingWithUnknownSizeCount > 0; | ||||||
| @@ -303,8 +304,6 @@ sealed partial class DownloadsPageModel : IAsyncDisposable { | |||||||
| 		 | 		 | ||||||
| 		HasSuccessfulDownloads = statusStatistics.SuccessfulCount > 0; | 		HasSuccessfulDownloads = statusStatistics.SuccessfulCount > 0; | ||||||
| 		HasFailedDownloads = statusStatistics.FailedCount > 0; | 		HasFailedDownloads = statusStatistics.FailedCount > 0; | ||||||
| 		 |  | ||||||
| 		return Task.CompletedTask; |  | ||||||
| 	} | 	} | ||||||
| 	 | 	 | ||||||
| 	public sealed partial class StatisticsRow(string state) { | 	public sealed partial class StatisticsRow(string state) { | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ | |||||||
|      |      | ||||||
|     <link rel="stylesheet" href="styles/main.css"> |     <link rel="stylesheet" href="styles/main.css"> | ||||||
|     <link rel="stylesheet" href="styles/menu.css"> |     <link rel="stylesheet" href="styles/menu.css"> | ||||||
|  |     <link rel="stylesheet" href="styles/servers.css"> | ||||||
|     <link rel="stylesheet" href="styles/channels.css"> |     <link rel="stylesheet" href="styles/channels.css"> | ||||||
|     <link rel="stylesheet" href="styles/messages.css"> |     <link rel="stylesheet" href="styles/messages.css"> | ||||||
|     <link rel="stylesheet" href="styles/modal.css"> |     <link rel="stylesheet" href="styles/modal.css"> | ||||||
| @@ -75,6 +76,7 @@ | |||||||
|     </div> |     </div> | ||||||
|      |      | ||||||
|     <div id="app"> |     <div id="app"> | ||||||
|  |       <div id="servers"></div> | ||||||
|       <div id="channels"> |       <div id="channels"> | ||||||
|         <div class="loading"></div> |         <div class="loading"></div> | ||||||
|       </div> |       </div> | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ import discord from "./discord.mjs"; | |||||||
| import gui from "./gui.mjs"; | import gui from "./gui.mjs"; | ||||||
| import state from "./state.mjs"; | import state from "./state.mjs"; | ||||||
| import "./polyfills.mjs"; | import "./polyfills.mjs"; | ||||||
|  | import servers from "./servers.mjs"; | ||||||
|  |  | ||||||
| window.DISCORD = discord; | window.DISCORD = discord; | ||||||
|  |  | ||||||
| @@ -25,10 +26,12 @@ document.addEventListener("DOMContentLoaded", () => { | |||||||
| 	 | 	 | ||||||
| 	state.onUsersRefreshed(users => { | 	state.onUsersRefreshed(users => { | ||||||
| 		gui.updateUserList(users); | 		gui.updateUserList(users); | ||||||
|  | 		servers.update() | ||||||
| 	}); | 	}); | ||||||
| 	 | 	 | ||||||
| 	state.onChannelsRefreshed((channels, selected) => { | 	state.onChannelsRefreshed((channels, selected) => { | ||||||
| 		gui.updateChannelList(channels, selected, state.selectChannel); | 		gui.updateChannelList(channels, selected, state.selectChannel); | ||||||
|  | 		servers.update() | ||||||
| 	}); | 	}); | ||||||
| 	 | 	 | ||||||
| 	state.onMessagesRefreshed(messages => { | 	state.onMessagesRefreshed(messages => { | ||||||
|   | |||||||
| @@ -107,22 +107,23 @@ export default (function() { | |||||||
| 	const isImageUrl = function(url) { | 	const isImageUrl = function(url) { | ||||||
| 		const dot = url.pathname.lastIndexOf("."); | 		const dot = url.pathname.lastIndexOf("."); | ||||||
| 		const ext = dot === -1 ? "" : url.pathname.substring(dot).toLowerCase(); | 		const ext = dot === -1 ? "" : url.pathname.substring(dot).toLowerCase(); | ||||||
| 		return ext === ".png" || ext === ".gif" || ext === ".jpg" || ext === ".jpeg" || ext === ".webp" || ext === ".avif"; | 		return ext === ".png" || ext === ".gif" || ext === ".jpg" || ext === ".jpeg"; | ||||||
| 	}; | 	}; | ||||||
| 	 | 	 | ||||||
| 	return { | 	return { | ||||||
| 		setup() { | 		setup() { | ||||||
| 			templateChannelServer = new template([ | 			templateChannelServer = new template([ | ||||||
| 				"<div class='channel' data-channel='{id}'>", | 				"<div class='channel ServerChannel' data-channel='{id}' server-id='{serverId}' server-name='{server.name}' server-type='{server.type}'>", | ||||||
| 				"<div class='info' title='{topic}'><strong class='name'>#{name}</strong>{nsfw}<span class='tag'>{msgcount}</span></div>", | 				"<div class='info' title='{topic}'><strong class='name'>#{name}</strong>{nsfw}<span class='tag'>{msgcount}</span></div>", | ||||||
| 				"<span class='server'>{server.name} ({server.type})</span>", | 				"<!--<span class='server'>{server.name} ({server.type})</span>-->", | ||||||
| 				"</div>" | 				"</div>" | ||||||
| 			].join("")); | 			].join("")); | ||||||
| 			 | 			 | ||||||
| 			templateChannelPrivate = new template([ | 			templateChannelPrivate = new template([ | ||||||
| 				"<div class='channel' data-channel='{id}'>", | 				"<div class='channel UserChannel' data-channel='{id}' server-id='0' server-name='{server.name}' server-type='{server.type}'>", | ||||||
|  | 				"<div class='avatar'>{icon}</div>", | ||||||
| 				"<div class='info'><strong class='name'>{name}</strong><span class='tag'>{msgcount}</span></div>", | 				"<div class='info'><strong class='name'>{name}</strong><span class='tag'>{msgcount}</span></div>", | ||||||
| 				"<span class='server'>({server.type})</span>", | 				"<!--<span class='server'>{server.name} ({server.type})</span>-->", | ||||||
| 				"</div>" | 				"</div>" | ||||||
| 			].join("")); | 			].join("")); | ||||||
| 			 | 			 | ||||||
| @@ -166,7 +167,7 @@ export default (function() { | |||||||
| 			 | 			 | ||||||
| 			// noinspection HtmlUnknownTarget | 			// noinspection HtmlUnknownTarget | ||||||
| 			templateEmbedImageWithSize = new template([ | 			templateEmbedImageWithSize = new template([ | ||||||
| 				"<a href='{url}' class='embed thumbnail loading'><img src='{src}' width='{width}' height='{height}' alt='' onload='window.DISCORD.handleImageLoad(this)' onerror='window.DISCORD.handleImageLoadError(this)'></a><br>" | 				"<a href='{url}' class='embed thumbnail loading'><img src='{src}' width='{width}' alt='' onload='window.DISCORD.handleImageLoad(this)' onerror='window.DISCORD.handleImageLoadError(this)'></a><br>" | ||||||
| 			].join("")); | 			].join("")); | ||||||
| 			 | 			 | ||||||
| 			// noinspection HtmlUnknownTarget | 			// noinspection HtmlUnknownTarget | ||||||
|   | |||||||
							
								
								
									
										109
									
								
								app/Resources/Viewer/scripts/servers.mjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								app/Resources/Viewer/scripts/servers.mjs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,109 @@ | |||||||
|  | import state from "./state.mjs"; | ||||||
|  |  | ||||||
|  | const servers = (() => { | ||||||
|  |     let currentServerId = "0"; | ||||||
|  |  | ||||||
|  |     function getIcon(name) { | ||||||
|  |         return name.split(" ").map(word => { | ||||||
|  |             if (word.startsWith("[") && word.length > 1) return word[1]; | ||||||
|  |             return word[0] || ""; | ||||||
|  |         }).join(""); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function update() { | ||||||
|  |         const channels = document.querySelectorAll("#channels .channel"); | ||||||
|  |         const serversMap = new Map(); | ||||||
|  |  | ||||||
|  |         // Check if there are any channels with server-id 0 (DM) | ||||||
|  |         const hasDMChannels = Array.from(channels).some(channel => { | ||||||
|  |             return channel.getAttribute("server-id") === "0"; | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         if (hasDMChannels) { | ||||||
|  |             serversMap.set("0", { | ||||||
|  |                 id: "0", | ||||||
|  |                 name: "DM", | ||||||
|  |                 icon: "DM", | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         channels.forEach(channel => { | ||||||
|  |             const serverId = channel.getAttribute("server-id") || "0"; | ||||||
|  |             const serverType = channel.getAttribute("server-type"); | ||||||
|  |             const serverName = channel.getAttribute("server-name"); | ||||||
|  |  | ||||||
|  |             if (serverType === "server" && !serversMap.has(serverId)) { | ||||||
|  |                 serversMap.set(serverId, { | ||||||
|  |                     id: serverId, | ||||||
|  |                     name: serverName, | ||||||
|  |                     icon: getIcon(serverName), | ||||||
|  |                 }); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         const serversDiv = document.getElementById("servers"); | ||||||
|  |         serversDiv.innerHTML = ""; | ||||||
|  |  | ||||||
|  |         if (hasDMChannels) { | ||||||
|  |             const dmServer = serversMap.get("0"); | ||||||
|  |             const dmElement = document.createElement("div"); | ||||||
|  |             dmElement.className = `Server${dmServer.id === currentServerId ? " active" : ""}`; | ||||||
|  |             dmElement.id = "DM"; | ||||||
|  |             dmElement.dataset.serverId = dmServer.id; | ||||||
|  |             dmElement.innerHTML = ` | ||||||
|  |                 <div class="icon">DM</div> | ||||||
|  |                 <div class="name" title="Direct Messages">Direct Messages</div> | ||||||
|  |             `; | ||||||
|  |             dmElement.addEventListener("click", () => selectServer(dmServer.id)); | ||||||
|  |             serversDiv.appendChild(dmElement); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         serversMap.forEach(server => { | ||||||
|  |             if (server.id === "0") return; // Skip DM since it's already added | ||||||
|  |  | ||||||
|  |             const serverElement = document.createElement("div"); | ||||||
|  |             serverElement.className = `Server${server.id === currentServerId ? " active" : ""}`; | ||||||
|  |             serverElement.dataset.serverId = server.id; | ||||||
|  |             serverElement.innerHTML = ` | ||||||
|  |                 <div class="icon">${server.icon}</div> | ||||||
|  |                 <div class="name" title="${server.name}">${server.name}</div> | ||||||
|  |             `; | ||||||
|  |             serverElement.addEventListener("click", () => selectServer(server.id)); | ||||||
|  |             serversDiv.appendChild(serverElement); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         if (!serversMap.has(currentServerId)) { | ||||||
|  |             currentServerId = "0"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         updateChannelVisibility(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function selectServer(serverId) { | ||||||
|  |         // Remove active class from all servers | ||||||
|  |         document.querySelectorAll("#servers .Server").forEach(server => { | ||||||
|  |             server.classList.remove("active"); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Add active class to the selected server | ||||||
|  |         const selectedServer = document.querySelector(`#servers .Server[data-server-id="${serverId}"]`); | ||||||
|  |         if (selectedServer) { | ||||||
|  |             selectedServer.classList.add("active"); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         currentServerId = serverId; | ||||||
|  |         updateChannelVisibility(); | ||||||
|  |         state.selectChannel(null); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function updateChannelVisibility() { | ||||||
|  |         document.querySelectorAll("#channels .channel").forEach(channel => { | ||||||
|  |             const channelServerId = channel.getAttribute("server-id") || "0"; | ||||||
|  |             channel.classList.toggle("visible", channelServerId === currentServerId); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return { update }; | ||||||
|  | })(); | ||||||
|  |  | ||||||
|  | export default servers; | ||||||
| @@ -9,6 +9,18 @@ export default (function() { | |||||||
| 	 * @property {{}} servers | 	 * @property {{}} servers | ||||||
| 	 * @property {{}} channels | 	 * @property {{}} channels | ||||||
| 	 */ | 	 */ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 	const fileUrlProcessor = function (serverToken) { | ||||||
|  | 		if (typeof serverToken === "string") { | ||||||
|  | 			return url => "/get-downloaded-file/" + encodeURIComponent(url) + "?token=" + encodeURIComponent(serverToken); | ||||||
|  | 		} | ||||||
|  | 		else { | ||||||
|  | 			return url => url; | ||||||
|  | 		} | ||||||
|  | 	}(window.DHT_SERVER_TOKEN); | ||||||
|  |  | ||||||
|  |  | ||||||
| 	let loadedFileMeta; | 	let loadedFileMeta; | ||||||
| 	let loadedFileData; | 	let loadedFileData; | ||||||
|  |  | ||||||
| @@ -31,6 +43,26 @@ export default (function() { | |||||||
| 		return loadedFileMeta.servers[id] || { "name": "<unknown>", "type": "unknown" }; | 		return loadedFileMeta.servers[id] || { "name": "<unknown>", "type": "unknown" }; | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
|  | 	const getUserIDByName = function (name) { | ||||||
|  | 		for (let userId in loadedFileMeta.users) { | ||||||
|  | 			let user = loadedFileMeta.users[userId]; | ||||||
|  | 			if (user.name === name) { | ||||||
|  | 				return userId; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		return 0; | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	const getUserByName = function (name) { | ||||||
|  | 		for (let userId in loadedFileMeta.users) { | ||||||
|  | 			let user = loadedFileMeta.users[userId]; | ||||||
|  | 			if (user.name === name) { | ||||||
|  | 				return user; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		return 0; | ||||||
|  | 	}; | ||||||
|  |  | ||||||
| 	const generateChannelHierarchy = function () { | 	const generateChannelHierarchy = function () { | ||||||
| 		/** | 		/** | ||||||
| 		 * @type {Map<string, Set>} | 		 * @type {Map<string, Set>} | ||||||
| @@ -144,13 +176,18 @@ export default (function() { | |||||||
| 		const channels = loadedFileMeta.channels; | 		const channels = loadedFileMeta.channels; | ||||||
| 		const channelOrder = generateChannelOrder(); | 		const channelOrder = generateChannelOrder(); | ||||||
|  |  | ||||||
|  |  | ||||||
| 		return Object.keys(channels).map(key => ({ | 		return Object.keys(channels).map(key => ({ | ||||||
| 			"id": key, | 			"id": key, | ||||||
|  | 			"serverId": channels[key].server, | ||||||
| 			"name": channels[key].name, | 			"name": channels[key].name, | ||||||
| 			"server": getServer(channels[key].server), | 			"server": getServer(channels[key].server), | ||||||
| 			"msgcount": getFilteredMessageKeys(key).length, | 			"msgcount": getFilteredMessageKeys(key).length, | ||||||
| 			"topic": channels[key].topic || "", | 			"topic": channels[key].topic || "", | ||||||
| 			"nsfw": channels[key].nsfw || false, | 			"nsfw": channels[key].nsfw || false, | ||||||
|  | 			"icon": getServer(channels[key].server).type === "group" | ||||||
|  | 				? `<!--<span>${channels[key].name.split(" ").map(word => word[0]).join("")}</span>-->` //Discord default naming without a icon | ||||||
|  | 				: `<img src='${fileUrlProcessor("https://cdn.discordapp.com/avatars/" + getUserIDByName(channels[key].name) + "/" + getUserByName(channels[key].name).avatar+".webp")}'>` | ||||||
| 		})).sort((ac, bc) => { | 		})).sort((ac, bc) => { | ||||||
| 			return channelOrder[ac.id] - channelOrder[bc.id]; | 			return channelOrder[ac.id] - channelOrder[bc.id]; | ||||||
| 		}); | 		}); | ||||||
|   | |||||||
| @@ -8,15 +8,21 @@ export default class { | |||||||
| 	}; | 	}; | ||||||
| 	 | 	 | ||||||
| 	apply(obj, processor) { | 	apply(obj, processor) { | ||||||
|  |  | ||||||
|  | 		//Keys to not escape | ||||||
|  | 		const allowHTMLKeys = new Set(["icon"]); //Example with more: Set(["icon", "description", "content"]); | ||||||
|  | 	 | ||||||
| 		return this.contents.replace(TEMPLATE_REGEX, (full, match) => { | 		return this.contents.replace(TEMPLATE_REGEX, (full, match) => { | ||||||
| 			const value = match.split(".").reduce((o, property) => o[property], obj); | 			const value = match.split(".").reduce((o, property) => o[property], obj); | ||||||
| 			 | 			 | ||||||
| 			if (processor) { | 			if (processor) { | ||||||
| 				const updated = processor(match, value); | 				const updated = processor(match, value); | ||||||
| 				return typeof updated === "undefined" ? dom.escapeHTML(value) : updated; | 				return typeof updated === "undefined" ? (allowHTMLKeys.has(match) ? value : dom.escapeHTML(value)) : updated; | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			return dom.escapeHTML(value); | 	 | ||||||
|  | 			return allowHTMLKeys.has(match) ? value : dom.escapeHTML(value); | ||||||
| 		}); | 		}); | ||||||
| 	} | 	} | ||||||
|  | 	 | ||||||
| } | } | ||||||
|   | |||||||
| @@ -4,8 +4,9 @@ | |||||||
|   max-width: 300px; |   max-width: 300px; | ||||||
|   overflow-y: auto; |   overflow-y: auto; | ||||||
|   color: #eee; |   color: #eee; | ||||||
|   background-color: #1c1e22; |   background-color: #2B2D31; | ||||||
|   font-size: 15px; |   font-size: 15px; | ||||||
|  |   padding: 2px; | ||||||
| } | } | ||||||
|  |  | ||||||
| #channels > div.loading { | #channels > div.loading { | ||||||
| @@ -14,27 +15,93 @@ | |||||||
|   height: 150px; |   height: 150px; | ||||||
| } | } | ||||||
|  |  | ||||||
| #channels > div.channel { | #channels > div.loading { | ||||||
|   cursor: pointer; |   background-color: rgba(0, 0, 0, 0) !important; | ||||||
|   padding: 10px 12px; |  | ||||||
|   border-bottom: 1px solid #333333; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| #channels > div.channel:hover, #channels > div.channel.active { |  | ||||||
|   background-color: #282b30; | #channels > div.channel.visible { | ||||||
|  |   display: flex !important; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #channels > div.channel { | ||||||
|  |   display: none !important; | ||||||
|  |  | ||||||
|  |   cursor: pointer; | ||||||
|  |   padding: 5px 8px; | ||||||
|  |  | ||||||
|  |   color: #eee; | ||||||
|  |   font-size: 15px; | ||||||
|  |  | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: row; | ||||||
|  |   flex-wrap: nowrap; | ||||||
|  |   align-items: flex-start; | ||||||
|  |  | ||||||
|  |   white-space: nowrap; | ||||||
|  |   text-overflow: ellipsis; | ||||||
|  |   overflow: hidden; | ||||||
|  |  | ||||||
|  |   border-radius: 5px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .ServerChannel { | ||||||
|  |   padding: 8px 8px !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #channels > div.channel:hover, | ||||||
|  | #channels > div.channel.active { | ||||||
|  |   background-color: rgba(78, 80, 88, 0.6); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #channels > div.channel.active > .info > .name { | ||||||
|  |   color: oklab(0.999994 0.0000455678 0.0000200868); | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
| #channels .info { | #channels .info { | ||||||
|   display: flex; |   display: flex; | ||||||
|   height: 16px; |   width: 100%; | ||||||
|   margin-bottom: 4px; | } | ||||||
|  |  | ||||||
|  | .ServerChannel > .info { | ||||||
|  |   padding: 0px !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .UserChannel > .info { | ||||||
|  |   align-self: center; | ||||||
|  |   padding-left: 6px !important; | ||||||
|  |   align-items: center; | ||||||
|  |  | ||||||
|  |   width: 150px !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | [server-type="group"] > .info { | ||||||
|  |   padding: 6px; | ||||||
|  |  | ||||||
|  |   width: 172px !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .UserChannel > .info > .name { | ||||||
|  |   min-height: 18px; | ||||||
| } | } | ||||||
|  |  | ||||||
| #channels .name { | #channels .name { | ||||||
|   flex-grow: 1; |   flex-grow: 1; | ||||||
|  |   overflow-x: hidden; | ||||||
|  |   overflow-y: clip; | ||||||
|   text-overflow: ellipsis; |   text-overflow: ellipsis; | ||||||
|   white-space: nowrap; |   white-space: nowrap; | ||||||
|   overflow: hidden; |   color: oklab(0.686636 -0.00407365 -0.0149199); | ||||||
|  |   font-weight: 500; | ||||||
|  |   font-size: 16px; | ||||||
|  |   /* max-width: 200px; */ | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .ServerChannel .name { | ||||||
|  |   height: 18px; | ||||||
| } | } | ||||||
|  |  | ||||||
| #channels .tag { | #channels .tag { | ||||||
| @@ -46,3 +113,25 @@ | |||||||
|   padding: 2px 5px; |   padding: 2px 5px; | ||||||
|   font-size: 11px; |   font-size: 11px; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | #channels > div:hover, | ||||||
|  | #channels > div.active { | ||||||
|  |   background-color: #404249 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #channels > div > .avatar { | ||||||
|  |   padding: 0; | ||||||
|  |   margin: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #channels > div > .avatar > * { | ||||||
|  |   width: 30px; | ||||||
|  |  | ||||||
|  |   min-width: 30px; | ||||||
|  |   min-height: 30px; | ||||||
|  |   /*Make not loaded images be 30px*/ | ||||||
|  |  | ||||||
|  |   border-radius: 100%; | ||||||
|  |   font-size: 1em; | ||||||
|  | } | ||||||
| @@ -17,6 +17,10 @@ body { | |||||||
|   --loading-backdrop: rgba(0, 0, 0, 0); |   --loading-backdrop: rgba(0, 0, 0, 0); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .loading:hover { | ||||||
|  |   background-color: rgba(0, 0, 0, 0) !important; | ||||||
|  | } | ||||||
|  |  | ||||||
| .loading::after { | .loading::after { | ||||||
|   content: ""; |   content: ""; | ||||||
|   background: var(--loading-backdrop) |   background: var(--loading-backdrop) | ||||||
|   | |||||||
| @@ -4,8 +4,8 @@ | |||||||
|   align-items: stretch; |   align-items: stretch; | ||||||
|   gap: 8px; |   gap: 8px; | ||||||
|   padding: 8px; |   padding: 8px; | ||||||
|   background-color: #17181c; |   background-color: #313338; | ||||||
|   border-bottom: 1px dotted #5d626b; |   border-bottom: 2px solid #27292D; | ||||||
| } | } | ||||||
|  |  | ||||||
| #menu .splitter { | #menu .splitter { | ||||||
| @@ -23,7 +23,9 @@ | |||||||
|   cursor: default; |   cursor: default; | ||||||
| } | } | ||||||
|  |  | ||||||
| #menu button, #menu select, #menu input[type="text"] { | #menu button, | ||||||
|  | #menu select, | ||||||
|  | #menu input[type="text"] { | ||||||
|   height: 31px; |   height: 31px; | ||||||
|   padding: 0 10px; |   padding: 0 10px; | ||||||
|   background-color: #7289da; |   background-color: #7289da; | ||||||
| @@ -64,11 +66,13 @@ | |||||||
|   padding: 0 8px; |   padding: 0 8px; | ||||||
| } | } | ||||||
|  |  | ||||||
| #menu .nav > button, #menu .nav > p { | #menu .nav > button, | ||||||
|  | #menu .nav > p { | ||||||
|   margin: 0 1px; |   margin: 0 1px; | ||||||
| } | } | ||||||
|  |  | ||||||
| #opt-filter-list > select, #opt-filter-list > input { | #opt-filter-list > select, | ||||||
|  | #opt-filter-list > input { | ||||||
|   display: none; |   display: none; | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -5,9 +5,14 @@ | |||||||
| } | } | ||||||
|  |  | ||||||
| #messages > div { | #messages > div { | ||||||
|   margin: 0 24px; |   padding: 0 4px 0 24px; | ||||||
|   padding: 4px 0 12px; |  | ||||||
|   border-bottom: 1px solid rgba(255, 255, 255, 0.04); |   margin-bottom: 17px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | #messages > div:hover { | ||||||
|  |   background-color: oklab(0.0846607 0.00000385568 0.00000169128 / 0.06); | ||||||
| } | } | ||||||
|  |  | ||||||
| #messages h2 { | #messages h2 { | ||||||
| @@ -23,6 +28,8 @@ | |||||||
|   align-content: flex-start; |   align-content: flex-start; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| #messages .avatar-wrapper > div { | #messages .avatar-wrapper > div { | ||||||
|   flex: 1 1 auto; |   flex: 1 1 auto; | ||||||
| } | } | ||||||
| @@ -50,14 +57,9 @@ | |||||||
|   font-size: 12px; |   font-size: 12px; | ||||||
|   font-weight: 500; |   font-weight: 500; | ||||||
|   letter-spacing: 0; |   letter-spacing: 0; | ||||||
|  |   margin-left: 5px; | ||||||
| } | } | ||||||
|  |  | ||||||
| #messages .info::before { |  | ||||||
|   content: "\2022"; |  | ||||||
|   text-align: center; |  | ||||||
|   display: inline-block; |  | ||||||
|   width: 14px; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #messages .jump { | #messages .jump { | ||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
| @@ -66,20 +68,22 @@ | |||||||
| } | } | ||||||
|  |  | ||||||
| .message { | .message { | ||||||
|   margin-top: 6px; |   margin-top: 2px; | ||||||
|   color: rgba(255, 255, 255, 0.7); |   color: oklab(0.89908 -0.00192907 -0.0048306); | ||||||
|   font-size: 15px; |   font-size: 16px; | ||||||
|   line-height: 1.1em; |   line-height: 1.1em; | ||||||
|   white-space: pre-wrap; |   white-space: pre-wrap; | ||||||
|   word-wrap: break-word; |   word-wrap: break-word; | ||||||
| } | } | ||||||
|  |  | ||||||
| .message .link, .reply-message .link { | .message .link, | ||||||
|  | .reply-message .link { | ||||||
|   color: #7289da; |   color: #7289da; | ||||||
|   background-color: rgba(115, 139, 215, 0.1); |   background-color: rgba(115, 139, 215, 0.1); | ||||||
| } | } | ||||||
|  |  | ||||||
| .message a, .reply-message a { | .message a, | ||||||
|  | .reply-message a { | ||||||
|   color: #0096cf; |   color: #0096cf; | ||||||
|   text-decoration: none; |   text-decoration: none; | ||||||
| } | } | ||||||
| @@ -126,7 +130,8 @@ | |||||||
|   border-radius: 3px; |   border-radius: 3px; | ||||||
| } | } | ||||||
|  |  | ||||||
| .message .embed:first-child, .message .download + .download { | .message .embed:first-child, | ||||||
|  | .message .download+.download { | ||||||
|   margin-top: 0; |   margin-top: 0; | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										81
									
								
								app/Resources/Viewer/styles/servers.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								app/Resources/Viewer/styles/servers.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | |||||||
|  | #servers { | ||||||
|  |     width: 76px; | ||||||
|  |     background-color: #1e1f22; | ||||||
|  |     padding: 0px; | ||||||
|  |     overflow-y: auto; | ||||||
|  |     overflow-x: hidden; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .Server { | ||||||
|  |     width: 54px; | ||||||
|  |     height: 54px; | ||||||
|  |     margin-left: 11px; | ||||||
|  |     margin-top: 8px; | ||||||
|  |     border-radius: 100%; | ||||||
|  |     transition: .15s ease-out; | ||||||
|  |     cursor: pointer; | ||||||
|  |     background-color: #313338; | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     overflow: hidden; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .Server:hover { | ||||||
|  |     border-radius: 20px; | ||||||
|  |     background-color: #5865f2; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .Server.active { | ||||||
|  |     border-radius: 20px; | ||||||
|  |     background-color: #5865f2; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .ServerImg { | ||||||
|  |     width: 100%; | ||||||
|  |     height: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | svg.ServerImg { | ||||||
|  |     width: 100%; | ||||||
|  |     height: 70%; | ||||||
|  |     /* margin-top: 15%; */ | ||||||
|  |     color: white; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #servers .Server .icon { | ||||||
|  |     font-weight: bold; | ||||||
|  |     font-size: 1.5em; | ||||||
|  |     text-align: center; | ||||||
|  |     word-wrap: break-word; | ||||||
|  |     word-break: break-all; | ||||||
|  |     white-space: normal; | ||||||
|  |     overflow: hidden; | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     justify-content: center; | ||||||
|  |     width: 100%; | ||||||
|  |     height: 100%; | ||||||
|  |     color: white; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #servers .Server .name { | ||||||
|  |     display: none; | ||||||
|  |     font-weight: bold; | ||||||
|  |     white-space: normal; | ||||||
|  |     word-wrap: break-word; | ||||||
|  |     text-align: center; | ||||||
|  | } | ||||||
|  | #servers .Server .name { | ||||||
|  |     display: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #servers .Server:hover .name { | ||||||
|  |     display: block; | ||||||
|  |     position: absolute; | ||||||
|  |     background: rgba(0, 0, 0, 0.8); | ||||||
|  |     color: white; | ||||||
|  |     padding: 5px; | ||||||
|  |     border-radius: 4px; | ||||||
|  |     margin-left: 60px; | ||||||
|  |     white-space: nowrap; | ||||||
|  | } | ||||||
							
								
								
									
										34
									
								
								app/Server/Database/DatabaseExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								app/Server/Database/DatabaseExtensions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | |||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Linq; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  | using DHT.Server.Data; | ||||||
|  |  | ||||||
|  | namespace DHT.Server.Database; | ||||||
|  |  | ||||||
|  | public static class DatabaseExtensions { | ||||||
|  | 	public static async Task AddFrom(this IDatabaseFile target, IDatabaseFile source) { | ||||||
|  | 		await target.Users.Add(await source.Users.Get().ToListAsync()); | ||||||
|  | 		await target.Servers.Add(await source.Servers.Get().ToListAsync()); | ||||||
|  | 		await target.Channels.Add(await source.Channels.Get().ToListAsync()); | ||||||
|  | 		 | ||||||
|  | 		const int MessageBatchSize = 100; | ||||||
|  | 		List<Message> batchedMessages = new (MessageBatchSize); | ||||||
|  | 		 | ||||||
|  | 		await foreach (Message message in source.Messages.Get()) { | ||||||
|  | 			batchedMessages.Add(message); | ||||||
|  | 			 | ||||||
|  | 			if (batchedMessages.Count >= MessageBatchSize) { | ||||||
|  | 				await target.Messages.Add(batchedMessages); | ||||||
|  | 				batchedMessages.Clear(); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		 | ||||||
|  | 		await target.Messages.Add(batchedMessages); | ||||||
|  | 		 | ||||||
|  | 		await foreach (Data.Download download in source.Downloads.Get()) { | ||||||
|  | 			if (download.Status != DownloadStatus.Success || !await source.Downloads.GetDownloadData(download.NormalizedUrl, stream => target.Downloads.AddDownload(download, stream))) { | ||||||
|  | 				await target.Downloads.AddDownload(download, stream: null); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
| @@ -1,77 +0,0 @@ | |||||||
| using System.Collections.Generic; |  | ||||||
| using System.Linq; |  | ||||||
| using System.Threading.Tasks; |  | ||||||
| using DHT.Server.Data; |  | ||||||
|  |  | ||||||
| namespace DHT.Server.Database.Import; |  | ||||||
|  |  | ||||||
| public static class DatabaseMerging { |  | ||||||
| 	public static async Task Merge(this IDatabaseFile target, IDatabaseFile source, IProgressCallback callback) { |  | ||||||
| 		// Import downloads first, otherwise automatic downloads would try to re-download files from other imported data. |  | ||||||
| 		await MergeDownloads(target, source, callback); |  | ||||||
| 		 |  | ||||||
| 		callback.OnImportingMetadata(); |  | ||||||
| 		await target.Users.Add(await source.Users.Get().ToListAsync()); |  | ||||||
| 		await target.Servers.Add(await source.Servers.Get().ToListAsync()); |  | ||||||
| 		await target.Channels.Add(await source.Channels.Get().ToListAsync()); |  | ||||||
| 		 |  | ||||||
| 		await MergeMessages(target, source, callback); |  | ||||||
| 	} |  | ||||||
| 	 |  | ||||||
| 	private static async Task MergeDownloads(IDatabaseFile target, IDatabaseFile source, IProgressCallback callback) { |  | ||||||
| 		const int ReportBatchSize = 100; |  | ||||||
| 		 |  | ||||||
| 		long totalDownloads = await source.Downloads.Count(); |  | ||||||
| 		long importedDownloads = 0; |  | ||||||
| 		 |  | ||||||
| 		callback.OnDownloadsImported(importedDownloads, totalDownloads); |  | ||||||
| 		 |  | ||||||
| 		await foreach (Data.Download download in source.Downloads.Get()) { |  | ||||||
| 			if (download.Status != DownloadStatus.Success || !await source.Downloads.GetDownloadData(download.NormalizedUrl, stream => target.Downloads.AddDownload(download, stream))) { |  | ||||||
| 				await target.Downloads.AddDownload(download, stream: null); |  | ||||||
| 			} |  | ||||||
| 			 |  | ||||||
| 			if (++importedDownloads % ReportBatchSize == 0) { |  | ||||||
| 				callback.OnDownloadsImported(importedDownloads, totalDownloads); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 		 |  | ||||||
| 		callback.OnDownloadsImported(totalDownloads, totalDownloads); |  | ||||||
| 	} |  | ||||||
| 	 |  | ||||||
| 	private static async Task MergeMessages(IDatabaseFile target, IDatabaseFile source, IProgressCallback callback) { |  | ||||||
| 		const int MessageBatchSize = 100; |  | ||||||
| 		const int ReportEveryBatches = 10; |  | ||||||
| 		List<Message> batchedMessages = new (MessageBatchSize); |  | ||||||
| 		 |  | ||||||
| 		long totalMessages = await source.Messages.Count(); |  | ||||||
| 		long importedMessages = 0; |  | ||||||
| 		 |  | ||||||
| 		callback.OnMessagesImported(importedMessages, totalMessages); |  | ||||||
| 		 |  | ||||||
| 		await foreach (Message message in source.Messages.Get()) { |  | ||||||
| 			batchedMessages.Add(message); |  | ||||||
| 			 |  | ||||||
| 			if (batchedMessages.Count >= MessageBatchSize) { |  | ||||||
| 				await target.Messages.Add(batchedMessages); |  | ||||||
| 				 |  | ||||||
| 				importedMessages += batchedMessages.Count; |  | ||||||
| 				 |  | ||||||
| 				if (importedMessages % (MessageBatchSize * ReportEveryBatches) == 0) { |  | ||||||
| 					callback.OnMessagesImported(importedMessages, totalMessages); |  | ||||||
| 				} |  | ||||||
| 				 |  | ||||||
| 				batchedMessages.Clear(); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 		 |  | ||||||
| 		await target.Messages.Add(batchedMessages); |  | ||||||
| 		callback.OnMessagesImported(totalMessages, totalMessages); |  | ||||||
| 	} |  | ||||||
| 	 |  | ||||||
| 	public interface IProgressCallback { |  | ||||||
| 		void OnImportingMetadata(); |  | ||||||
| 		void OnMessagesImported(long finished, long total); |  | ||||||
| 		void OnDownloadsImported(long finished, long total); |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| @@ -1,14 +1,15 @@ | |||||||
|  | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using System.Linq; | using System.Linq; | ||||||
|  | using System.Reactive.Linq; | ||||||
| using System.Threading; | using System.Threading; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
| using DHT.Server.Data; | using DHT.Server.Data; | ||||||
| using DHT.Utils.Observables; |  | ||||||
|  |  | ||||||
| namespace DHT.Server.Database.Repositories; | namespace DHT.Server.Database.Repositories; | ||||||
|  |  | ||||||
| public interface IChannelRepository { | public interface IChannelRepository { | ||||||
| 	ObservableValue<long> TotalCount { get; } | 	IObservable<long> TotalCount { get; } | ||||||
| 	 | 	 | ||||||
| 	Task Add(IReadOnlyList<Channel> channels); | 	Task Add(IReadOnlyList<Channel> channels); | ||||||
| 	 | 	 | ||||||
| @@ -19,7 +20,7 @@ public interface IChannelRepository { | |||||||
| 	Task<int> RemoveUnreachable(); | 	Task<int> RemoveUnreachable(); | ||||||
| 	 | 	 | ||||||
| 	internal sealed class Dummy : IChannelRepository { | 	internal sealed class Dummy : IChannelRepository { | ||||||
| 		public ObservableValue<long> TotalCount { get; } = new (0L); | 		public IObservable<long> TotalCount { get; } = Observable.Return(0L); | ||||||
| 		 | 		 | ||||||
| 		public Task Add(IReadOnlyList<Channel> channels) { | 		public Task Add(IReadOnlyList<Channel> channels) { | ||||||
| 			return Task.CompletedTask; | 			return Task.CompletedTask; | ||||||
|   | |||||||
| @@ -2,22 +2,22 @@ using System; | |||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using System.IO; | using System.IO; | ||||||
| using System.Linq; | using System.Linq; | ||||||
|  | using System.Reactive.Linq; | ||||||
| using System.Threading; | using System.Threading; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
| using DHT.Server.Data; | using DHT.Server.Data; | ||||||
| using DHT.Server.Data.Aggregations; | using DHT.Server.Data.Aggregations; | ||||||
| using DHT.Server.Data.Filters; | using DHT.Server.Data.Filters; | ||||||
| using DHT.Server.Download; | using DHT.Server.Download; | ||||||
| using DHT.Utils.Observables; |  | ||||||
|  |  | ||||||
| namespace DHT.Server.Database.Repositories; | namespace DHT.Server.Database.Repositories; | ||||||
|  |  | ||||||
| public interface IDownloadRepository { | public interface IDownloadRepository { | ||||||
| 	ObservableValue<long> TotalCount { get; } | 	IObservable<long> TotalCount { get; } | ||||||
| 	 | 	 | ||||||
| 	Task AddDownload(Data.Download item, Stream? stream); | 	Task AddDownload(Data.Download item, Stream? stream); | ||||||
| 	 | 	 | ||||||
| 	Task<long> Count(DownloadItemFilter? filter = null, CancellationToken cancellationToken = default); | 	Task<long> Count(DownloadItemFilter filter, CancellationToken cancellationToken = default); | ||||||
| 	 | 	 | ||||||
| 	Task<DownloadStatusStatistics> GetStatistics(DownloadItemFilter nonSkippedFilter, CancellationToken cancellationToken = default); | 	Task<DownloadStatusStatistics> GetStatistics(DownloadItemFilter nonSkippedFilter, CancellationToken cancellationToken = default); | ||||||
| 	 | 	 | ||||||
| @@ -38,13 +38,13 @@ public interface IDownloadRepository { | |||||||
| 	IAsyncEnumerable<FileUrl> FindReachableFiles(CancellationToken cancellationToken = default); | 	IAsyncEnumerable<FileUrl> FindReachableFiles(CancellationToken cancellationToken = default); | ||||||
| 	 | 	 | ||||||
| 	internal sealed class Dummy : IDownloadRepository { | 	internal sealed class Dummy : IDownloadRepository { | ||||||
| 		public ObservableValue<long> TotalCount { get; } = new (0L); | 		public IObservable<long> TotalCount { get; } = Observable.Return(0L); | ||||||
| 		 | 		 | ||||||
| 		public Task AddDownload(Data.Download item, Stream? stream) { | 		public Task AddDownload(Data.Download item, Stream? stream) { | ||||||
| 			return Task.CompletedTask; | 			return Task.CompletedTask; | ||||||
| 		} | 		} | ||||||
| 		 | 		 | ||||||
| 		public Task<long> Count(DownloadItemFilter? filter, CancellationToken cancellationToken) { | 		public Task<long> Count(DownloadItemFilter filter, CancellationToken cancellationToken) { | ||||||
| 			return Task.FromResult(0L); | 			return Task.FromResult(0L); | ||||||
| 		} | 		} | ||||||
| 		 | 		 | ||||||
|   | |||||||
| @@ -1,15 +1,16 @@ | |||||||
|  | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using System.Linq; | using System.Linq; | ||||||
|  | using System.Reactive.Linq; | ||||||
| using System.Threading; | using System.Threading; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
| using DHT.Server.Data; | using DHT.Server.Data; | ||||||
| using DHT.Server.Data.Filters; | using DHT.Server.Data.Filters; | ||||||
| using DHT.Utils.Observables; |  | ||||||
|  |  | ||||||
| namespace DHT.Server.Database.Repositories; | namespace DHT.Server.Database.Repositories; | ||||||
|  |  | ||||||
| public interface IMessageRepository { | public interface IMessageRepository { | ||||||
| 	ObservableValue<long> TotalCount { get; } | 	IObservable<long> TotalCount { get; } | ||||||
| 	 | 	 | ||||||
| 	Task Add(IReadOnlyList<Message> messages); | 	Task Add(IReadOnlyList<Message> messages); | ||||||
| 	 | 	 | ||||||
| @@ -24,7 +25,7 @@ public interface IMessageRepository { | |||||||
| 	Task<int> RemoveUnreachableAttachments(); | 	Task<int> RemoveUnreachableAttachments(); | ||||||
| 	 | 	 | ||||||
| 	internal sealed class Dummy : IMessageRepository { | 	internal sealed class Dummy : IMessageRepository { | ||||||
| 		public ObservableValue<long> TotalCount { get; } = new (0L); | 		public IObservable<long> TotalCount { get; } = Observable.Return(0L); | ||||||
| 		 | 		 | ||||||
| 		public Task Add(IReadOnlyList<Message> messages) { | 		public Task Add(IReadOnlyList<Message> messages) { | ||||||
| 			return Task.CompletedTask; | 			return Task.CompletedTask; | ||||||
|   | |||||||
| @@ -1,13 +1,14 @@ | |||||||
|  | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using System.Linq; | using System.Linq; | ||||||
|  | using System.Reactive.Linq; | ||||||
| using System.Threading; | using System.Threading; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
| using DHT.Utils.Observables; |  | ||||||
|  |  | ||||||
| namespace DHT.Server.Database.Repositories; | namespace DHT.Server.Database.Repositories; | ||||||
|  |  | ||||||
| public interface IServerRepository { | public interface IServerRepository { | ||||||
| 	ObservableValue<long> TotalCount { get; } | 	IObservable<long> TotalCount { get; } | ||||||
| 	 | 	 | ||||||
| 	Task Add(IReadOnlyList<Data.Server> servers); | 	Task Add(IReadOnlyList<Data.Server> servers); | ||||||
| 	 | 	 | ||||||
| @@ -18,7 +19,7 @@ public interface IServerRepository { | |||||||
| 	Task<int> RemoveUnreachable(); | 	Task<int> RemoveUnreachable(); | ||||||
| 	 | 	 | ||||||
| 	internal sealed class Dummy : IServerRepository { | 	internal sealed class Dummy : IServerRepository { | ||||||
| 		public ObservableValue<long> TotalCount { get; } = new (0L); | 		public IObservable<long> TotalCount { get; } = Observable.Return(0L); | ||||||
| 		 | 		 | ||||||
| 		public Task Add(IReadOnlyList<Data.Server> servers) { | 		public Task Add(IReadOnlyList<Data.Server> servers) { | ||||||
| 			return Task.CompletedTask; | 			return Task.CompletedTask; | ||||||
|   | |||||||
| @@ -1,14 +1,15 @@ | |||||||
|  | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using System.Linq; | using System.Linq; | ||||||
|  | using System.Reactive.Linq; | ||||||
| using System.Threading; | using System.Threading; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
| using DHT.Server.Data; | using DHT.Server.Data; | ||||||
| using DHT.Utils.Observables; |  | ||||||
|  |  | ||||||
| namespace DHT.Server.Database.Repositories; | namespace DHT.Server.Database.Repositories; | ||||||
|  |  | ||||||
| public interface IUserRepository { | public interface IUserRepository { | ||||||
| 	ObservableValue<long> TotalCount { get; } | 	IObservable<long> TotalCount { get; } | ||||||
| 	 | 	 | ||||||
| 	Task Add(IReadOnlyList<User> users); | 	Task Add(IReadOnlyList<User> users); | ||||||
| 	 | 	 | ||||||
| @@ -19,7 +20,7 @@ public interface IUserRepository { | |||||||
| 	Task<int> RemoveUnreachable(); | 	Task<int> RemoveUnreachable(); | ||||||
| 	 | 	 | ||||||
| 	internal sealed class Dummy : IUserRepository { | 	internal sealed class Dummy : IUserRepository { | ||||||
| 		public ObservableValue<long> TotalCount { get; } = new (0L); | 		public IObservable<long> TotalCount { get; } = Observable.Return(0L); | ||||||
| 		 | 		 | ||||||
| 		public Task Add(IReadOnlyList<User> users) { | 		public Task Add(IReadOnlyList<User> users) { | ||||||
| 			return Task.CompletedTask; | 			return Task.CompletedTask; | ||||||
|   | |||||||
| @@ -1,27 +1,23 @@ | |||||||
| using System; | using System; | ||||||
|  | using System.Reactive.Linq; | ||||||
| using System.Threading; | using System.Threading; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
| using DHT.Utils.Logging; | using DHT.Utils.Logging; | ||||||
| using DHT.Utils.Observables; |  | ||||||
| using DHT.Utils.Tasks; | using DHT.Utils.Tasks; | ||||||
|  |  | ||||||
| namespace DHT.Server.Database.Sqlite.Repositories; | namespace DHT.Server.Database.Sqlite.Repositories; | ||||||
|  |  | ||||||
| abstract class BaseSqliteRepository : IDisposable { | abstract class BaseSqliteRepository : IDisposable { | ||||||
| 	private readonly ThrottledTask<long> totalCountTask; | 	private readonly ObservableThrottledTask<long> totalCountTask; | ||||||
| 	 | 	 | ||||||
| 	public ObservableValue<long> TotalCount { get; } = new (0L); | 	public IObservable<long> TotalCount { get; } | ||||||
| 	 | 	 | ||||||
| 	protected BaseSqliteRepository(Log log) { | 	protected BaseSqliteRepository(Log log) { | ||||||
| 		totalCountTask = new ThrottledTask<long>(log, SetTotalCount, TimeSpan.Zero, TaskScheduler.Default); | 		totalCountTask = new ObservableThrottledTask<long>(log, TaskScheduler.Default); | ||||||
|  | 		TotalCount = totalCountTask.DistinctUntilChanged(); | ||||||
| 		UpdateTotalCount(); | 		UpdateTotalCount(); | ||||||
| 	} | 	} | ||||||
| 	 | 	 | ||||||
| 	private Task SetTotalCount(long newCount) { |  | ||||||
| 		TotalCount.Set(newCount); |  | ||||||
| 		return Task.CompletedTask; |  | ||||||
| 	} |  | ||||||
| 	 |  | ||||||
| 	public void Dispose() { | 	public void Dispose() { | ||||||
| 		totalCountTask.Dispose(); | 		totalCountTask.Dispose(); | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -306,14 +306,12 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep | |||||||
| 			await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); | 			await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); | ||||||
| 			 | 			 | ||||||
| 			while (await reader.ReadAsync(cancellationToken)) { | 			while (await reader.ReadAsync(cancellationToken)) { | ||||||
| 				var item = new DownloadItem( | 				found.Add(new DownloadItem { | ||||||
| 					NormalizedUrl: reader.GetString(0), | 					NormalizedUrl = reader.GetString(0), | ||||||
| 					DownloadUrl: reader.GetString(1), | 					DownloadUrl = reader.GetString(1), | ||||||
| 					Type: reader.IsDBNull(2) ? null : reader.GetString(2), | 					Type = reader.IsDBNull(2) ? null : reader.GetString(2), | ||||||
| 					Size: reader.IsDBNull(3) ? null : reader.GetUint64(3) | 					Size = reader.IsDBNull(3) ? null : reader.GetUint64(3), | ||||||
| 				); | 				}); | ||||||
| 				 |  | ||||||
| 				found.Add(item); |  | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		 | 		 | ||||||
|   | |||||||
| @@ -4,7 +4,12 @@ using DHT.Server.Data; | |||||||
|  |  | ||||||
| namespace DHT.Server.Download; | namespace DHT.Server.Download; | ||||||
|  |  | ||||||
| public sealed record DownloadItem(string NormalizedUrl, string DownloadUrl, string? Type, ulong? Size) { | public readonly struct DownloadItem { | ||||||
|  | 	public string NormalizedUrl { get; init; } | ||||||
|  | 	public string DownloadUrl { get; init; } | ||||||
|  | 	public string? Type { get; init; } | ||||||
|  | 	public ulong? Size { get; init; } | ||||||
|  | 	 | ||||||
| 	internal Data.Download ToSuccess(long size) { | 	internal Data.Download ToSuccess(long size) { | ||||||
| 		return new Data.Download(NormalizedUrl, DownloadUrl, DownloadStatus.Success, Type, (ulong) Math.Max(size, val2: 0)); | 		return new Data.Download(NormalizedUrl, DownloadUrl, DownloadStatus.Success, Type, (ulong) Math.Max(size, val2: 0)); | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -1,8 +1,8 @@ | |||||||
|  | using System; | ||||||
| using System.Threading; | using System.Threading; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
| using DHT.Server.Data.Filters; | using DHT.Server.Data.Filters; | ||||||
| using DHT.Server.Database; | using DHT.Server.Database; | ||||||
| using DHT.Utils.Observables; |  | ||||||
|  |  | ||||||
| namespace DHT.Server.Download; | namespace DHT.Server.Download; | ||||||
|  |  | ||||||
| @@ -19,11 +19,11 @@ public sealed class Downloader { | |||||||
| 		this.concurrentDownloads = concurrentDownloads; | 		this.concurrentDownloads = concurrentDownloads; | ||||||
| 	} | 	} | ||||||
| 	 | 	 | ||||||
| 	public async Task<ObservableValue<DownloadItem>> Start(DownloadItemFilter filter) { | 	public async Task<IObservable<DownloadItem>> Start(DownloadItemFilter filter) { | ||||||
| 		await semaphore.WaitAsync(); | 		await semaphore.WaitAsync(); | ||||||
| 		try { | 		try { | ||||||
| 			current ??= new DownloaderTask(db, filter, concurrentDownloads); | 			current ??= new DownloaderTask(db, filter, concurrentDownloads); | ||||||
| 			return current.LastFinishedItem; | 			return current.FinishedItems; | ||||||
| 		} finally { | 		} finally { | ||||||
| 			semaphore.Release(); | 			semaphore.Release(); | ||||||
| 		} | 		} | ||||||
|   | |||||||
| @@ -4,13 +4,13 @@ using System.IO; | |||||||
| using System.Linq; | using System.Linq; | ||||||
| using System.Net; | using System.Net; | ||||||
| using System.Net.Http; | using System.Net.Http; | ||||||
|  | using System.Reactive.Subjects; | ||||||
| using System.Threading; | using System.Threading; | ||||||
| using System.Threading.Channels; | using System.Threading.Channels; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
| using DHT.Server.Data.Filters; | using DHT.Server.Data.Filters; | ||||||
| using DHT.Server.Database; | using DHT.Server.Database; | ||||||
| using DHT.Utils.Logging; | using DHT.Utils.Logging; | ||||||
| using DHT.Utils.Observables; |  | ||||||
| using DHT.Utils.Tasks; | using DHT.Utils.Tasks; | ||||||
|  |  | ||||||
| namespace DHT.Server.Download; | namespace DHT.Server.Download; | ||||||
| @@ -38,11 +38,12 @@ sealed class DownloaderTask : IAsyncDisposable { | |||||||
| 	 | 	 | ||||||
| 	private readonly IDatabaseFile db; | 	private readonly IDatabaseFile db; | ||||||
| 	private readonly DownloadItemFilter filter; | 	private readonly DownloadItemFilter filter; | ||||||
|  | 	private readonly ISubject<DownloadItem> finishedItemPublisher = Subject.Synchronize(new Subject<DownloadItem>()); | ||||||
| 	 | 	 | ||||||
| 	private readonly Task queueWriterTask; | 	private readonly Task queueWriterTask; | ||||||
| 	private readonly Task[] downloadTasks; | 	private readonly Task[] downloadTasks; | ||||||
| 	 | 	 | ||||||
| 	public ObservableValue<DownloadItem> LastFinishedItem { get; } = new (null); | 	public IObservable<DownloadItem> FinishedItems => finishedItemPublisher; | ||||||
| 	 | 	 | ||||||
| 	internal DownloaderTask(IDatabaseFile db, DownloadItemFilter filter, int? concurrentDownloads) { | 	internal DownloaderTask(IDatabaseFile db, DownloadItemFilter filter, int? concurrentDownloads) { | ||||||
| 		this.db = db; | 		this.db = db; | ||||||
| @@ -100,7 +101,7 @@ sealed class DownloaderTask : IAsyncDisposable { | |||||||
| 				log.Error("Could not download file: " + item.DownloadUrl, e); | 				log.Error("Could not download file: " + item.DownloadUrl, e); | ||||||
| 			} finally { | 			} finally { | ||||||
| 				try { | 				try { | ||||||
| 					LastFinishedItem.Set(item); | 					finishedItemPublisher.OnNext(item); | ||||||
| 				} catch (Exception e) { | 				} catch (Exception e) { | ||||||
| 					log.Error("Caught exception in event handler: " + e); | 					log.Error("Caught exception in event handler: " + e); | ||||||
| 				} | 				} | ||||||
| @@ -144,6 +145,7 @@ sealed class DownloaderTask : IAsyncDisposable { | |||||||
| 			await Task.WhenAll(downloadTasks).WaitIgnoringCancellation(); | 			await Task.WhenAll(downloadTasks).WaitIgnoringCancellation(); | ||||||
| 		} finally { | 		} finally { | ||||||
| 			cancellationTokenSource.Dispose(); | 			cancellationTokenSource.Dispose(); | ||||||
|  | 			finishedItemPublisher.OnCompleted(); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,63 +0,0 @@ | |||||||
| using System; |  | ||||||
| using System.Threading; |  | ||||||
| using System.Threading.Channels; |  | ||||||
| using System.Threading.Tasks; |  | ||||||
|  |  | ||||||
| namespace DHT.Utils.Observables; |  | ||||||
|  |  | ||||||
| sealed class LastValueObserver<T> : IDisposable { |  | ||||||
| 	private readonly ObservableValue<T> observable; |  | ||||||
| 	private readonly Func<T, CancellationToken, Task> action; |  | ||||||
| 	private readonly TaskScheduler scheduler; |  | ||||||
| 	 |  | ||||||
| 	private readonly Channel<T> channel = Channel.CreateBounded<T>(new BoundedChannelOptions(capacity: 1) { |  | ||||||
| 		AllowSynchronousContinuations = false, |  | ||||||
| 		FullMode = BoundedChannelFullMode.DropOldest, |  | ||||||
| 		SingleReader = true, |  | ||||||
| 		SingleWriter = false, |  | ||||||
| 	}); |  | ||||||
| 	 |  | ||||||
| 	private readonly CancellationTokenSource cancellationTokenSource = new (); |  | ||||||
| 	 |  | ||||||
| 	public LastValueObserver(ObservableValue<T> observable, Func<T, CancellationToken, Task> action, TaskScheduler scheduler) { |  | ||||||
| 		this.observable = observable; |  | ||||||
| 		this.action = action; |  | ||||||
| 		this.scheduler = scheduler; |  | ||||||
| 		 |  | ||||||
| 		_ = ReadNextValue(); |  | ||||||
| 	} |  | ||||||
| 	 |  | ||||||
| 	private async Task ReadNextValue() { |  | ||||||
| 		CancellationToken cancellationToken = cancellationTokenSource.Token; |  | ||||||
| 		 |  | ||||||
| 		try { |  | ||||||
| 			await foreach (T value in channel.Reader.ReadAllAsync(cancellationToken)) { |  | ||||||
| 				try { |  | ||||||
| 					await Task.Factory.StartNew(UseValue, value, CancellationToken.None, TaskCreationOptions.None, scheduler).WaitAsync(cancellationToken); |  | ||||||
| 				} catch (Exception) { |  | ||||||
| 					// Ignore. |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 		} finally { |  | ||||||
| 			cancellationTokenSource.Dispose(); |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	 |  | ||||||
| 	private Task UseValue(object? value) { |  | ||||||
| 		return action((T) value!, cancellationTokenSource.Token); |  | ||||||
| 	} |  | ||||||
| 	 |  | ||||||
| 	public void Notify(T value) { |  | ||||||
| 		channel.Writer.TryWrite(value); |  | ||||||
| 	} |  | ||||||
| 	 |  | ||||||
| 	public void Dispose() { |  | ||||||
| 		observable.Unsubscribe(this); |  | ||||||
| 		 |  | ||||||
| 		try { |  | ||||||
| 			cancellationTokenSource.Cancel(); |  | ||||||
| 		} catch (ObjectDisposedException) {} |  | ||||||
| 		 |  | ||||||
| 		channel.Writer.TryComplete(); |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| @@ -1,45 +0,0 @@ | |||||||
| using System; |  | ||||||
| using System.Collections.Generic; |  | ||||||
| using System.Threading; |  | ||||||
| using System.Threading.Tasks; |  | ||||||
|  |  | ||||||
| namespace DHT.Utils.Observables; |  | ||||||
|  |  | ||||||
| public sealed class ObservableValue<T>(T? value) { |  | ||||||
| 	private readonly List<LastValueObserver<T>> observers = []; |  | ||||||
| 	private T? value = value; |  | ||||||
| 	 |  | ||||||
| 	public void Set(T value) { |  | ||||||
| 		lock (this) { |  | ||||||
| 			if (EqualityComparer<T>.Default.Equals(value, this.value)) { |  | ||||||
| 				return; |  | ||||||
| 			} |  | ||||||
| 			 |  | ||||||
| 			this.value = value; |  | ||||||
| 			 |  | ||||||
| 			foreach (var observer in observers) { |  | ||||||
| 				observer.Notify(value); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	 |  | ||||||
| 	public IDisposable SubscribeLast(Func<T, CancellationToken, Task> action, TaskScheduler scheduler) { |  | ||||||
| 		var observer = new LastValueObserver<T>(this, action, scheduler); |  | ||||||
| 		 |  | ||||||
| 		lock (this) { |  | ||||||
| 			observers.Add(observer); |  | ||||||
| 			 |  | ||||||
| 			if (value is not null) { |  | ||||||
| 				observer.Notify(value); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 		 |  | ||||||
| 		return observer; |  | ||||||
| 	} |  | ||||||
| 	 |  | ||||||
| 	internal void Unsubscribe(LastValueObserver<T> observer) { |  | ||||||
| 		lock (this) { |  | ||||||
| 			observers.Remove(observer); |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
							
								
								
									
										64
									
								
								app/Utils/Tasks/DelayedThrottledTask.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								app/Utils/Tasks/DelayedThrottledTask.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | |||||||
|  | using System; | ||||||
|  | using System.Threading; | ||||||
|  | using System.Threading.Channels; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  | using DHT.Utils.Logging; | ||||||
|  |  | ||||||
|  | namespace DHT.Utils.Tasks; | ||||||
|  |  | ||||||
|  | public sealed class DelayedThrottledTask<T> : IDisposable { | ||||||
|  | 	private readonly Channel<T> taskChannel = Channel.CreateBounded<T>(new BoundedChannelOptions(capacity: 1) { | ||||||
|  | 		SingleReader = true, | ||||||
|  | 		SingleWriter = false, | ||||||
|  | 		AllowSynchronousContinuations = false, | ||||||
|  | 		FullMode = BoundedChannelFullMode.DropOldest, | ||||||
|  | 	}); | ||||||
|  | 	 | ||||||
|  | 	private readonly CancellationTokenSource cancellationTokenSource = new (); | ||||||
|  | 	private readonly Log log; | ||||||
|  | 	private readonly TimeSpan delay; | ||||||
|  | 	private readonly Func<T, Task> inputProcessor; | ||||||
|  | 	 | ||||||
|  | 	public DelayedThrottledTask(Log log, TimeSpan delay, Func<T, Task> inputProcessor) { | ||||||
|  | 		this.log = log; | ||||||
|  | 		this.delay = delay; | ||||||
|  | 		this.inputProcessor = inputProcessor; | ||||||
|  | 		 | ||||||
|  | 		Task.Run(ReaderTask); | ||||||
|  | 	} | ||||||
|  | 	 | ||||||
|  | 	private async Task ReaderTask() { | ||||||
|  | 		CancellationToken cancellationToken = cancellationTokenSource.Token; | ||||||
|  | 		 | ||||||
|  | 		try { | ||||||
|  | 			while (await taskChannel.Reader.WaitToReadAsync(cancellationToken)) { | ||||||
|  | 				await Task.Delay(delay, cancellationToken); | ||||||
|  | 				 | ||||||
|  | 				T input = await taskChannel.Reader.ReadAsync(cancellationToken); | ||||||
|  | 				try { | ||||||
|  | 					await inputProcessor(input); | ||||||
|  | 				} catch (OperationCanceledException) { | ||||||
|  | 					throw; | ||||||
|  | 				} catch (Exception e) { | ||||||
|  | 					log.Error("Caught exception in task: " + e); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} catch (OperationCanceledException) { | ||||||
|  | 			// Ignore. | ||||||
|  | 		} finally { | ||||||
|  | 			cancellationTokenSource.Dispose(); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	 | ||||||
|  | 	public void Post(T input) { | ||||||
|  | 		taskChannel.Writer.TryWrite(input); | ||||||
|  | 	} | ||||||
|  | 	 | ||||||
|  | 	public void Dispose() { | ||||||
|  | 		try { | ||||||
|  | 			cancellationTokenSource.Cancel(); | ||||||
|  | 		} catch (ObjectDisposedException) {} | ||||||
|  | 		 | ||||||
|  | 		taskChannel.Writer.Complete(); | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										32
									
								
								app/Utils/Tasks/ObservableThrottledTask.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								app/Utils/Tasks/ObservableThrottledTask.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | |||||||
|  | using System; | ||||||
|  | using System.Reactive.Subjects; | ||||||
|  | using System.Threading; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  | using DHT.Utils.Logging; | ||||||
|  |  | ||||||
|  | namespace DHT.Utils.Tasks; | ||||||
|  |  | ||||||
|  | public sealed class ObservableThrottledTask<T> : IObservable<T>, IDisposable { | ||||||
|  | 	private readonly ReplaySubject<T> subject; | ||||||
|  | 	private readonly ThrottledTask<T> task; | ||||||
|  | 	 | ||||||
|  | 	public ObservableThrottledTask(Log log, TaskScheduler resultScheduler) { | ||||||
|  | 		this.subject = new ReplaySubject<T>(bufferSize: 1); | ||||||
|  | 		this.task = new ThrottledTask<T>(log, subject.OnNext, resultScheduler); | ||||||
|  | 	} | ||||||
|  | 	 | ||||||
|  | 	public void Post(Func<CancellationToken, Task<T>> resultComputer) { | ||||||
|  | 		task.Post(resultComputer); | ||||||
|  | 	} | ||||||
|  | 	 | ||||||
|  | 	public IDisposable Subscribe(IObserver<T> observer) { | ||||||
|  | 		return subject.Subscribe(observer); | ||||||
|  | 	} | ||||||
|  | 	 | ||||||
|  | 	public void Dispose() { | ||||||
|  | 		task.Dispose(); | ||||||
|  | 		 | ||||||
|  | 		subject.OnCompleted(); | ||||||
|  | 		subject.Dispose(); | ||||||
|  | 	} | ||||||
|  | } | ||||||
| @@ -14,13 +14,11 @@ public abstract class ThrottledTaskBase<T> : IDisposable { | |||||||
| 		FullMode = BoundedChannelFullMode.DropOldest, | 		FullMode = BoundedChannelFullMode.DropOldest, | ||||||
| 	}); | 	}); | ||||||
| 	 | 	 | ||||||
| 	private readonly Log log; |  | ||||||
| 	private readonly TimeSpan delayBetweenRuns; |  | ||||||
| 	private readonly CancellationTokenSource cancellationTokenSource = new (); | 	private readonly CancellationTokenSource cancellationTokenSource = new (); | ||||||
|  | 	private readonly Log log; | ||||||
| 	 | 	 | ||||||
| 	internal ThrottledTaskBase(Log log, TimeSpan delayBetweenRuns) { | 	internal ThrottledTaskBase(Log log) { | ||||||
| 		this.log = log; | 		this.log = log; | ||||||
| 		this.delayBetweenRuns = delayBetweenRuns; |  | ||||||
| 	} | 	} | ||||||
| 	 | 	 | ||||||
| 	protected async Task ReaderTask() { | 	protected async Task ReaderTask() { | ||||||
| @@ -35,9 +33,9 @@ public abstract class ThrottledTaskBase<T> : IDisposable { | |||||||
| 				} catch (Exception e) { | 				} catch (Exception e) { | ||||||
| 					log.Error("Caught exception in task: " + e); | 					log.Error("Caught exception in task: " + e); | ||||||
| 				} | 				} | ||||||
| 				 |  | ||||||
| 				await Task.Delay(delayBetweenRuns, cancellationToken); |  | ||||||
| 			} | 			} | ||||||
|  | 		} catch (OperationCanceledException) { | ||||||
|  | 			// Ignore. | ||||||
| 		} finally { | 		} finally { | ||||||
| 			cancellationTokenSource.Dispose(); | 			cancellationTokenSource.Dispose(); | ||||||
| 		} | 		} | ||||||
| @@ -50,20 +48,20 @@ public abstract class ThrottledTaskBase<T> : IDisposable { | |||||||
| 	} | 	} | ||||||
| 	 | 	 | ||||||
| 	public void Dispose() { | 	public void Dispose() { | ||||||
| 		cancellationTokenSource.Cancel(); |  | ||||||
| 		taskChannel.Writer.Complete(); | 		taskChannel.Writer.Complete(); | ||||||
|  | 		cancellationTokenSource.Cancel(); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| public sealed class ThrottledTask : ThrottledTaskBase<Task> { | public sealed class ThrottledTask : ThrottledTaskBase<Task> { | ||||||
| 	private readonly Func<Task> resultProcessor; | 	private readonly Action resultProcessor; | ||||||
| 	private readonly TaskScheduler resultScheduler; | 	private readonly TaskScheduler resultScheduler; | ||||||
| 	 | 	 | ||||||
| 	public ThrottledTask(Log log, Func<Task> resultProcessor, TimeSpan delayBetweenRuns, TaskScheduler resultScheduler) : base(log, delayBetweenRuns) { | 	public ThrottledTask(Log log, Action resultProcessor, TaskScheduler resultScheduler) : base(log) { | ||||||
| 		this.resultProcessor = resultProcessor; | 		this.resultProcessor = resultProcessor; | ||||||
| 		this.resultScheduler = resultScheduler; | 		this.resultScheduler = resultScheduler; | ||||||
| 		 | 		 | ||||||
| 		_ = ReaderTask(); | 		Task.Run(ReaderTask); | ||||||
| 	} | 	} | ||||||
| 	 | 	 | ||||||
| 	protected override async Task Run(Func<CancellationToken, Task> func, CancellationToken cancellationToken) { | 	protected override async Task Run(Func<CancellationToken, Task> func, CancellationToken cancellationToken) { | ||||||
| @@ -73,22 +71,18 @@ public sealed class ThrottledTask : ThrottledTaskBase<Task> { | |||||||
| } | } | ||||||
|  |  | ||||||
| public sealed class ThrottledTask<T> : ThrottledTaskBase<Task<T>> { | public sealed class ThrottledTask<T> : ThrottledTaskBase<Task<T>> { | ||||||
| 	private readonly Func<T, Task> resultProcessor; | 	private readonly Action<T> resultProcessor; | ||||||
| 	private readonly TaskScheduler resultScheduler; | 	private readonly TaskScheduler resultScheduler; | ||||||
| 	 | 	 | ||||||
| 	public ThrottledTask(Log log, Func<T, Task> resultProcessor, TimeSpan delayBetweenRuns, TaskScheduler resultScheduler) : base(log, delayBetweenRuns) { | 	public ThrottledTask(Log log, Action<T> resultProcessor, TaskScheduler resultScheduler) : base(log) { | ||||||
| 		this.resultProcessor = resultProcessor; | 		this.resultProcessor = resultProcessor; | ||||||
| 		this.resultScheduler = resultScheduler; | 		this.resultScheduler = resultScheduler; | ||||||
| 		 | 		 | ||||||
| 		_ = ReaderTask(); | 		Task.Run(ReaderTask); | ||||||
| 	} | 	} | ||||||
| 	 | 	 | ||||||
| 	protected override async Task Run(Func<CancellationToken, Task<T>> func, CancellationToken cancellationToken) { | 	protected override async Task Run(Func<CancellationToken, Task<T>> func, CancellationToken cancellationToken) { | ||||||
| 		T result = await func(cancellationToken); | 		T result = await func(cancellationToken); | ||||||
| 		await Task.Factory.StartNew(() => resultProcessor(result), cancellationToken, TaskCreationOptions.None, resultScheduler); | 		await Task.Factory.StartNew(() => resultProcessor(result), cancellationToken, TaskCreationOptions.None, resultScheduler); | ||||||
| 	} | 	} | ||||||
| 	 |  | ||||||
| 	public void Post(T result) { |  | ||||||
| 		base.Post(_ => Task.FromResult(result)); |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -16,6 +16,7 @@ | |||||||
|    |    | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <PackageReference Include="JetBrains.Annotations" Version="2023.2.0" /> |     <PackageReference Include="JetBrains.Annotations" Version="2023.2.0" /> | ||||||
|  |     <PackageReference Include="System.Reactive" Version="6.0.0" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
|    |    | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|   | |||||||
| @@ -8,5 +8,5 @@ using DHT.Utils; | |||||||
| namespace DHT.Utils; | namespace DHT.Utils; | ||||||
|  |  | ||||||
| static class Version { | static class Version { | ||||||
| 	public const string Tag = "47.2.0.0"; | 	public const string Tag = "47.1.0.0"; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -22,6 +22,7 @@ define('LATEST_VERSION', $version === false ? '_' : $version); | |||||||
|       <h1>Discord History Tracker <span class="bar">|</span> <span class="notes"><a href="https://github.com/chylex/Discord-History-Tracker/releases">Release Notes</a></span></h1> |       <h1>Discord History Tracker <span class="bar">|</span> <span class="notes"><a href="https://github.com/chylex/Discord-History-Tracker/releases">Release Notes</a></span></h1> | ||||||
|       <p>Discord History Tracker lets you save chat history in your servers, groups, and private conversations, and view it offline.</p> |       <p>Discord History Tracker lets you save chat history in your servers, groups, and private conversations, and view it offline.</p> | ||||||
|       <img src="img/tracker.png" width="851" class="dht bordered"> |       <img src="img/tracker.png" width="851" class="dht bordered"> | ||||||
|  |       <p>This page explains how to use the desktop app. If you are looking for the older version of Discord History Tracker which only needs a browser or the Discord app, visit the page for the <a href="https://dht.chylex.com/browser-only">browser-only version</a>, however keep in mind that this version has <strong>significant limitations and fewer features</strong>.</p> | ||||||
|        |        | ||||||
|       <h2>How to Use</h2> |       <h2>How to Use</h2> | ||||||
|       <p>Download the latest version of the desktop app here, or visit <a href="https://github.com/chylex/Discord-History-Tracker/releases">All Releases</a> for older versions and release notes.</p> |       <p>Download the latest version of the desktop app here, or visit <a href="https://github.com/chylex/Discord-History-Tracker/releases">All Releases</a> for older versions and release notes.</p> | ||||||
| @@ -70,9 +71,9 @@ define('LATEST_VERSION', $version === false ? '_' : $version); | |||||||
|        |        | ||||||
|       <h2>External Links</h2> |       <h2>External Links</h2> | ||||||
|       <p class="links"> |       <p class="links"> | ||||||
|         <a href="https://github.com/chylex/Discord-History-Tracker/issues">Issues & Suggestions</a>  •  |         <a href="https://github.com/chylex/Discord-History-Tracker/issues">Issues & Suggestions</a>  —  | ||||||
|         <a href="https://github.com/chylex/Discord-History-Tracker">Source Code</a>  •  |         <a href="https://github.com/chylex/Discord-History-Tracker">Source Code</a>  —  | ||||||
|         <a href="https://chylex.com">Developer's Website</a>  •  |         <a href="https://twitter.com/chylexmc">Follow Dev on Twitter</a>  —  | ||||||
|         <a href="https://ko-fi.com/chylex">Support via Ko-fi</a> |         <a href="https://ko-fi.com/chylex">Support via Ko-fi</a> | ||||||
|       </p> |       </p> | ||||||
|        |        | ||||||
|   | |||||||
| @@ -165,7 +165,7 @@ code { | |||||||
|  |  | ||||||
| .downloads svg { | .downloads svg { | ||||||
|   margin: 1px 4px; |   margin: 1px 4px; | ||||||
|   vertical-align: -30%; |   vertical-align: -25%; | ||||||
| } | } | ||||||
|  |  | ||||||
| .downloads svg.icon-large { | .downloads svg.icon-large { | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user