From 25a54fdc65532b0d6f890820c383a286b4d84c6e Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 4 Feb 2024 16:36:56 +0100 Subject: [PATCH] GH-1 GH-13: Add Gif Search and improve Settings --- package.json | 3 + public/locales/de/appearance.json | 3 +- public/locales/de/common.json | 4 +- public/locales/dev/appearance.json | 3 +- public/locales/dev/common.json | 4 +- public/locales/en/appearance.json | 3 +- public/locales/en/common.json | 4 +- public/locales/es/appearance.json | 11 + public/locales/es/audio.json | 19 + public/locales/es/common.json | 34 + public/locales/es/language.json | 13 + public/locales/es/notifications.json | 4 + public/locales/es/privacy.json | 7 + public/locales/es/time.json | 8 + public/locales/es/user_interaction.json | 18 + public/locales/fr/appearance.json | 3 +- public/locales/fr/common.json | 4 +- src-tauri/Cargo.toml | 3 +- src-tauri/build.rs | 1 + src-tauri/src/commands/settings_cmd.rs | 73 +- src-tauri/src/commands/web_cmd.rs | 30 + src-tauri/src/main.rs | 25 +- src-tauri/src/proto/Fancy.proto | 10 + src-tauri/src/tests/tidy.rs | 1 + src-tauri/tauri.conf.json | 3 + src/components/ChatInput.tsx | 8 +- src/components/ChatMessageContainer.tsx | 37 +- src/components/GifSearch.tsx | 241 ++- src/components/QuillChatInput.tsx | 107 ++ src/components/QuillEditor.tsx | 788 ++++++-- src/components/Sidebar.tsx | 6 + src/components/Titlebar.tsx | 14 +- src/components/UrlPreview.tsx | 3 +- .../settings/AdditionalFeatures.tsx | 82 +- src/components/settings/AdvancedSettings.tsx | 98 +- src/components/settings/Audio.tsx | 4 +- src/components/styles/Quill.css | 12 + src/index.css | 3 +- src/routes/Chat.tsx | 45 +- src/store/features/users/audioSettings.ts | 13 +- src/store/features/users/frontendSettings.ts | 21 +- src/store/persistance/persist.ts | 16 + tsconfig.json | 2 +- yarn.lock | 1631 +++++++++-------- 44 files changed, 2291 insertions(+), 1131 deletions(-) create mode 100644 public/locales/es/appearance.json create mode 100644 public/locales/es/audio.json create mode 100644 public/locales/es/common.json create mode 100644 public/locales/es/language.json create mode 100644 public/locales/es/notifications.json create mode 100644 public/locales/es/privacy.json create mode 100644 public/locales/es/time.json create mode 100644 public/locales/es/user_interaction.json create mode 100644 src-tauri/src/proto/Fancy.proto create mode 100644 src/components/QuillChatInput.tsx create mode 100644 src/components/styles/Quill.css create mode 100644 src/store/persistance/persist.ts diff --git a/package.json b/package.json index bf0ef09..1abecb2 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@reduxjs/toolkit": "^1.9.5", "@tauri-apps/api": "^1.2.0", "@types/dompurify": "^3.0.1", + "@types/lodash": "^4.14.202", "@types/marked": "^5.0.0", "@types/quill": "^2.0.10", "@types/react-color": "^3.0.6", @@ -34,6 +35,7 @@ "marked": "^5.0.2", "marked-highlight": "^2.0.0", "quill": "^1.3.7", + "quill-delta": "^5.1.0", "react": "^18.2.0", "react-color": "^2.19.3", "react-dom": "^18.2.0", @@ -43,6 +45,7 @@ "react-router-dom": "^6.10.0", "socket.io": "^4.7.4", "socket.io-client": "^4.7.4", + "tauri-plugin-store-api": "https://github.com/tauri-apps/tauri-plugin-store#v1", "tinycolor2": "^1.6.0" }, "devDependencies": { diff --git a/public/locales/de/appearance.json b/public/locales/de/appearance.json index 6fdb648..31d7941 100644 --- a/public/locales/de/appearance.json +++ b/public/locales/de/appearance.json @@ -6,5 +6,6 @@ "Primary": "Primär", "Accent": "Akzent", "Disable Auto-Scroll": "Automatisches Scrollen deaktivieren", - "Always auto-scroll, even if scrolled up": "Immer automatisch scrollen, auch wenn nach oben gescrollt wurde" + "Always auto-scroll, even if scrolled up": "Immer automatisch scrollen, auch wenn nach oben gescrollt wurde", + "Enable WYSIWYG Editor": "WYSIWYG-Editor aktivieren" } \ No newline at end of file diff --git a/public/locales/de/common.json b/public/locales/de/common.json index 6ec2665..0edbae0 100644 --- a/public/locales/de/common.json +++ b/public/locales/de/common.json @@ -27,5 +27,7 @@ "Description": "Beschreibung", "Server": "Server", "Port": "Port", - "Username": "Benutzername" + "Username": "Benutzername", + "Advanced Settings": "Erweiterte Einstellungen", + "Beta": "Beta" } \ No newline at end of file diff --git a/public/locales/dev/appearance.json b/public/locales/dev/appearance.json index 37e0ce5..bc09f77 100644 --- a/public/locales/dev/appearance.json +++ b/public/locales/dev/appearance.json @@ -6,5 +6,6 @@ "Primary": "---", "Accent": "---", "Disable Auto-Scroll": "---", - "Always auto-scroll, even if scrolled up": "---" + "Always auto-scroll, even if scrolled up": "---", + "Enable WYSIWYG Editor": "---" } \ No newline at end of file diff --git a/public/locales/dev/common.json b/public/locales/dev/common.json index 40951f3..11e1150 100644 --- a/public/locales/dev/common.json +++ b/public/locales/dev/common.json @@ -27,5 +27,7 @@ "Description": "---", "Server": "---", "Port": "---", - "Username": "---" + "Username": "---", + "Advanced Settings": "---", + "Beta": "---" } \ No newline at end of file diff --git a/public/locales/en/appearance.json b/public/locales/en/appearance.json index ddfbcaa..2b81ff2 100644 --- a/public/locales/en/appearance.json +++ b/public/locales/en/appearance.json @@ -6,5 +6,6 @@ "Primary": "Primary", "Accent": "Accent", "Disable Auto-Scroll": "Disable Auto-Scroll", - "Always auto-scroll, even if scrolled up": "Always auto-scroll, even if scrolled up" + "Always auto-scroll, even if scrolled up": "Always auto-scroll, even if scrolled up", + "Enable WYSIWYG Editor": "Enable WYSIWYG Editor" } \ No newline at end of file diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 49f3454..f95ea31 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -28,5 +28,7 @@ "Server": "Server", "Port": "Port", "Username": "Username", - "Client Certitcate": "Client Certitcate" + "Client Certitcate": "Client Certitcate", + "Advanced Settings": "Advanced Settings", + "Beta": "Beta" } \ No newline at end of file diff --git a/public/locales/es/appearance.json b/public/locales/es/appearance.json new file mode 100644 index 0000000..cd04218 --- /dev/null +++ b/public/locales/es/appearance.json @@ -0,0 +1,11 @@ +{ + "Appearance": "Apariencia", + "Colors": "Colores", + "Background Image": "Imagen de Fondo", + "Profile Image": "Imagen de Perfil", + "Primary": "Principal", + "Accent": "Acento", + "Disable Auto-Scroll": "Desactivar Auto-Scroll", + "Always auto-scroll, even if scrolled up": "Siempre auto-scroll, incluso si se desplaza hacia arriba", + "Enable WYSIWYG Editor": "Habilitar Editor WYSIWYG" +} \ No newline at end of file diff --git a/public/locales/es/audio.json b/public/locales/es/audio.json new file mode 100644 index 0000000..f309efa --- /dev/null +++ b/public/locales/es/audio.json @@ -0,0 +1,19 @@ +{ + "Audio": "Audio", + "Input Device": "Dispositivo de entrada", + "Microphone": "Micrófono", + "Voice Activation": "Activación de voz", + "Push To Talk": "Pulsar para hablar", + "Automatically detect Microphone sensitivity": "Detectar automáticamente la sensibilidad del micrófono", + "Hold Activation for": "Mantener activación durante {{duration}}", + "Fade-out Audio after activation for": "Desvanecer audio después de la activación durante {{duration}}", + "Audio activation at": "Activación de audio en {{threshold}}", + "Audio deactivation at": "Desactivación de audio en {{threshold}}", + "Amplification dB": "Amplificación +{{amplification}}dB", + "Echo Cancelation": "Cancelación de eco", + "Noise Suppression": "Supresión de ruido", + "Compressor Threshold": "Umbral del compresor {{threshold}}dB", + "Compressor Ratio": "Relación del compresor {{ratio}}:1", + "Attack Time": "Tiempo de ataque {{duration}}", + "Release Time": "Tiempo de liberación {{duration}}" +} \ No newline at end of file diff --git a/public/locales/es/common.json b/public/locales/es/common.json new file mode 100644 index 0000000..2001bda --- /dev/null +++ b/public/locales/es/common.json @@ -0,0 +1,34 @@ +{ + "User Profiles": "Perfiles de usuario", + "Profile": "Perfil", + "Add New Server": "Agregar nuevo servidor", + "Fancy Mumble Title": "Título elegante de Mumble", + "Unknown User": "Usuario desconocido", + "Search": "Buscar", + "Search Channel": "Buscar canal", + "Search Tenor": "Buscar Tenor para GIFs", + "Open In Browser": "Abrir en el navegador", + "Muted": "Silenciado", + "Deafened": "Sordo", + "Joined": "Unido", + "User ID": "ID de usuario", + "Settings": "Configuración", + "None": "Ninguno", + "Image too large": "[[ Imagen demasiado grande ({{size}} de {{maximum}}) ]]", + "write something": "Escribe algo :)", + "Feature Not Implemented": "{{feature}} (No implementado)", + "About Me": "Acerca de mí", + "Tell us about yourself": "Cuéntanos sobre ti", + "Edit Image": "Editar imagen", + "Link Preview": "Vista previa del enlace", + "Additional Features": "Funciones adicionales", + "Advanced": "Avanzado", + "Images": "Imágenes", + "Description": "Descripción", + "Server": "Servidor", + "Port": "Puerto", + "Username": "Nombre de usuario", + "Client Certitcate": "Certificado de cliente", + "Advanced Settings": "Configuración avanzada", + "Beta": "Beta" +} \ No newline at end of file diff --git a/public/locales/es/language.json b/public/locales/es/language.json new file mode 100644 index 0000000..cbd2619 --- /dev/null +++ b/public/locales/es/language.json @@ -0,0 +1,13 @@ +{ + "Language": "Idioma", + "en": "English", + "en native": "Inglés", + "de": "Deutsch", + "de native": "Alemán", + "fr": "Français", + "fr native": "Francés", + "es": "Español", + "es native": "Español", + "dev": "Development", + "dev native": "Desarrollo" +} \ No newline at end of file diff --git a/public/locales/es/notifications.json b/public/locales/es/notifications.json new file mode 100644 index 0000000..8aaf969 --- /dev/null +++ b/public/locales/es/notifications.json @@ -0,0 +1,4 @@ +{ + "Notifications": "Notifications", + "Hotkeys": "Hotkeys" +} \ No newline at end of file diff --git a/public/locales/es/privacy.json b/public/locales/es/privacy.json new file mode 100644 index 0000000..47c18f3 --- /dev/null +++ b/public/locales/es/privacy.json @@ -0,0 +1,7 @@ +{ + "Privacy": "Privacidad", + "Allow URLs from all sources": "Permitir URLs de todas las fuentes", + "Allowed Link Preview Urls": "URLs permitidas para vista previa de enlaces", + "Enable Link Preview": "Habilitar vista previa de enlaces", + "Tenor API Key": "Clave de API de Tenor" +} \ No newline at end of file diff --git a/public/locales/es/time.json b/public/locales/es/time.json new file mode 100644 index 0000000..87be56c --- /dev/null +++ b/public/locales/es/time.json @@ -0,0 +1,8 @@ +{ + "Timestamp": "Marca de tiempo", + "day short": "d", + "hour short": "h", + "minute short": "m", + "second short": "s", + "millisecond short": "ms" +} \ No newline at end of file diff --git a/public/locales/es/user_interaction.json b/public/locales/es/user_interaction.json new file mode 100644 index 0000000..d1531a4 --- /dev/null +++ b/public/locales/es/user_interaction.json @@ -0,0 +1,18 @@ +{ + "Are you sure you want to delete all messages?": "¿Estás seguro de que quieres eliminar todos los mensajes?", + "Yes": "Sí", + "No": "No", + "Skip": "Saltar", + "Cancel": "Cancelar", + "Like": "Me gusta", + "Message": "Mensaje", + "Go Back": "Volver", + "Save": "Guardar", + "Connect": "Conectar", + "Apply": "Aplicar", + "Discard": "Descartar", + "write user a message": "escribe un mensaje a {{user}}...", + "Delete all messages": "Eliminar todos los mensajes", + "Send Message to Channel": "Enviar mensaje a {{channel}}", + "User Joined the Server": "{{user}} se unió al servidor" +} \ No newline at end of file diff --git a/public/locales/fr/appearance.json b/public/locales/fr/appearance.json index 6604d49..c35099b 100644 --- a/public/locales/fr/appearance.json +++ b/public/locales/fr/appearance.json @@ -6,5 +6,6 @@ "Primary": "Primaire", "Accent": "Accent", "Disable Auto-Scroll": "Désactiver le défilement automatique", - "Always auto-scroll, even if scrolled up": "Toujours défilement automatique, même si défilé vers le haut" + "Always auto-scroll, even if scrolled up": "Toujours défilement automatique, même si défilé vers le haut", + "Enable WYSIWYG Editor": "Activer l'éditeur WYSIWYG" } \ No newline at end of file diff --git a/public/locales/fr/common.json b/public/locales/fr/common.json index 2f75b29..47000c8 100644 --- a/public/locales/fr/common.json +++ b/public/locales/fr/common.json @@ -27,5 +27,7 @@ "Description": "Description", "Server": "Serveur", "Port": "Port", - "Username": "Nom d'utilisateur" + "Username": "Nom d'utilisateur", + "Advanced Settings": "Paramètres avancés", + "Beta": "Bêta" } \ No newline at end of file diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 21f8ea7..ef447d1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,7 +22,7 @@ patch = "0.7.0" [dependencies] serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } -tauri = { version = "1.4.0", features = ["dialog-open", "global-shortcut-all", "shell-open", "updater", "window-close", "window-hide", "window-maximize", "window-minimize", "window-start-dragging", "window-unmaximize", "window-unminimize"] } +tauri = { version = "1.5.4", features = [ "path-all", "dialog-open", "global-shortcut-all", "shell-open", "updater", "window-close", "window-hide", "window-maximize", "window-minimize", "window-start-dragging", "window-unmaximize", "window-unminimize"] } futures = "0.3.4" tokio = { version = "1", features = ["full"] } tokio-native-tls = "0.3.1" @@ -44,6 +44,7 @@ webbrowser = "0.8.10" reqwest = "0.11" scraper = "0.18.1" tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } +tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } symphonia = "0.5.3" mime_guess = "2.0.4" uuid = "1.7.0" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 0e8e933..d90623f 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -118,6 +118,7 @@ fn main() -> io::Result<()> { prost_build::compile_protos(&["src/proto/Mumble.proto"], &["src/"])?; prost_build::compile_protos(&["src/proto/MumbleUDP.proto"], &["src/"])?; + prost_build::compile_protos(&["src/proto/Fancy.proto"], &["src/"])?; tauri_build::build(); Ok(()) diff --git a/src-tauri/src/commands/settings_cmd.rs b/src-tauri/src/commands/settings_cmd.rs index cffb459..d628d30 100644 --- a/src-tauri/src/commands/settings_cmd.rs +++ b/src-tauri/src/commands/settings_cmd.rs @@ -1,10 +1,8 @@ use std::{ fs, - io::{Read, Seek, SeekFrom, Write}, - sync::RwLock, + io::{Seek, SeekFrom, Write}, }; -use tauri::State; use tracing::{info, trace}; use crate::{ @@ -12,10 +10,7 @@ use crate::{ utils::{constants::get_project_dirs, server::Server}, }; -use super::utils::settings::FrontendSettings; - const SERVER_SETTINS_FILE: &str = "server.json"; -const FRONTEND_SETTINS_FILE: &str = "frontend_settings.json"; pub fn get_settings_file(file_name: &str) -> Result { let project_dirs = get_project_dirs().ok_or("Unable to load project dir")?; @@ -30,18 +25,6 @@ pub fn get_settings_file(file_name: &str) -> Result { Ok(settings_file) } -pub fn get_settings_file_location(file_name: &str) -> Result { - let project_dirs = get_project_dirs().ok_or("Unable to load project dir")?; - let data_dir = project_dirs.config_dir(); - std::fs::create_dir_all(data_dir).map_err(|e| format!("{e:?}"))?; - - Ok(data_dir - .join(file_name) - .to_str() - .ok_or_else(|| "Unable to get file location".to_string())? - .to_string()) -} - #[tauri::command] pub fn save_server( description: &str, @@ -116,60 +99,6 @@ pub fn get_server_list() -> Result, String> { Ok(server_list) } -pub struct FrontendSettingsState { - pub state: RwLock, -} - -#[allow(clippy::needless_pass_by_value)] // LinkPreview needs to be deserialized -#[allow(clippy::significant_drop_tightening)] // we need this to prevent simultaneous writes -#[tauri::command] -pub fn save_frontend_settings( - state: State<'_, FrontendSettingsState>, - settings_name: &str, - data: FrontendSettings, -) -> Result<(), String> { - trace!("Saving frontend settings: {settings_name}"); - - trace!("Settings data: {:#?}", data); - let lock = state.state.write(); - if let Err(e) = lock { - return Err(format!("Error locking write state: {}", e.get_ref())); - } - let data = serde_json::to_string_pretty(&data).map_err(|e| format!("{e:?}"))?; - - fs::write( - get_settings_file_location(&format!("{settings_name}_{FRONTEND_SETTINS_FILE}"))?, - data, - ) - .map_err(|e| format!("{e:?}"))?; - - Ok(()) -} - -// State is passed by value by tauri -#[allow(clippy::needless_pass_by_value)] -#[tauri::command] -pub fn get_frontend_settings( - state: State<'_, FrontendSettingsState>, - settings_name: &str, -) -> Result { - info!("Getting frontend settings: {settings_name}"); - let mut settings_file = get_settings_file(&format!("{settings_name}_{FRONTEND_SETTINS_FILE}"))?; - - if let Err(e) = state.state.read() { - return Err(format!("Error locking write state: {}", e.get_ref())); - } - - let mut settings_data = String::new(); - settings_file - .read_to_string(&mut settings_data) - .map_err(|e| format!("{e:?}"))?; - - trace!("Settings data: {:#?}", settings_data); - - Ok(settings_data) -} - #[tauri::command] pub fn get_identity_certs() -> Result, String> { let project_dirs = get_project_dirs() diff --git a/src-tauri/src/commands/web_cmd.rs b/src-tauri/src/commands/web_cmd.rs index add8bbf..2b26f5d 100644 --- a/src-tauri/src/commands/web_cmd.rs +++ b/src-tauri/src/commands/web_cmd.rs @@ -45,3 +45,33 @@ pub async fn get_open_graph_data_from_website( Ok(result.to_string()) } + +#[tauri::command] +pub async fn get_tenor_search_results( + api_key: &str, + query: &str, + limit: u32, + pos: u32, +) -> Result { + let params = format!("&q={query}&limit={limit}&pos={pos}"); + + get_tenor_results(api_key, "search", params).await +} + +#[tauri::command] +pub async fn get_tenor_trending_results(api_key: &str) -> Result { + get_tenor_results(api_key, "trending", String::new()).await +} + +async fn get_tenor_results(api_key: &str, api: &str, params: String) -> Result { + let url = format!("https://api.tenor.com/v1/{api}?key={api_key}{params}"); + + let response = reqwest::get(&url) + .await + .map_err(|e| format!("{e:?}"))? + .text() + .await + .map_err(|e| format!("{e:?}"))?; + + Ok(response) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 274505f..fdd0179 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -14,12 +14,9 @@ mod utils; #[cfg(test)] mod tests; -use std::{ - collections::HashMap, - sync::{Arc, RwLock}, -}; +use std::{collections::HashMap, sync::Arc}; -use commands::{settings_cmd::FrontendSettingsState, web_cmd::CrawlerState, ConnectionState}; +use commands::{web_cmd::CrawlerState, ConnectionState}; use tauri_plugin_window_state::{StateFlags, WindowExt}; use tokio::sync::Mutex; @@ -34,11 +31,11 @@ use crate::commands::{ change_user_state, connect_to_server, crop_and_store_image, disable_audio_info, enable_audio_info, get_audio_devices, like_message, logout, send_message, set_audio_input_setting, set_audio_output_setting, set_user_image, - settings_cmd::{ - get_frontend_settings, get_identity_certs, get_server_list, save_frontend_settings, - save_server, + settings_cmd::{get_identity_certs, get_server_list, save_server}, + web_cmd::{ + get_open_graph_data_from_website, get_tenor_search_results, get_tenor_trending_results, + open_browser, }, - web_cmd::{get_open_graph_data_from_website, open_browser}, zip_cmd::{convert_to_base64, unzip_data_from_utf8, zip_data_to_utf8}, }; @@ -64,6 +61,7 @@ async fn main() { tauri::Builder::default() .plugin(tauri_plugin_window_state::Builder::default().build()) + .plugin(tauri_plugin_store::Builder::default().build()) .setup(|app| { app.manage(ConnectionState { connection: Mutex::new(None), @@ -78,9 +76,6 @@ async fn main() { app.manage(CrawlerState { crawler: Mutex::new(None), }); - app.manage(FrontendSettingsState { - state: RwLock::new(false), - }); if let Some(window) = app.get_window("main") { window.restore_state(StateFlags::all())?; } @@ -103,13 +98,13 @@ async fn main() { convert_to_base64, open_browser, get_open_graph_data_from_website, - save_frontend_settings, - get_frontend_settings, get_identity_certs, set_audio_input_setting, set_audio_output_setting, enable_audio_info, - disable_audio_info + disable_audio_info, + get_tenor_search_results, + get_tenor_trending_results ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/proto/Fancy.proto b/src-tauri/src/proto/Fancy.proto new file mode 100644 index 0000000..544b318 --- /dev/null +++ b/src-tauri/src/proto/Fancy.proto @@ -0,0 +1,10 @@ + +syntax = "proto3"; + +package FancyProto; + +option optimize_for = SPEED; + +message LikeMessage { + string message = 1; +} \ No newline at end of file diff --git a/src-tauri/src/tests/tidy.rs b/src-tauri/src/tests/tidy.rs index 981bec8..5dc322d 100644 --- a/src-tauri/src/tests/tidy.rs +++ b/src-tauri/src/tests/tidy.rs @@ -44,6 +44,7 @@ fn check_licenses() { 0BSD OR MIT OR Apache-2.0 Apache-2.0 Apache-2.0 OR BSL-1.0 +Apache-2.0 OR ISC OR MIT Apache-2.0 OR MIT Apache-2.0 / MIT Apache-2.0 WITH LLVM-exception diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b0e3e70..e93adf3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -15,6 +15,9 @@ "globalShortcut": { "all": true }, + "path": { + "all": true + }, "shell": { "all": false, "execute": false, diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 37b809f..b5208ba 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -7,7 +7,7 @@ import { ChatMessageHandler } from "../helper/ChatMessage"; import { useDispatch, useSelector } from "react-redux"; import { RootState } from "../store/store"; import { formatBytes } from "../helper/Fomat"; -import GifSearch from "./GifSearch"; +import GifSearch, { GifResult } from "./GifSearch"; import { useTranslation } from "react-i18next"; function ChatInput() { @@ -67,6 +67,10 @@ function ChatInput() { } }, [chatMessageHandler, currentUser]); + function sendGif(gif: GifResult): void { + chatMessageHandler.sendCustomChatMessage(``, currentUser); + } + return ( - + sendGif(gif)} /> {({ TransitionProps }) => ( diff --git a/src/components/ChatMessageContainer.tsx b/src/components/ChatMessageContainer.tsx index 5ce681e..818e7a0 100644 --- a/src/components/ChatMessageContainer.tsx +++ b/src/components/ChatMessageContainer.tsx @@ -1,5 +1,5 @@ -import { Avatar, Box, Card, CardContent, Grid, List, Typography } from "@mui/material"; -import React, { ReactElement, useEffect, useMemo, useRef, useState } from "react"; +import { Avatar, Box, Grid, List, Typography } from "@mui/material"; +import React, { ReactElement, useEffect, useMemo, useState } from "react"; import { MemoChatMessage } from "./ChatMessage"; import { useSelector } from "react-redux"; import { RootState } from "../store/store"; @@ -20,41 +20,48 @@ interface GroupedMessages { const ChatMessageContainer = (props: ChatMessageContainerProps) => { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const userList = useSelector((state: RootState) => state.reducer.userInfo); const advancedSettings = useSelector((state: RootState) => state.reducer.frontendSettings.advancedSettings); const chatContainer: React.RefObject = React.createRef(); const messagesEndRef: React.RefObject = React.createRef(); const [userInfoAnchor, setUserInfoAnchor] = React.useState(null); const [currentPopoverUserId, setCurrentPopoverUserId]: any = useState(null); - const [userScrolled, setUserScrolled] = useState(false); - const prevPropsRef = useRef(props); const scrollToBottom = () => { - if (advancedSettings.disableAutoscroll) { + if (advancedSettings?.disableAutoscroll) { return; } new Promise(r => setTimeout(r, 100)).then(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + console.log("End Ref", messagesEndRef.current); + if(messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); + } else { + // workaround for when the ref is not set yet + document.getElementById("msg-end-ref")?.scrollIntoView({ behavior: "smooth" }); + } }); } useEffect(() => { let messages = props.messages; if (messages.length > 0) { - const isScrolledToBottom = !advancedSettings.disableAutoscroll && (advancedSettings.alwaysScrollDown || ((chatContainer?.current?.scrollHeight || 0) - (chatContainer?.current?.scrollTop || 0) >= (chatContainer?.current?.clientHeight || 0) * 1.2)); + const scrollTrigger = (chatContainer?.current?.clientHeight ?? 0) * 1.2; + const scrollPosition = (chatContainer?.current?.scrollHeight ?? 0) - (chatContainer?.current?.scrollTop ?? 0); + const isWithinTrigger = scrollPosition >= scrollTrigger; + const shouldScrollDown = advancedSettings?.alwaysScrollDown || isWithinTrigger; - if (isScrolledToBottom) { - messagesEndRef?.current?.scrollIntoView({ behavior: 'smooth' }); + if (shouldScrollDown) { + scrollToBottom(); } } }, [props.messages]); useEffect(() => { - if (!userScrolled || advancedSettings.alwaysScrollDown) { + if (advancedSettings?.alwaysScrollDown) { scrollToBottom(); } - }, [props, userScrolled]); // Depend on props and userScrolled + }, [props]); // Depend on props and userScrolled useEffect(() => { const images = Array.from(document.getElementsByTagName('img')); @@ -138,7 +145,7 @@ const ChatMessageContainer = (props: ChatMessageContainerProps) => { const emptyChatMessageContainer = useMemo(() => { if (props.messages.length === 0) { return ( - + {t("write something")} @@ -160,7 +167,7 @@ const ChatMessageContainer = (props: ChatMessageContainerProps) => { { setCurrentPopoverUserId(group.user?.id); setUserInfoAnchor(e.currentTarget); console.log(e.currentTarget) }} variant="rounded" /> @@ -179,7 +186,7 @@ const ChatMessageContainer = (props: ChatMessageContainerProps) => { {chatElements} {emptyChatMessageContainer} {currentPopoverUserId && userIdToPopoverMap.get(currentPopoverUserId)} -
+
); } diff --git a/src/components/GifSearch.tsx b/src/components/GifSearch.tsx index 3b53db3..0bf9c0c 100644 --- a/src/components/GifSearch.tsx +++ b/src/components/GifSearch.tsx @@ -1,18 +1,220 @@ import { Box, Fade, ImageList, ImageListItem, Paper, Popper, Skeleton, TextField } from '@mui/material' -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import SearchIcon from '@mui/icons-material/Search'; import { useTranslation } from 'react-i18next'; +import { debounce } from 'lodash'; +import { useSelector } from 'react-redux'; +import { RootState } from '../store/store'; +import { invoke } from '@tauri-apps/api'; +import { use } from 'i18next'; + +interface MediaElement { + webm: { + size: number; + duration: number; + preview: string; + url: string; + dims: [number, number]; + }; + tinywebm: { + preview: string; + dims: [number, number]; + url: string; + size: number; + duration: number; + }; + webp_transparent: { + url: string; + size: number; + preview: string; + dims: [number, number]; + duration: number; + }; + tinygif: { + size: number; + preview: string; + dims: [number, number]; + duration: number; + url: string; + }; + tinymp4: { + dims: [number, number]; + preview: string; + size: number; + url: string; + duration: number; + }; + gif: { + size: number; + preview: string; + duration: number; + dims: [number, number]; + url: string; + }; + nanowebp_transparent: { + duration: number; + url: string; + preview: string; + size: number; + dims: [number, number]; + }; + tinywebp_transparent: { + preview: string; + size: number; + dims: [number, number]; + url: string; + duration: number; + }; + nanomp4: { + size: number; + duration: number; + dims: [number, number]; + preview: string; + url: string; + }; + nanowebm: { + url: string; + dims: [number, number]; + preview: string; + size: number; + duration: number; + }; + mp4: { + duration: number; + preview: string; + url: string; + size: number; + dims: [number, number]; + }; + loopedmp4: { + url: string; + size: number; + preview: string; + dims: [number, number]; + duration: number; + }; + nanogif: { + duration: number; + size: number; + preview: string; + dims: [number, number]; + url: string; + }; + mediumgif: { + url: string; + dims: [number, number]; + size: number; + preview: string; + duration: number; + }; +}; + +export interface GifResult { + id: string; + title: string; + content_description: string; + content_rating: string; + h1_title: string; + media: MediaElement[]; + bg_color: string; + created: number; + itemurl: string; + url: string; + tags: string[]; + flags: string[]; + shares: number; + hasaudio: boolean; + hascaption: boolean; + source_id: string; + composite: any; +} + +interface GidResultContainer { + results: GifResult[]; + next: string; +} + +interface EmptyResult { + +} + +interface ErrorElement { + code: number; + error: string; +} interface GifSearchProps { open: boolean anchor: HTMLElement | undefined + onGifSelected?: (gif: GifResult) => void } -function GifSearch(props: GifSearchProps) { - const { t, i18n } = useTranslation(); +const handleSearchChange = debounce(async ( + value: string, + tenorApiKey: string | undefined, + setItemData: React.Dispatch> +) => { + if (tenorApiKey) { + if (value.length === 0) { + const result = await invoke('get_tenor_trending_results', { + apiKey: tenorApiKey + }); + const resultObj = JSON.parse(result as string); + setItemData(resultObj as GidResultContainer); + return; + } + const result = await invoke('get_tenor_search_results', { + apiKey: tenorApiKey, + query: value, + limit: 10, + pos: 0, + }); + const resultObj = JSON.parse(result as string); + console.log(resultObj); + + setItemData(resultObj as GidResultContainer); + } +}, 1000); + +function GifSearch(props: Readonly) { + const frontendSettings = useSelector((state: RootState) => state.reducer.frontendSettings); + const tenorApiKey = frontendSettings.api_keys?.tenor; + + const { t } = useTranslation(); const [search, setSearch] = useState(""); - const [itemData, setItemData] = useState([{}, {}, {}, {}, {}, {}]); + const [itemData, setItemData] = useState([{}, {}, {}, {}, {}, {}]); + + useEffect(() => { + handleSearchChange(search, tenorApiKey, setItemData); + }, [search]); + + useEffect(() => { + if(!tenorApiKey || tenorApiKey.length === 0) { + return; + } + invoke('get_tenor_trending_results', { + apiKey: tenorApiKey + }).then((result) => { + const resultObj = JSON.parse(result as string); + if(resultObj.error) { + console.log(resultObj.error); + return; + } + setItemData(resultObj as GidResultContainer); + }).catch(e => { + console.log(e); + }); + }, [tenorApiKey]); + + function handleGifClick(gif: GifResult) { + console.log("clicked"); + if (props.onGifSelected) { + props.onGifSelected(gif); + } + } + + console.log("Data: ", itemData); return ( {({ TransitionProps }) => ( @@ -27,18 +229,33 @@ function GifSearch(props: GifSearchProps) { }} size='small' value={search} - onChange={e => setSearch(e.target.value)} + onChange={e => { + setSearch(e.target.value); + }} /> - - - {itemData.map((item) => ( - - - - ))} + + + {((itemData as GidResultContainer)?.results ?? itemData).map((item, i) => { + if (Object.keys(item).length === 0) { + return ( + + + + ); + } else { + const imgElement = item as GifResult; + + return ( + + {imgElement.title} handleGifClick(imgElement)} /> + + ); + } + })} + {tenorApiKey && tenorApiKey.length > 0 ? null : {t("Tenor API Key not set")}} )} diff --git a/src/components/QuillChatInput.tsx b/src/components/QuillChatInput.tsx new file mode 100644 index 0000000..d91ee9e --- /dev/null +++ b/src/components/QuillChatInput.tsx @@ -0,0 +1,107 @@ +import { useCallback, useMemo, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { ChatMessageHandler } from "../helper/ChatMessage"; +import { RootState } from "../store/store"; +import QuillEditor from "./QuillEditor"; +import { Box, Button, Divider, Fade, IconButton, Paper, Popper, Tooltip } from "@mui/material"; +import SendIcon from '@mui/icons-material/Send'; +import DeleteIcon from '@mui/icons-material/Delete'; +import GifIcon from '@mui/icons-material/Gif'; +import GifSearch, { GifResult } from "./GifSearch"; +import { t } from "i18next"; + +function QuillChatInput() { + const dispatch = useDispatch(); + const [chatMessage, setChatMessage] = useState(""); + const [showDeleteMessageConfirmation, setShowDeleteMessageConfirmation] = useState(false); + const [messageDeleteAnchor, setMessageDeleteAnchor] = useState(); + const [showGifSearch, setShowGifSearch] = useState(false); + const [gifSearchAnchor, setGifSearchAnchor] = useState(); + + const chatMessageHandler = useMemo(() => new ChatMessageHandler(dispatch, setChatMessage), [dispatch]); + const currentUser = useSelector((state: RootState) => state.reducer.userInfo?.currentUser); + const channelInfo = useSelector((state: RootState) => state.reducer.channel); + const currentChannel = useMemo(() => channelInfo.find(e => e.channel_id === currentUser?.channel_id)?.name, [channelInfo, currentUser]); + + const keyDownHandler = (e: KeyboardEvent) => { + if (e && e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + chatMessageHandler.sendChatMessage(chatMessage, currentUser); + setChatMessage(""); + } + }; + + const showDeleteMessageConfirmationDialog = useCallback((e: any) => { + setShowDeleteMessageConfirmation(prev => !prev); + setMessageDeleteAnchor(e.currentTarget) + }, []); + + const showGifPreview = useCallback((e: any) => { + setShowGifSearch(prev => !prev); + setGifSearchAnchor(e.currentTarget) + }, []); + + const deleteMessages = useCallback(() => { + chatMessageHandler.deleteMessages(); + }, [chatMessageHandler]); + + function updateContent(content: string): void { + setChatMessage(content); + } + + function sendGif(gif: GifResult): void { + chatMessageHandler.sendCustomChatMessage(``, currentUser); + } + + return ( + + + + + + + + keyDownHandler(e)} + onChange={(content: string) => updateContent(content)} + value={chatMessage} + theme="bubble" + placeholder={t("Send Message to Channel", { ns: "user_interaction", channel: currentChannel })} + /> + + + + + chatMessageHandler.sendChatMessage(chatMessage, currentUser)}> + + + + sendGif(gif)} /> + + {({ TransitionProps }) => ( + + + + {t('Are you sure you want to delete all messages?', { ns: 'user_interaction' })} + + + + + + + + )} + + + ); +} + +export default QuillChatInput; \ No newline at end of file diff --git a/src/components/QuillEditor.tsx b/src/components/QuillEditor.tsx index c02ba34..b7bfded 100644 --- a/src/components/QuillEditor.tsx +++ b/src/components/QuillEditor.tsx @@ -1,124 +1,708 @@ -import React, { useEffect, useRef, useState } from 'react'; +/* +React-Quill +https://github.com/zenoamaro/react-quill +*/ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import isEqual from 'lodash/isEqual'; import Quill, { QuillOptionsStatic, RangeStatic, BoundsStatic, StringMap, - Sources, - TextChangeHandler, + Sources } from 'quill'; +import Delta from "quill-delta"; + +import 'quill/dist/quill.bubble.css'; +import "./styles/Quill.css"; +const Link = Quill.import('formats/link'); +const BlockEmbed = Quill.import('blots/block/embed'); +Link.PROTOCOL_WHITELIST = ['http', 'https', 'mailto', 'tel', 'radar', 'rdar', 'smb', 'sms', 'data']; -import 'quill/dist/quill.snow.css'; - -Quill.register - -interface QuillEditorProps { - theme?: 'bubble' | 'snow' | string; - placeholder?: string; - readOnly?: boolean; - bounds?: string; - debug?: 'error' | 'warn' | 'log' | boolean; - formats?: string[]; - modules?: any; - scrollingContainer?: string | HTMLElement | undefined; - style?: React.CSSProperties; - onKeyDown?: (this: HTMLDivElement, ev: KeyboardEvent) => any; - onChange?: (content: string) => void; - onPaste?: (this: HTMLDivElement, ev: ClipboardEvent) => any; - multiline?: boolean; - value?: string; +class VideoBlot extends BlockEmbed { + static create(value: string) { + const node = super.create(); + node.setAttribute('src', value); + node.setAttribute('controls', ''); + node.setAttribute('width', '100%'); + node.setAttribute('height', 'auto'); + return node; + } + + static value(node: HTMLElement) { + return node.getAttribute('src'); + } } -export const QuillEditor: React.FC = ({ - theme = 'snow', - placeholder = 'Compose an epic...', - readOnly = false, - bounds = 'document.body', - debug = 'warn', - formats = [], - modules = { - toolbar: { - toolbar: '#toolbar' - } - }, - scrollingContainer = undefined, - style = {}, - onKeyDown, - onChange, - onPaste, - multiline = false, - value = '' -}) => { - const editorRef = useRef(null); - const quillRef = useRef(); // This will hold the Quill instance - const [toolbarVisible, setToolbarVisible] = useState(multiline); - let quill: Quill | undefined = undefined; - let textChangeListener: TextChangeHandler | undefined = undefined; - - // This runs once on component mount - useEffect(() => { - if (editorRef.current) { - console.log("editorRef.current", editorRef.current); - quill = new Quill(editorRef.current, { - theme, - placeholder, - readOnly, - bounds, - debug, - formats, - modules, - scrollingContainer - }); - - // Set the initial value - quill.root.innerHTML = value; - - // Add event listeners - if (onKeyDown) - quill.root.addEventListener('keydown', onKeyDown); - if (onPaste) - quill.root.addEventListener('paste', onPaste); - - if (onChange) { - textChangeListener = () => { - onChange(quill?.root.innerHTML || ''); - } +VideoBlot.blotName = 'video'; +VideoBlot.tagName = 'video'; - quill.on('text-change', textChangeListener); - } +Quill.register(VideoBlot); + +// Merged namespace hack to export types along with default object +// See: https://github.com/Microsoft/TypeScript/issues/2719 +namespace QuillEditor { + export type Value = string | Delta; + export type Range = RangeStatic | null; + + export interface QuillOptions extends QuillOptionsStatic { + tabIndex?: number, + } + + export interface ReactQuillProps { + bounds?: string | HTMLElement, + children?: React.ReactElement, + className?: string, + defaultValue?: Value, + formats?: string[], + id?: string, + modules?: StringMap, + onChange?( + value: string, + delta: Delta, + source: Sources, + editor: UnprivilegedEditor, + ): void, + onChangeSelection?( + selection: Range, + source: Sources, + editor: UnprivilegedEditor, + ): void, + onFocus?( + selection: Range, + source: Sources, + editor: UnprivilegedEditor, + ): void, + onBlur?( + previousSelection: Range, + source: Sources, + editor: UnprivilegedEditor, + ): void, + onKeyDown?: React.EventHandler, + onKeyPress?: React.EventHandler, + onKeyUp?: React.EventHandler, + placeholder?: string, + preserveWhitespace?: boolean, + readOnly?: boolean, + scrollingContainer?: string | HTMLElement, + style?: React.CSSProperties, + tabIndex?: number, + theme?: string, + value?: Value, + } + + export interface UnprivilegedEditor { + getLength(): number; + getText(index?: number, length?: number): string; + getHTML(): string; + getBounds(index: number, length?: number): BoundsStatic; + getSelection(focus?: boolean): RangeStatic; + getContents(index?: number, length?: number): Delta; + } +} + +// Re-import everything from namespace into scope for comfort +import Value = QuillEditor.Value; +import Range = QuillEditor.Range; +import QuillOptions = QuillEditor.QuillOptions; +import ReactQuillProps = QuillEditor.ReactQuillProps; +import UnprivilegedEditor = QuillEditor.UnprivilegedEditor; + +interface ReactQuillState { + generation: number, +} + +class QuillEditor extends React.Component { + + static readonly displayName = 'React Quill' + + /* + Export Quill to be able to call `register` + */ + static readonly Quill = Quill; + + /* + Changing one of these props should cause a full re-render and a + re-instantiation of the Quill editor. + */ + dirtyProps: (keyof ReactQuillProps)[] = [ + 'modules', + 'formats', + 'bounds', + 'theme', + 'children', + ] + + /* + Changing one of these props should cause a regular update. These are mostly + props that act on the container, rather than the quillized editing area. + */ + cleanProps: (keyof ReactQuillProps)[] = [ + 'id', + 'className', + 'style', + 'placeholder', + 'tabIndex', + 'onChange', + 'onChangeSelection', + 'onFocus', + 'onBlur', + 'onKeyPress', + 'onKeyDown', + 'onKeyUp', + ] + + static defaultProps = { + theme: 'snow', + modules: {}, + readOnly: false, + } + + state: ReactQuillState = { + generation: 0, + } + + /* + The Quill Editor instance. + */ + editor?: Quill + + /* + Reference to the element holding the Quill editing area. + */ + editingArea?: React.ReactInstance | null + + /* + Tracks the internal value of the Quill editor + */ + value: Value + + /* + Tracks the internal selection of the Quill editor + */ + selection: Range = null + + /* + Used to compare whether deltas from `onChange` are being used as `value`. + */ + lastDeltaChangeSet?: Delta + + /* + Stores the contents of the editor to be restored after regeneration. + */ + regenerationSnapshot?: { + delta: Delta, + selection: Range, + } + + /* + A weaker, unprivileged proxy for the editor that does not allow accidentally + modifying editor state. + */ + unprivilegedEditor?: UnprivilegedEditor + + pasteListenerEvent!: (e: ClipboardEvent) => void; + + constructor(props: ReactQuillProps) { + super(props); + const value = this.isControlled() ? props.value : props.defaultValue; + this.value = value ?? ''; + } + validateProps(props: ReactQuillProps): void { + if (React.Children.count(props.children) > 1) throw new Error( + 'The Quill editing area can only be composed of a single React element.' + ); - // Check for multiline to show/hide toolbar - if (multiline) { - quill.on('text-change', () => { - const text = quill?.getText(); - const lineCount = (text?.match(/\n/g) || []).length; - setToolbarVisible(lineCount > 1); - }); + if (React.Children.count(props.children)) { + const child = React.Children.only(props.children); + if (child?.type === 'textarea') throw new Error( + 'Quill does not support editing on a