mirror of
				https://github.com/chylex/Discord-History-Tracker.git
				synced 2025-10-29 22:24:22 +01:00 
			
		
		
		
	Compare commits
	
		
			19 Commits
		
	
	
		
			v47.0
			...
			a7892ba0c7
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | a7892ba0c7 | ||
| 08dec7deb3 | |||
| b148a5634f | |||
| 57e2f9ed80 | |||
| 895d69279f | |||
| 0176fa5d53 | |||
| b5a5826cf9 | |||
|   | 6c0e0ff697 | ||
|   | 7e29c7f837 | ||
|   | 7ba012ef5c | ||
|   | c52572b387 | ||
|   | 5ba80dc12f | ||
|   | 37a0feddcc | ||
|   | 47f448dcde | ||
|   | 0281b49815 | ||
|   | 648b221bb8 | ||
|   | 7d8558ae04 | ||
|   | 41053549ab | ||
|   | f279bb4d16 | 
| @@ -69,7 +69,7 @@ sealed class Arguments { | ||||
| 						Log.Warn("Invalid concurrent downloads count: " + value); | ||||
| 					} | ||||
| 					else if (concurrentDownloads > 10) { | ||||
| 						Log.Warn("Limiting concurrent downloads to 10"); | ||||
| 						Log.Warn("Limiting concurrent downloads to 10."); | ||||
| 						ConcurrentDownloads = 10; | ||||
| 					} | ||||
| 					else { | ||||
|   | ||||
| @@ -52,7 +52,7 @@ static class DatabaseGui { | ||||
| 		} catch (DatabaseTooNewException ex) { | ||||
| 			await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' was opened in a newer version of DHT (database version " + ex.DatabaseVersion + ", app version " + ex.CurrentVersion + ")."); | ||||
| 		} catch (Exception ex) { | ||||
| 			Log.Error(ex); | ||||
| 			Log.Error("Could not open database file: " + path, ex); | ||||
| 			await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' could not be opened:" + ex.Message); | ||||
| 		} | ||||
| 		 | ||||
|   | ||||
| @@ -42,8 +42,7 @@ static class DiscordAppSettings { | ||||
| 			JsonObject settingsJson = await ReadSettingsJson(); | ||||
| 			return AreDevToolsEnabled(settingsJson); | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error("Cannot read settings file."); | ||||
| 			Log.Error(e); | ||||
| 			Log.Error("Could not read settings file.", e); | ||||
| 			return null; | ||||
| 		} | ||||
| 	} | ||||
| @@ -62,7 +61,7 @@ static class DiscordAppSettings { | ||||
| 		} catch (JsonException) { | ||||
| 			return SettingsJsonResult.InvalidJson; | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error(e); | ||||
| 			Log.Error("Could not read settings file.", e); | ||||
| 			return SettingsJsonResult.ReadError; | ||||
| 		} | ||||
| 		 | ||||
| @@ -84,16 +83,14 @@ static class DiscordAppSettings { | ||||
| 			 | ||||
| 			await WriteSettingsJson(json); | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error("An error occurred when writing settings file."); | ||||
| 			Log.Error(e); | ||||
| 			Log.Error("Could not write settings file.", e); | ||||
| 			 | ||||
| 			if (File.Exists(JsonBackupFilePath)) { | ||||
| 				try { | ||||
| 					File.Move(JsonBackupFilePath, JsonFilePath, overwrite: true); | ||||
| 					Log.Info("Restored settings file from backup."); | ||||
| 				} catch (Exception e2) { | ||||
| 					Log.Error("Cannot restore settings file from backup."); | ||||
| 					Log.Error(e2); | ||||
| 					Log.Error("Could not restore settings file from backup.", e2); | ||||
| 				} | ||||
| 			} | ||||
| 			 | ||||
| @@ -103,8 +100,7 @@ static class DiscordAppSettings { | ||||
| 		try { | ||||
| 			File.Delete(JsonBackupFilePath); | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error("Cannot delete backup file."); | ||||
| 			Log.Error(e); | ||||
| 			Log.Error("Could not delete backup file.", e); | ||||
| 		} | ||||
| 		 | ||||
| 		return SettingsJsonResult.Success; | ||||
|   | ||||
| @@ -109,8 +109,7 @@ sealed partial class DownloadItemFilterPanelModel : IAsyncDisposable { | ||||
| 				await setter.Set(SettingsKey.DownloadsMaximumSizeUnit, settings.MaximumSizeUnit.Name); | ||||
| 			}); | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error("Could not save download filter settings"); | ||||
| 			Log.Error(e); | ||||
| 			Log.Error("Could not save download filter settings.", e); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
|   | ||||
| @@ -62,7 +62,7 @@ sealed partial class ServerConfigurationPanelModel : IDisposable { | ||||
| 		try { | ||||
| 			await server.Start(ServerConfiguration.Port, ServerConfiguration.Token); | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error(e); | ||||
| 			Log.Error("Could not start internal server.", e); | ||||
| 			await Dialog.ShowOk(window, "Internal Server Error", e.Message); | ||||
| 		} | ||||
| 		 | ||||
| @@ -76,7 +76,7 @@ sealed partial class ServerConfigurationPanelModel : IDisposable { | ||||
| 		try { | ||||
| 			await server.Stop(); | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error(e); | ||||
| 			Log.Error("Could not stop internal server.", e); | ||||
| 			await Dialog.ShowOk(window, "Internal Server Error", e.Message); | ||||
| 		} | ||||
| 		 | ||||
|   | ||||
| @@ -89,14 +89,21 @@ sealed partial class MainWindowModel : IAsyncDisposable { | ||||
| 		try { | ||||
| 			await state.Server.Start(ServerConfiguration.Port, ServerConfiguration.Token); | ||||
| 		} catch (Exception ex) { | ||||
| 			Log.Error(ex); | ||||
| 			Log.Error("Could not start internal server.", ex); | ||||
| 			await Dialog.ShowOk(window, "Internal Server Error", ex.Message); | ||||
| 		} | ||||
| 		 | ||||
| 		mainContentScreenModel = new MainContentScreenModel(window, state); | ||||
| 		mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed; | ||||
| 		 | ||||
| 		await mainContentScreenModel.Initialize(); | ||||
| 		try { | ||||
| 			mainContentScreenModel = new MainContentScreenModel(window, state); | ||||
| 			mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed; | ||||
| 			await mainContentScreenModel.Initialize(); | ||||
| 		} catch (Exception ex) { | ||||
| 			Log.Error("Could not initialize content screen.", ex); | ||||
| 			await Dialog.ShowOk(window, "Initialization Error", ex.Message); | ||||
| 			await DisposeContent(); | ||||
| 			await DisposeState(); | ||||
| 			return; | ||||
| 		} | ||||
| 		 | ||||
| 		Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle; | ||||
| 		CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel }; | ||||
|   | ||||
| @@ -84,7 +84,7 @@ sealed class DatabasePageModel { | ||||
| 		try { | ||||
| 			result = await ProgressDialog.Show(window, Title, async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback)); | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error(e); | ||||
| 			Log.Error("Could not merge databases.", e); | ||||
| 			await Dialog.ShowOk(window, Title, "Could not merge databases: " + e.Message); | ||||
| 			return; | ||||
| 		} | ||||
| @@ -160,7 +160,7 @@ sealed class DatabasePageModel { | ||||
| 		try { | ||||
| 			result = await ProgressDialog.Show(window, Title, async (dialog, callback) => await ImportLegacyArchiveFromPaths(Db, paths, dialog, callback)); | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error(e); | ||||
| 			Log.Error("Could not import legacy archives.", e); | ||||
| 			await Dialog.ShowOk(window, Title, "Could not import legacy archives: " + e.Message); | ||||
| 			return; | ||||
| 		} | ||||
| @@ -236,7 +236,7 @@ sealed class DatabasePageModel { | ||||
| 					++successful; | ||||
| 				} | ||||
| 			} catch (Exception ex) { | ||||
| 				Log.Error(ex); | ||||
| 				Log.Error("Could not import file: " + path, ex); | ||||
| 				await Dialog.ShowOk(dialog, dialogTitle, "File '" + Path.GetFileName(path) + "' could not be imported: " + ex.Message); | ||||
| 			} | ||||
| 		} | ||||
| @@ -302,7 +302,7 @@ sealed class DatabasePageModel { | ||||
| 		try { | ||||
| 			await ProgressDialog.ShowIndeterminate(window, Title, "Vacuuming database...", _ => Db.Vacuum()); | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error(e); | ||||
| 			Log.Error("Could not vacuum database.", e); | ||||
| 			await Dialog.ShowOk(window, Title, "Could not vacuum database: " + e.Message); | ||||
| 			return; | ||||
| 		} | ||||
|   | ||||
| @@ -45,5 +45,8 @@ | ||||
|                 </StackPanel> | ||||
|             </WrapPanel> | ||||
|         </Expander> | ||||
|         <Expander Header="About" IsExpanded="True"> | ||||
|             <TextBlock Text="{Binding SqliteVersion, StringFormat=Sqlite Version: {0}}" /> | ||||
|         </Expander> | ||||
|     </StackPanel> | ||||
| </UserControl> | ||||
|   | ||||
| @@ -9,14 +9,18 @@ using DHT.Desktop.Dialogs.Progress; | ||||
| using DHT.Server; | ||||
| using DHT.Server.Data; | ||||
| using DHT.Server.Service; | ||||
| using PropertyChanged.SourceGenerator; | ||||
|  | ||||
| namespace DHT.Desktop.Main.Pages; | ||||
|  | ||||
| sealed class DebugPageModel { | ||||
| sealed partial class DebugPageModel { | ||||
| 	public string GenerateChannels { get; set; } = "0"; | ||||
| 	public string GenerateUsers { get; set; } = "0"; | ||||
| 	public string GenerateMessages { get; set; } = "0"; | ||||
| 	 | ||||
| 	[Notify(Setter.Private)] | ||||
| 	private string? sqliteVersion = string.Empty; | ||||
| 	 | ||||
| 	private readonly Window window; | ||||
| 	private readonly State state; | ||||
| 	 | ||||
| @@ -28,6 +32,10 @@ sealed class DebugPageModel { | ||||
| 		this.state = state; | ||||
| 	} | ||||
| 	 | ||||
| 	public async Task Initialize() { | ||||
| 		SqliteVersion = await state.Db.GetVersion() ?? "<unknown>"; | ||||
| 	} | ||||
| 	 | ||||
| 	public async void OnClickAddRandomDataToDatabase() { | ||||
| 		if (!int.TryParse(GenerateChannels, out int channels) || channels < 1) { | ||||
| 			await Dialog.ShowOk(window, "Generate Random Data", "Amount of channels must be at least 1!"); | ||||
| @@ -169,6 +177,8 @@ sealed class DebugPageModel { | ||||
| 	public string GenerateUsers { get; set; } = "0"; | ||||
| 	public string GenerateMessages { get; set; } = "0"; | ||||
|  | ||||
| 	public string SqliteVersion => string.Empty; | ||||
| 	 | ||||
| 	public void OnClickAddRandomDataToDatabase() {} | ||||
| } | ||||
| #endif | ||||
|   | ||||
| @@ -92,7 +92,12 @@ sealed partial class DownloadsPageModel : IAsyncDisposable { | ||||
| 		await FilterModel.Initialize(); | ||||
| 		 | ||||
| 		if (await state.Db.Settings.Get(SettingsKey.DownloadsAutoStart, defaultValue: false)) { | ||||
| 			await StartDownload(); | ||||
| 			try { | ||||
| 				await StartDownload(); | ||||
| 			} catch (Exception e) { | ||||
| 				Log.Error("Could not automatically start downloads.", e); | ||||
| 				await Dialog.ShowOk(window, "Database Error", "Could not automatically start downloads: " + e.Message); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| @@ -111,23 +116,43 @@ sealed partial class DownloadsPageModel : IAsyncDisposable { | ||||
| 	 | ||||
| 	public async Task OnClickToggleDownload() { | ||||
| 		IsToggleDownloadButtonEnabled = false; | ||||
| 		 | ||||
| 		if (IsDownloading) { | ||||
| 			await StopDownload(); | ||||
| 		try { | ||||
| 			if (IsDownloading) { | ||||
| 				await StopDownload(); | ||||
| 			} | ||||
| 			else { | ||||
| 				try { | ||||
| 					await StartDownload(); | ||||
| 				} catch (Exception e) { | ||||
| 					Log.Error("Could not start downloads.", e); | ||||
| 					await Dialog.ShowOk(window, "Database Error", "Could not start downloads: " + e.Message); | ||||
| 					return; | ||||
| 				} | ||||
| 			} | ||||
| 			 | ||||
| 			try { | ||||
| 				await state.Db.Settings.Set(SettingsKey.DownloadsAutoStart, IsDownloading); | ||||
| 			} catch (Exception e) { | ||||
| 				Log.Error("Could not update auto-start setting in database.", e); | ||||
| 			} | ||||
| 		} finally { | ||||
| 			IsToggleDownloadButtonEnabled = true; | ||||
| 		} | ||||
| 		else { | ||||
| 			await StartDownload(); | ||||
| 		} | ||||
| 		 | ||||
| 		await state.Db.Settings.Set(SettingsKey.DownloadsAutoStart, IsDownloading); | ||||
| 		IsToggleDownloadButtonEnabled = true; | ||||
| 	} | ||||
| 	 | ||||
| 	private async Task StartDownload() { | ||||
| 		await state.Db.Downloads.MoveDownloadingItemsBackToQueue(); | ||||
| 		 | ||||
| 		IObservable<DownloadItem> finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter()); | ||||
| 		finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished); | ||||
| 		try { | ||||
| 			currentDownloadFilter = FilterModel.CreateFilter(); | ||||
| 			IObservable<DownloadItem> finishedItems = await state.Downloader.Start(currentDownloadFilter); | ||||
| 			finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished); | ||||
| 		} catch (Exception) { | ||||
| 			finishedItemsSubscription?.Dispose(); | ||||
| 			finishedItemsSubscription = null; | ||||
| 			currentDownloadFilter = null; | ||||
| 			throw; | ||||
| 		} | ||||
| 		 | ||||
| 		OnDownloadStateChanged(); | ||||
| 	} | ||||
| @@ -138,8 +163,8 @@ sealed partial class DownloadsPageModel : IAsyncDisposable { | ||||
| 		 | ||||
| 		finishedItemsSubscription?.Dispose(); | ||||
| 		finishedItemsSubscription = null; | ||||
| 		 | ||||
| 		currentDownloadFilter = null; | ||||
| 		 | ||||
| 		OnDownloadStateChanged(); | ||||
| 	} | ||||
| 	 | ||||
| @@ -155,12 +180,12 @@ sealed partial class DownloadsPageModel : IAsyncDisposable { | ||||
| 	 | ||||
| 	public async Task OnClickRetryFailed() { | ||||
| 		IsRetryingFailedDownloads = true; | ||||
| 		 | ||||
| 		try { | ||||
| 			await state.Db.Downloads.RetryFailed(); | ||||
| 			RecomputeDownloadStatistics(); | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error(e); | ||||
| 			Log.Error("Could not retry failed downloads.", e); | ||||
| 			await Dialog.ShowOk(window, "Retry Failed", "Could not retry failed downloads: " + e.Message); | ||||
| 		} finally { | ||||
| 			IsRetryingFailedDownloads = false; | ||||
| 		} | ||||
| @@ -212,7 +237,7 @@ sealed partial class DownloadsPageModel : IAsyncDisposable { | ||||
| 				await state.Db.Vacuum(); | ||||
| 			}); | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error(e); | ||||
| 			Log.Error("Could not delete orphaned downloads.", e); | ||||
| 			await Dialog.ShowOk(window, Title, "Could not delete orphaned downloads: " + e.Message); | ||||
| 		} | ||||
| 	} | ||||
| @@ -239,7 +264,7 @@ sealed partial class DownloadsPageModel : IAsyncDisposable { | ||||
| 				return await exporter.Export(new ExportProgressReporter(callback)); | ||||
| 			}); | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error(e); | ||||
| 			Log.Error("Could not export downloaded files.", e); | ||||
| 			await Dialog.ShowOk(window, Title, "Could not export downloaded files: " + e.Message); | ||||
| 			return; | ||||
| 		} | ||||
|   | ||||
| @@ -146,7 +146,7 @@ sealed partial class TrackingPageModel { | ||||
| 			await clipboard.SetTextAsync(script); | ||||
| 			return true; | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error(e); | ||||
| 			Log.Error("Could not copy to clipboard.", e); | ||||
| 			await Dialog.ShowOk(window, errorDialogTitle, "An error occurred while copying to clipboard."); | ||||
| 			return false; | ||||
| 		} | ||||
|   | ||||
| @@ -75,7 +75,7 @@ sealed partial class ViewerPageModel : IDisposable { | ||||
| 				} | ||||
| 			} | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error(e); | ||||
| 			Log.Error("Could not apply filters.", e); | ||||
| 			await Dialog.ShowOk(window, "Apply Filters", "Could not apply filters: " + e.Message); | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -70,6 +70,10 @@ sealed class MainContentScreenModel : IAsyncDisposable { | ||||
| 	 | ||||
| 	public async Task Initialize() { | ||||
| 		await DownloadsPageModel.Initialize(); | ||||
| 		 | ||||
| 		#if DEBUG | ||||
| 		await DebugPageModel.Initialize(); | ||||
| 		#endif | ||||
| 	} | ||||
| 	 | ||||
| 	public async ValueTask DisposeAsync() { | ||||
|   | ||||
| @@ -131,7 +131,7 @@ sealed partial class WelcomeScreenModel { | ||||
| 			await Dialog.ShowOk(window, "Check Updates", "Request timed out."); | ||||
| 			return; | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error(e); | ||||
| 			Log.Error("Could not check for updates.", e); | ||||
| 			await Dialog.ShowOk(window, "Check Updates", "Error checking for updates: " + e.Message); | ||||
| 			return; | ||||
| 		} | ||||
|   | ||||
| @@ -67,7 +67,7 @@ class DISCORD { | ||||
| 	/** | ||||
| 	 * @type {function(String): MessageData} | ||||
| 	 */ | ||||
| 	static #getMessages = WEBPACK.findFunction("getMessages"); | ||||
| 	static #getMessages = WEBPACK.findFunction("getMessages", [ "isLoadingMessages" ]); | ||||
| 	 | ||||
| 	/** | ||||
| 	 * @type {function(String): void} | ||||
|   | ||||
| @@ -88,7 +88,7 @@ class WEBPACK { | ||||
| 			return modules[0]; | ||||
| 		} | ||||
| 		 | ||||
| 		console.error("[DHT] Cannot find module " + name + ", results found:", modules.length); | ||||
| 		console.error("[DHT] Cannot find module " + name + ", results found:", modules.length, modules); | ||||
| 		return null; | ||||
| 	} | ||||
| 	 | ||||
|   | ||||
| @@ -10,6 +10,7 @@ | ||||
|      | ||||
|     <link rel="stylesheet" href="styles/main.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/messages.css"> | ||||
|     <link rel="stylesheet" href="styles/modal.css"> | ||||
| @@ -75,6 +76,7 @@ | ||||
|     </div> | ||||
|      | ||||
|     <div id="app"> | ||||
|       <div id="servers"></div> | ||||
|       <div id="channels"> | ||||
|         <div class="loading"></div> | ||||
|       </div> | ||||
| @@ -86,4 +88,4 @@ | ||||
|       <div id="dialog"></div> | ||||
|     </div> | ||||
|   </body> | ||||
| </html> | ||||
| </html> | ||||
| @@ -2,6 +2,7 @@ import discord from "./discord.mjs"; | ||||
| import gui from "./gui.mjs"; | ||||
| import state from "./state.mjs"; | ||||
| import "./polyfills.mjs"; | ||||
| import servers from "./servers.mjs"; | ||||
|  | ||||
| window.DISCORD = discord; | ||||
|  | ||||
| @@ -25,10 +26,12 @@ document.addEventListener("DOMContentLoaded", () => { | ||||
| 	 | ||||
| 	state.onUsersRefreshed(users => { | ||||
| 		gui.updateUserList(users); | ||||
| 		servers.update() | ||||
| 	}); | ||||
| 	 | ||||
| 	state.onChannelsRefreshed((channels, selected) => { | ||||
| 		gui.updateChannelList(channels, selected, state.selectChannel); | ||||
| 		servers.update() | ||||
| 	}); | ||||
| 	 | ||||
| 	state.onMessagesRefreshed(messages => { | ||||
|   | ||||
| @@ -113,16 +113,17 @@ export default (function() { | ||||
| 	return { | ||||
| 		setup() { | ||||
| 			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>", | ||||
| 				"<span class='server'>{server.name} ({server.type})</span>", | ||||
| 				"<!--<span class='server'>{server.name} ({server.type})</span>-->", | ||||
| 				"</div>" | ||||
| 			].join("")); | ||||
| 			 | ||||
| 			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>", | ||||
| 				"<span class='server'>({server.type})</span>", | ||||
| 				"<!--<span class='server'>{server.name} ({server.type})</span>-->", | ||||
| 				"</div>" | ||||
| 			].join("")); | ||||
| 			 | ||||
| @@ -166,7 +167,7 @@ export default (function() { | ||||
| 			 | ||||
| 			// noinspection HtmlUnknownTarget | ||||
| 			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("")); | ||||
| 			 | ||||
| 			// 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; | ||||
| @@ -2,192 +2,229 @@ import settings from "./settings.mjs"; | ||||
| import processor from "./processor.mjs"; | ||||
|  | ||||
| // noinspection FunctionWithInconsistentReturnsJS | ||||
| export default (function() { | ||||
| export default (function () { | ||||
| 	/** | ||||
| 	 * @type {{}} | ||||
| 	 * @property {{}} users | ||||
| 	 * @property {{}} servers | ||||
| 	 * @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 loadedFileData; | ||||
| 	 | ||||
|  | ||||
| 	let loadedMessages; | ||||
| 	 | ||||
|  | ||||
| 	let filterFunction; | ||||
| 	let selectedChannel; | ||||
| 	let currentPage; | ||||
| 	let messagesPerPage; | ||||
| 	 | ||||
| 	const getUser = function(id) { | ||||
|  | ||||
| 	const getUser = function (id) { | ||||
| 		return loadedFileMeta.users[id] || { "name": "<unknown>" }; | ||||
| 	}; | ||||
| 	 | ||||
| 	const getUserList = function() { | ||||
|  | ||||
| 	const getUserList = function () { | ||||
| 		return loadedFileMeta ? loadedFileMeta.users : []; | ||||
| 	}; | ||||
| 	 | ||||
| 	const getServer = function(id) { | ||||
|  | ||||
| 	const getServer = function (id) { | ||||
| 		return loadedFileMeta.servers[id] || { "name": "<unknown>", "type": "unknown" }; | ||||
| 	}; | ||||
| 	 | ||||
| 	const generateChannelHierarchy = function() { | ||||
|  | ||||
| 	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 () { | ||||
| 		/** | ||||
| 		 * @type {Map<string, Set>} | ||||
| 		 */ | ||||
| 		const hierarchy = new Map(); | ||||
| 		 | ||||
|  | ||||
| 		if (!loadedFileMeta) { | ||||
| 			return hierarchy; | ||||
| 		} | ||||
| 		 | ||||
|  | ||||
| 		/** | ||||
| 		 * @returns {Set} | ||||
| 		 */ | ||||
| 		function getChildren(parentId) { | ||||
| 			let children = hierarchy.get(parentId); | ||||
| 			 | ||||
|  | ||||
| 			if (!children) { | ||||
| 				children = new Set(); | ||||
| 				hierarchy.set(parentId, children); | ||||
| 			} | ||||
| 			 | ||||
|  | ||||
| 			return children; | ||||
| 		} | ||||
| 		 | ||||
| 		for (const [ id, channel ] of Object.entries(loadedFileMeta.channels)) { | ||||
|  | ||||
| 		for (const [id, channel] of Object.entries(loadedFileMeta.channels)) { | ||||
| 			getChildren(channel.parent || "").add(id); | ||||
| 		} | ||||
| 		 | ||||
|  | ||||
| 		const unreachableIds = new Set(hierarchy.keys()); | ||||
| 		 | ||||
|  | ||||
| 		function reachIds(parentId) { | ||||
| 			unreachableIds.delete(parentId); | ||||
| 			 | ||||
|  | ||||
| 			const children = hierarchy.get(parentId); | ||||
| 			 | ||||
|  | ||||
| 			if (children) { | ||||
| 				for (const id of children) { | ||||
| 					reachIds(id); | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
|  | ||||
| 		reachIds(""); | ||||
| 		 | ||||
|  | ||||
| 		const rootChildren = getChildren(""); | ||||
| 		 | ||||
|  | ||||
| 		for (const unreachableId of unreachableIds) { | ||||
| 			for (const id of hierarchy.get(unreachableId)) { | ||||
| 				rootChildren.add(id); | ||||
| 			} | ||||
| 			 | ||||
|  | ||||
| 			hierarchy.delete(unreachableId); | ||||
| 		} | ||||
| 		 | ||||
|  | ||||
| 		return hierarchy; | ||||
| 	}; | ||||
| 	 | ||||
| 	const generateChannelOrder = function() { | ||||
|  | ||||
| 	const generateChannelOrder = function () { | ||||
| 		if (!loadedFileMeta) { | ||||
| 			return {}; | ||||
| 		} | ||||
| 		 | ||||
|  | ||||
| 		const channels = loadedFileMeta.channels; | ||||
| 		const hierarchy = generateChannelHierarchy(); | ||||
| 		 | ||||
|  | ||||
| 		function getSortedSubTree(parentId) { | ||||
| 			const children = hierarchy.get(parentId); | ||||
| 			if (!children) { | ||||
| 				return []; | ||||
| 			} | ||||
| 			 | ||||
|  | ||||
| 			const sortedChildren = Array.from(children); | ||||
| 			 | ||||
|  | ||||
| 			sortedChildren.sort((id1, id2) => { | ||||
| 				const c1 = channels[id1]; | ||||
| 				const c2 = channels[id2]; | ||||
| 				const s1 = getServer(c1.server); | ||||
| 				const s2 = getServer(c2.server); | ||||
| 				 | ||||
|  | ||||
| 				return s1.type.localeCompare(s2.type, "en") || | ||||
| 					s1.name.toLocaleLowerCase().localeCompare(s2.name.toLocaleLowerCase(), undefined, { numeric: true }) || | ||||
| 					(c1.position || -1) - (c2.position || -1) || | ||||
| 					c1.name.toLocaleLowerCase().localeCompare(c2.name.toLocaleLowerCase(), undefined, { numeric: true }); | ||||
| 			}); | ||||
| 			 | ||||
|  | ||||
| 			const subTree = []; | ||||
| 			 | ||||
|  | ||||
| 			for (const id of sortedChildren) { | ||||
| 				subTree.push(id); | ||||
| 				subTree.push(...getSortedSubTree(id)); | ||||
| 			} | ||||
| 			 | ||||
|  | ||||
| 			return subTree; | ||||
| 		} | ||||
| 		 | ||||
|  | ||||
| 		const orderArray = getSortedSubTree(""); | ||||
| 		const orderMap = {}; | ||||
| 		 | ||||
|  | ||||
| 		for (let i = 0; i < orderArray.length; i++) { | ||||
| 			orderMap[orderArray[i]] = i; | ||||
| 		} | ||||
| 		 | ||||
|  | ||||
| 		return orderMap; | ||||
| 	}; | ||||
| 	 | ||||
| 	const getChannelList = function() { | ||||
|  | ||||
| 	const getChannelList = function () { | ||||
| 		if (!loadedFileMeta) { | ||||
| 			return []; | ||||
| 		} | ||||
| 		 | ||||
|  | ||||
| 		const channels = loadedFileMeta.channels; | ||||
| 		const channelOrder = generateChannelOrder(); | ||||
| 		 | ||||
|  | ||||
|  | ||||
| 		return Object.keys(channels).map(key => ({ | ||||
| 			"id": key, | ||||
| 			"serverId": channels[key].server, | ||||
| 			"name": channels[key].name, | ||||
| 			"server": getServer(channels[key].server), | ||||
| 			"msgcount": getFilteredMessageKeys(key).length, | ||||
| 			"topic": channels[key].topic || "", | ||||
| 			"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) => { | ||||
| 			return channelOrder[ac.id] - channelOrder[bc.id]; | ||||
| 		}); | ||||
| 	}; | ||||
| 	 | ||||
| 	const getMessages = function(channel) { | ||||
|  | ||||
| 	const getMessages = function (channel) { | ||||
| 		return loadedFileData[channel] || {}; | ||||
| 	}; | ||||
| 	 | ||||
| 	const getMessageById = function(id) { | ||||
|  | ||||
| 	const getMessageById = function (id) { | ||||
| 		for (const messages of Object.values(loadedFileData)) { | ||||
| 			if (id in messages) { | ||||
| 				return messages[id]; | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
|  | ||||
| 		return null; | ||||
| 	}; | ||||
| 	 | ||||
| 	const getMessageChannel = function(id) { | ||||
| 		for (const [ channel, messages ] of Object.entries(loadedFileData)) { | ||||
|  | ||||
| 	const getMessageChannel = function (id) { | ||||
| 		for (const [channel, messages] of Object.entries(loadedFileData)) { | ||||
| 			if (id in messages) { | ||||
| 				return channel; | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
|  | ||||
| 		return null; | ||||
| 	}; | ||||
| 	 | ||||
| 	const getMessageList = function() { | ||||
|  | ||||
| 	const getMessageList = function () { | ||||
| 		if (!loadedMessages) { | ||||
| 			return []; | ||||
| 		} | ||||
| 		 | ||||
|  | ||||
| 		const messages = getMessages(selectedChannel); | ||||
| 		const startIndex = messagesPerPage * (root.getCurrentPage() - 1); | ||||
| 		 | ||||
|  | ||||
| 		return loadedMessages.slice(startIndex, !messagesPerPage ? undefined : startIndex + messagesPerPage).map(key => { | ||||
| 			/** | ||||
| 			 * @type {{}} | ||||
| @@ -203,35 +240,35 @@ export default (function() { | ||||
| 			const message = messages[key]; | ||||
| 			const user = getUser(message.u); | ||||
| 			const avatar = user.avatar ? { id: message.u, path: user.avatar } : null; | ||||
| 			 | ||||
|  | ||||
| 			const obj = { | ||||
| 				user, | ||||
| 				avatar, | ||||
| 				"timestamp": message.t, | ||||
| 				"jump": key, | ||||
| 			}; | ||||
| 			 | ||||
|  | ||||
| 			if ("m" in message) { | ||||
| 				obj["contents"] = message.m; | ||||
| 			} | ||||
| 			 | ||||
|  | ||||
| 			if ("e" in message) { | ||||
| 				obj["embeds"] = message.e.map(embed => JSON.parse(embed)); | ||||
| 			} | ||||
| 			 | ||||
|  | ||||
| 			if ("a" in message) { | ||||
| 				obj["attachments"] = message.a; | ||||
| 			} | ||||
| 			 | ||||
|  | ||||
| 			if ("te" in message) { | ||||
| 				obj["edit"] = message.te; | ||||
| 			} | ||||
| 			 | ||||
|  | ||||
| 			if ("r" in message) { | ||||
| 				const replyMessage = getMessageById(message.r); | ||||
| 				const replyUser = replyMessage ? getUser(replyMessage.u) : null; | ||||
| 				const replyAvatar = replyUser && replyUser.avatar ? { id: replyMessage.u, path: replyUser.avatar } : null; | ||||
| 				 | ||||
|  | ||||
| 				obj["reply"] = replyMessage ? { | ||||
| 					"id": message.r, | ||||
| 					"user": replyUser, | ||||
| @@ -239,212 +276,212 @@ export default (function() { | ||||
| 					"contents": replyMessage.m | ||||
| 				} : null; | ||||
| 			} | ||||
| 			 | ||||
|  | ||||
| 			if ("re" in message) { | ||||
| 				obj["reactions"] = message.re; | ||||
| 			} | ||||
| 			 | ||||
|  | ||||
| 			return obj; | ||||
| 		}); | ||||
| 	}; | ||||
| 	 | ||||
|  | ||||
| 	let eventOnUsersRefreshed; | ||||
| 	let eventOnChannelsRefreshed; | ||||
| 	let eventOnMessagesRefreshed; | ||||
| 	 | ||||
| 	const triggerUsersRefreshed = function() { | ||||
|  | ||||
| 	const triggerUsersRefreshed = function () { | ||||
| 		eventOnUsersRefreshed && eventOnUsersRefreshed(getUserList()); | ||||
| 	}; | ||||
| 	 | ||||
| 	const triggerChannelsRefreshed = function(selectedChannel) { | ||||
|  | ||||
| 	const triggerChannelsRefreshed = function (selectedChannel) { | ||||
| 		eventOnChannelsRefreshed && eventOnChannelsRefreshed(getChannelList(), selectedChannel); | ||||
| 	}; | ||||
| 	 | ||||
| 	const triggerMessagesRefreshed = function() { | ||||
|  | ||||
| 	const triggerMessagesRefreshed = function () { | ||||
| 		eventOnMessagesRefreshed && eventOnMessagesRefreshed(getMessageList()); | ||||
| 	}; | ||||
| 	 | ||||
| 	const getFilteredMessageKeys = function(channel) { | ||||
|  | ||||
| 	const getFilteredMessageKeys = function (channel) { | ||||
| 		const messages = getMessages(channel); | ||||
| 		let keys = Object.keys(messages); | ||||
| 		 | ||||
|  | ||||
| 		if (filterFunction) { | ||||
| 			keys = keys.filter(key => filterFunction(messages[key])); | ||||
| 		} | ||||
| 		 | ||||
|  | ||||
| 		return keys; | ||||
| 	}; | ||||
| 	 | ||||
|  | ||||
| 	const root = { | ||||
| 		onChannelsRefreshed(callback) { | ||||
| 			eventOnChannelsRefreshed = callback; | ||||
| 		}, | ||||
| 		 | ||||
|  | ||||
| 		onMessagesRefreshed(callback) { | ||||
| 			eventOnMessagesRefreshed = callback; | ||||
| 		}, | ||||
| 		 | ||||
|  | ||||
| 		onUsersRefreshed(callback) { | ||||
| 			eventOnUsersRefreshed = callback; | ||||
| 		}, | ||||
| 		 | ||||
|  | ||||
| 		uploadFile(meta, data) { | ||||
| 			if (loadedFileMeta != null) { | ||||
| 				throw "A file is already loaded!"; | ||||
| 			} | ||||
| 			 | ||||
|  | ||||
| 			if (typeof meta !== "object" || typeof data !== "object") { | ||||
| 				throw "Invalid file format!"; | ||||
| 			} | ||||
| 			 | ||||
|  | ||||
| 			loadedFileMeta = meta; | ||||
| 			loadedFileData = data; | ||||
| 			loadedMessages = null; | ||||
| 			 | ||||
|  | ||||
| 			selectedChannel = null; | ||||
| 			currentPage = 1; | ||||
| 			 | ||||
|  | ||||
| 			triggerUsersRefreshed(); | ||||
| 			triggerChannelsRefreshed(); | ||||
| 			triggerMessagesRefreshed(); | ||||
| 			 | ||||
|  | ||||
| 			settings.onSettingsChanged(() => triggerMessagesRefreshed()); | ||||
| 		}, | ||||
| 		 | ||||
|  | ||||
| 		getChannelName(channel) { | ||||
| 			const channelObj = loadedFileMeta.channels[channel]; | ||||
| 			return (channelObj && channelObj.name) || channel; | ||||
| 		}, | ||||
| 		 | ||||
|  | ||||
| 		getUserName(user) { | ||||
| 			const userObj = loadedFileMeta.users[user]; | ||||
| 			return (userObj && userObj.name) || user; | ||||
| 		}, | ||||
| 		 | ||||
|  | ||||
| 		getUserDisplayName(user) { | ||||
| 			const userObj = loadedFileMeta.users[user]; | ||||
| 			return (userObj && (userObj.displayName || userObj.name)) || user; | ||||
| 		}, | ||||
| 		 | ||||
|  | ||||
| 		selectChannel(channel) { | ||||
| 			currentPage = 1; | ||||
| 			selectedChannel = channel; | ||||
| 			 | ||||
|  | ||||
| 			loadedMessages = getFilteredMessageKeys(channel).sort(processor.SORTER.oldestToNewest); | ||||
| 			triggerMessagesRefreshed(); | ||||
| 		}, | ||||
| 		 | ||||
|  | ||||
| 		setMessagesPerPage(amount) { | ||||
| 			messagesPerPage = amount; | ||||
| 			triggerMessagesRefreshed(); | ||||
| 		}, | ||||
| 		 | ||||
|  | ||||
| 		updateCurrentPage(action) { | ||||
| 			switch (action) { | ||||
| 				case "first": | ||||
| 					currentPage = 1; | ||||
| 					break; | ||||
| 				 | ||||
|  | ||||
| 				case "prev": | ||||
| 					currentPage = Math.max(1, currentPage - 1); | ||||
| 					break; | ||||
| 				 | ||||
|  | ||||
| 				case "next": | ||||
| 					currentPage = Math.min(this.getPageCount(), currentPage + 1); | ||||
| 					break; | ||||
| 				 | ||||
|  | ||||
| 				case "last": | ||||
| 					currentPage = this.getPageCount(); | ||||
| 					break; | ||||
| 				 | ||||
|  | ||||
| 				case "pick": | ||||
| 					const page = parseInt(prompt("Select page:", currentPage), 10); | ||||
| 					 | ||||
|  | ||||
| 					if (!page && page !== 0) { | ||||
| 						return; | ||||
| 					} | ||||
| 					 | ||||
|  | ||||
| 					currentPage = Math.max(1, Math.min(this.getPageCount(), page)); | ||||
| 					break; | ||||
| 			} | ||||
| 			 | ||||
|  | ||||
| 			triggerMessagesRefreshed(); | ||||
| 		}, | ||||
| 		 | ||||
|  | ||||
| 		getCurrentPage() { | ||||
| 			const total = this.getPageCount(); | ||||
| 			 | ||||
|  | ||||
| 			if (currentPage > total && total > 0) { | ||||
| 				currentPage = total; | ||||
| 			} | ||||
| 			 | ||||
|  | ||||
| 			return currentPage || 1; | ||||
| 		}, | ||||
| 		 | ||||
|  | ||||
| 		getPageCount() { | ||||
| 			return !loadedMessages ? 0 : (!messagesPerPage ? 1 : Math.ceil(loadedMessages.length / messagesPerPage)); | ||||
| 		}, | ||||
| 		 | ||||
|  | ||||
| 		navigateToMessage(id) { | ||||
| 			if (!loadedMessages) { | ||||
| 				return -1; | ||||
| 			} | ||||
| 			 | ||||
|  | ||||
| 			const channel = getMessageChannel(id); | ||||
| 			 | ||||
|  | ||||
| 			if (channel !== null && channel !== selectedChannel) { | ||||
| 				triggerChannelsRefreshed(channel); | ||||
| 				this.selectChannel(channel); | ||||
| 			} | ||||
| 			 | ||||
|  | ||||
| 			const index = loadedMessages.indexOf(id); | ||||
| 			 | ||||
|  | ||||
| 			if (index === -1) { | ||||
| 				return -1; | ||||
| 			} | ||||
| 			 | ||||
|  | ||||
| 			currentPage = Math.max(1, Math.min(this.getPageCount(), 1 + Math.floor(index / messagesPerPage))); | ||||
| 			triggerMessagesRefreshed(); | ||||
| 			return index % messagesPerPage; | ||||
| 		}, | ||||
| 		 | ||||
|  | ||||
| 		setActiveFilter(filter) { | ||||
| 			switch (filter ? filter.type : "") { | ||||
| 				case "user": | ||||
| 					filterFunction = processor.FILTER.byUser(filter.value); | ||||
| 					break; | ||||
| 				 | ||||
|  | ||||
| 				case "contents": | ||||
| 					filterFunction = processor.FILTER.byContents(filter.value); | ||||
| 					break; | ||||
| 				 | ||||
|  | ||||
| 				case "withimages": | ||||
| 					filterFunction = processor.FILTER.withImages(); | ||||
| 					break; | ||||
| 				 | ||||
|  | ||||
| 				case "withdownloads": | ||||
| 					filterFunction = processor.FILTER.withDownloads(); | ||||
| 					break; | ||||
| 				 | ||||
|  | ||||
| 				case "edited": | ||||
| 					filterFunction = processor.FILTER.isEdited(); | ||||
| 					break; | ||||
| 				 | ||||
|  | ||||
| 				default: | ||||
| 					filterFunction = null; | ||||
| 					break; | ||||
| 			} | ||||
| 			 | ||||
|  | ||||
| 			this.hasActiveFilter = filterFunction != null; | ||||
| 			 | ||||
|  | ||||
| 			triggerChannelsRefreshed(selectedChannel); | ||||
| 			 | ||||
|  | ||||
| 			if (selectedChannel) { | ||||
| 				this.selectChannel(selectedChannel); // resets current page and updates messages | ||||
| 			} | ||||
| 		} | ||||
| 	}; | ||||
| 	 | ||||
|  | ||||
| 	root.hasActiveFilter = false; | ||||
| 	return root; | ||||
| })(); | ||||
|   | ||||
| @@ -8,15 +8,21 @@ export default class { | ||||
| 	}; | ||||
| 	 | ||||
| 	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) => { | ||||
| 			const value = match.split(".").reduce((o, property) => o[property], obj); | ||||
| 			 | ||||
| 			if (processor) { | ||||
| 				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; | ||||
|   overflow-y: auto; | ||||
|   color: #eee; | ||||
|   background-color: #1c1e22; | ||||
|   background-color: #2B2D31; | ||||
|   font-size: 15px; | ||||
|   padding: 2px; | ||||
| } | ||||
|  | ||||
| #channels > div.loading { | ||||
| @@ -14,27 +15,93 @@ | ||||
|   height: 150px; | ||||
| } | ||||
|  | ||||
| #channels > div.channel { | ||||
|   cursor: pointer; | ||||
|   padding: 10px 12px; | ||||
|   border-bottom: 1px solid #333333; | ||||
| #channels > div.loading { | ||||
|   background-color: rgba(0, 0, 0, 0) !important; | ||||
| } | ||||
|  | ||||
| #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 { | ||||
|   display: flex; | ||||
|   height: 16px; | ||||
|   margin-bottom: 4px; | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| .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 { | ||||
|   flex-grow: 1; | ||||
|   overflow-x: hidden; | ||||
|   overflow-y: clip; | ||||
|   text-overflow: ellipsis; | ||||
|   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 { | ||||
| @@ -46,3 +113,25 @@ | ||||
|   padding: 2px 5px; | ||||
|   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:hover { | ||||
|   background-color: rgba(0, 0, 0, 0) !important; | ||||
| } | ||||
|  | ||||
| .loading::after { | ||||
|   content: ""; | ||||
|   background: var(--loading-backdrop) | ||||
|   | ||||
| @@ -4,8 +4,8 @@ | ||||
|   align-items: stretch; | ||||
|   gap: 8px; | ||||
|   padding: 8px; | ||||
|   background-color: #17181c; | ||||
|   border-bottom: 1px dotted #5d626b; | ||||
|   background-color: #313338; | ||||
|   border-bottom: 2px solid #27292D; | ||||
| } | ||||
|  | ||||
| #menu .splitter { | ||||
| @@ -23,7 +23,9 @@ | ||||
|   cursor: default; | ||||
| } | ||||
|  | ||||
| #menu button, #menu select, #menu input[type="text"] { | ||||
| #menu button, | ||||
| #menu select, | ||||
| #menu input[type="text"] { | ||||
|   height: 31px; | ||||
|   padding: 0 10px; | ||||
|   background-color: #7289da; | ||||
| @@ -64,11 +66,13 @@ | ||||
|   padding: 0 8px; | ||||
| } | ||||
|  | ||||
| #menu .nav > button, #menu .nav > p { | ||||
| #menu .nav > button, | ||||
| #menu .nav > p { | ||||
|   margin: 0 1px; | ||||
| } | ||||
|  | ||||
| #opt-filter-list > select, #opt-filter-list > input { | ||||
| #opt-filter-list > select, | ||||
| #opt-filter-list > input { | ||||
|   display: none; | ||||
| } | ||||
|  | ||||
| @@ -78,4 +82,4 @@ | ||||
|  | ||||
| #btn-about { | ||||
|   margin-left: auto; | ||||
| } | ||||
| } | ||||
| @@ -5,9 +5,14 @@ | ||||
| } | ||||
|  | ||||
| #messages > div { | ||||
|   margin: 0 24px; | ||||
|   padding: 4px 0 12px; | ||||
|   border-bottom: 1px solid rgba(255, 255, 255, 0.04); | ||||
|   padding: 0 4px 0 24px; | ||||
|  | ||||
|   margin-bottom: 17px; | ||||
| } | ||||
|  | ||||
|  | ||||
| #messages > div:hover { | ||||
|   background-color: oklab(0.0846607 0.00000385568 0.00000169128 / 0.06); | ||||
| } | ||||
|  | ||||
| #messages h2 { | ||||
| @@ -23,6 +28,8 @@ | ||||
|   align-content: flex-start; | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| #messages .avatar-wrapper > div { | ||||
|   flex: 1 1 auto; | ||||
| } | ||||
| @@ -50,14 +57,9 @@ | ||||
|   font-size: 12px; | ||||
|   font-weight: 500; | ||||
|   letter-spacing: 0; | ||||
|   margin-left: 5px; | ||||
| } | ||||
|  | ||||
| #messages .info::before { | ||||
|   content: "\2022"; | ||||
|   text-align: center; | ||||
|   display: inline-block; | ||||
|   width: 14px; | ||||
| } | ||||
|  | ||||
| #messages .jump { | ||||
|   cursor: pointer; | ||||
| @@ -66,20 +68,22 @@ | ||||
| } | ||||
|  | ||||
| .message { | ||||
|   margin-top: 6px; | ||||
|   color: rgba(255, 255, 255, 0.7); | ||||
|   font-size: 15px; | ||||
|   margin-top: 2px; | ||||
|   color: oklab(0.89908 -0.00192907 -0.0048306); | ||||
|   font-size: 16px; | ||||
|   line-height: 1.1em; | ||||
|   white-space: pre-wrap; | ||||
|   word-wrap: break-word; | ||||
| } | ||||
|  | ||||
| .message .link, .reply-message .link { | ||||
| .message .link, | ||||
| .reply-message .link { | ||||
|   color: #7289da; | ||||
|   background-color: rgba(115, 139, 215, 0.1); | ||||
| } | ||||
|  | ||||
| .message a, .reply-message a { | ||||
| .message a, | ||||
| .reply-message a { | ||||
|   color: #0096cf; | ||||
|   text-decoration: none; | ||||
| } | ||||
| @@ -126,7 +130,8 @@ | ||||
|   border-radius: 3px; | ||||
| } | ||||
|  | ||||
| .message .embed:first-child, .message .download + .download { | ||||
| .message .embed:first-child, | ||||
| .message .download+.download { | ||||
|   margin-top: 0; | ||||
| } | ||||
|  | ||||
| @@ -257,4 +262,4 @@ | ||||
| .reactions .count { | ||||
|   color: rgba(255, 255, 255, 0.45); | ||||
|   font-size: 14px; | ||||
| } | ||||
| } | ||||
							
								
								
									
										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; | ||||
| } | ||||
| @@ -19,6 +19,10 @@ sealed class DummyDatabaseFile : IDatabaseFile { | ||||
| 	 | ||||
| 	private DummyDatabaseFile() {} | ||||
| 	 | ||||
| 	public Task<string?> GetVersion() { | ||||
| 		return Task.FromResult<string?>(null); | ||||
| 	} | ||||
| 	 | ||||
| 	public Task Vacuum() { | ||||
| 		return Task.CompletedTask; | ||||
| 	} | ||||
|   | ||||
| @@ -14,5 +14,6 @@ public interface IDatabaseFile : IAsyncDisposable { | ||||
| 	IMessageRepository Messages { get; } | ||||
| 	IDownloadRepository Downloads { get; } | ||||
| 	 | ||||
| 	Task<string?> GetVersion(); | ||||
| 	Task Vacuum(); | ||||
| } | ||||
|   | ||||
| @@ -80,6 +80,11 @@ public sealed class SqliteDatabaseFile : IDatabaseFile { | ||||
| 		await pool.DisposeAsync(); | ||||
| 	} | ||||
| 	 | ||||
| 	public async Task<string?> GetVersion() { | ||||
| 		await using var conn = await pool.Take(); | ||||
| 		return await conn.ExecuteReaderAsync("SELECT sqlite_version()", static reader => reader?.GetString(0)); | ||||
| 	} | ||||
| 	 | ||||
| 	public async Task Vacuum() { | ||||
| 		await using var conn = await pool.Take(); | ||||
| 		 | ||||
|   | ||||
| @@ -95,7 +95,7 @@ public sealed partial class DownloadExporter(IDatabaseFile db, string folderPath | ||||
| 				} catch (FileAlreadyExistsException) { | ||||
| 					success = false; | ||||
| 				} catch (Exception e) { | ||||
| 					Log.Error(e); | ||||
| 					Log.Error("Could not export downloaded file: " + download.NormalizedUrl, e); | ||||
| 					success = false; | ||||
| 				} | ||||
| 				 | ||||
|   | ||||
| @@ -2,6 +2,7 @@ using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| using System.Reactive.Subjects; | ||||
| using System.Threading; | ||||
| @@ -94,12 +95,10 @@ sealed class DownloaderTask : IAsyncDisposable { | ||||
| 			} catch (TaskCanceledException e) when (e.InnerException is TimeoutException) { | ||||
| 				await db.Downloads.AddDownload(item.ToFailure(), stream: null); | ||||
| 				log.Error("Download timed out: " + item.DownloadUrl); | ||||
| 			} catch (HttpRequestException e) { | ||||
| 				await db.Downloads.AddDownload(item.ToFailure(e.StatusCode), stream: null); | ||||
| 				log.Error(e); | ||||
| 			} catch (Exception e) { | ||||
| 				await db.Downloads.AddDownload(item.ToFailure(), stream: null); | ||||
| 				log.Error(e); | ||||
| 				HttpStatusCode? statusCode = e is HttpRequestException hre ? hre.StatusCode : null; | ||||
| 				await db.Downloads.AddDownload(item.ToFailure(statusCode), stream: null); | ||||
| 				log.Error("Could not download file: " + item.DownloadUrl, e); | ||||
| 			} finally { | ||||
| 				try { | ||||
| 					finishedItemPublisher.OnNext(item); | ||||
|   | ||||
| @@ -33,7 +33,7 @@ abstract class BaseEndpoint { | ||||
| 				await response.WriteAsync(e.Message); | ||||
| 			} | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error(e); | ||||
| 			Log.Error("Could not handle request.", e); | ||||
| 			response.StatusCode = (int) HttpStatusCode.InternalServerError; | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -11,7 +11,7 @@ | ||||
|   </ItemGroup> | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.7" /> | ||||
|     <PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.6" /> | ||||
|     <PackageReference Include="System.Linq.Async" Version="6.0.1" /> | ||||
|   </ItemGroup> | ||||
|    | ||||
|   | ||||
| @@ -30,11 +30,11 @@ sealed class ServerLoggingMiddleware(RequestDelegate next) { | ||||
| 		long elapsedMs = stopwatch.ElapsedMilliseconds; | ||||
| 		 | ||||
| 		if (context.RequestAborted.IsCancellationRequested) { | ||||
| 			Log.Debug("Request to " + request.GetEncodedPathAndQuery() + " (" + requestLength + " B) was cancelled after " + elapsedMs + " ms"); | ||||
| 			Log.Debug("Request to " + request.GetEncodedPathAndQuery() + " (" + requestLength + " B) was cancelled after " + elapsedMs + " ms."); | ||||
| 		} | ||||
| 		else { | ||||
| 			int responseStatus = context.Response.StatusCode; | ||||
| 			Log.Debug("Request to " + request.GetEncodedPathAndQuery() + " (" + requestLength + " B) returned " + responseStatus + ", took " + elapsedMs + " ms"); | ||||
| 			Log.Debug("Request to " + request.GetEncodedPathAndQuery() + " (" + requestLength + " B) returned " + responseStatus + ", took " + elapsedMs + " ms."); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -88,7 +88,7 @@ public sealed class ServerManager { | ||||
| 			throw; | ||||
| 		} | ||||
| 		 | ||||
| 		Log.Info("Server started"); | ||||
| 		Log.Info("Server started."); | ||||
| 		 | ||||
| 		server = newServer; | ||||
| 		 | ||||
| @@ -104,7 +104,7 @@ public sealed class ServerManager { | ||||
| 		 | ||||
| 		Log.Info("Stopping server..."); | ||||
| 		await server.StopAsync(); | ||||
| 		Log.Info("Server stopped"); | ||||
| 		Log.Info("Server stopped."); | ||||
| 		 | ||||
| 		server.Dispose(); | ||||
| 		server = null; | ||||
|   | ||||
| @@ -88,6 +88,11 @@ public sealed class Log { | ||||
| 		LogLevel(ConsoleColor.Red, "ERROR", e.ToString()); | ||||
| 	} | ||||
| 	 | ||||
| 	public void Error(string message, Exception e) { | ||||
| 		Error(message); | ||||
| 		Error(e); | ||||
| 	} | ||||
| 	 | ||||
| 	public Perf Start(string? context = null, [CallerMemberName] string callerMemberName = "") { | ||||
| 		return Perf.Start(this, context, callerMemberName); | ||||
| 	} | ||||
|   | ||||
| @@ -8,5 +8,5 @@ using DHT.Utils; | ||||
| namespace DHT.Utils; | ||||
|  | ||||
| static class Version { | ||||
| 	public const string Tag = "47.0.0.0"; | ||||
| 	public const string Tag = "47.1.0.0"; | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user