Skip to content

Commit 7accfdb

Browse files
committed
feat(tray): add Recent Transcriptions copy, Recording Mode submenu, and Check for Updates; sync Dashboard↔Tray mode changes; refresh tray on history changes
1 parent ac80b7b commit 7accfdb

File tree

5 files changed

+203
-2
lines changed

5 files changed

+203
-2
lines changed

src-tauri/src/commands/audio.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1350,6 +1350,11 @@ pub async fn save_transcription(app: AppHandle, text: String, model: String) ->
13501350
// Emit the new transcription data to frontend for append-only update
13511351
let _ = emit_to_window(&app, "main", "transcription-added", transcription_data);
13521352

1353+
// Refresh tray menu (best-effort) so Recent Transcriptions stays updated
1354+
if let Err(e) = crate::commands::settings::update_tray_menu(app.clone()).await {
1355+
log::warn!("Failed to update tray menu after saving transcription: {}", e);
1356+
}
1357+
13531358
log::info!("Saved transcription with {} characters", text.len());
13541359
Ok(())
13551360
}
@@ -1686,6 +1691,11 @@ pub async fn delete_transcription_entry(app: AppHandle, timestamp: String) -> Re
16861691
// Emit event to update UI
16871692
let _ = emit_to_window(&app, "main", "history-updated", ());
16881693

1694+
// Refresh tray menu to reflect removal
1695+
if let Err(e) = crate::commands::settings::update_tray_menu(app.clone()).await {
1696+
log::warn!("Failed to update tray menu after deletion: {}", e);
1697+
}
1698+
16891699
log::info!("Deleted transcription entry: {}", timestamp);
16901700
Ok(())
16911701
}
@@ -1714,6 +1724,11 @@ pub async fn clear_all_transcriptions(app: AppHandle) -> Result<(), String> {
17141724
// Emit event to update UI
17151725
let _ = emit_to_window(&app, "main", "history-updated", ());
17161726

1727+
// Refresh tray menu after clearing
1728+
if let Err(e) = crate::commands::settings::update_tray_menu(app.clone()).await {
1729+
log::warn!("Failed to update tray menu after clearing history: {}", e);
1730+
}
1731+
17171732
log::info!("Cleared all transcription entries: {} items", count);
17181733
Ok(())
17191734
}

src-tauri/src/commands/settings.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,15 @@ pub async fn get_settings(app: AppHandle) -> Result<Settings, String> {
131131
pub async fn save_settings(app: AppHandle, settings: Settings) -> Result<(), String> {
132132
let store = app.store("settings").map_err(|e| e.to_string())?;
133133

134-
// Check if model changed
134+
// Check if model and recording mode changed
135135
let old_model = store
136136
.get("current_model")
137137
.and_then(|v| v.as_str().map(|s| s.to_string()))
138138
.unwrap_or_default();
139+
let old_mode = store
140+
.get("recording_mode")
141+
.and_then(|v| v.as_str().map(|s| s.to_string()))
142+
.unwrap_or_else(|| Settings::default().recording_mode);
139143

140144
store.set("hotkey", json!(settings.hotkey));
141145
store.set("current_model", json!(settings.current_model));
@@ -260,6 +264,13 @@ pub async fn save_settings(app: AppHandle, settings: Settings) -> Result<(), Str
260264
}
261265
}
262266

267+
// If recording mode changed, refresh tray to update checked state
268+
if old_mode != settings.recording_mode {
269+
if let Err(e) = update_tray_menu(app.clone()).await {
270+
log::warn!("Failed to update tray menu after mode change: {}", e);
271+
}
272+
}
273+
263274
Ok(())
264275
}
265276

src-tauri/src/commands/text.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,21 @@ pub async fn insert_text(app: tauri::AppHandle, text: String) -> Result<(), Stri
5252
.map_err(|e| format!("Task failed: {}", e))?
5353
}
5454

55+
/// Copy plain text to the system clipboard without attempting to paste
56+
#[tauri::command]
57+
pub async fn copy_text_to_clipboard(text: String) -> Result<(), String> {
58+
tokio::task::spawn_blocking(move || {
59+
let mut clipboard =
60+
Clipboard::new().map_err(|e| format!("Failed to initialize clipboard: {}", e))?;
61+
clipboard
62+
.set_text(&text)
63+
.map_err(|e| format!("Failed to set clipboard: {}", e))?;
64+
Ok(())
65+
})
66+
.await
67+
.map_err(|e| format!("Task failed: {}", e))?
68+
}
69+
5570
fn insert_via_clipboard(text: String, has_accessibility_permission: bool, app_handle: Option<tauri::AppHandle>) -> Result<(), String> {
5671
// This function handles both copying text to clipboard AND pasting it at cursor
5772
// Initialize clipboard

src-tauri/src/lib.rs

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,85 @@ async fn build_tray_menu<R: tauri::Runtime>(
195195
None
196196
};
197197

198+
// Recent transcriptions (last 5)
199+
use tauri_plugin_store::StoreExt;
200+
let mut recent_owned: Vec<tauri::menu::MenuItem<R>> = Vec::new();
201+
{
202+
if let Ok(store) = app.store("transcriptions") {
203+
let mut entries: Vec<(String, serde_json::Value)> = Vec::new();
204+
for key in store.keys() {
205+
if let Some(value) = store.get(&key) {
206+
entries.push((key.to_string(), value));
207+
}
208+
}
209+
entries.sort_by(|a, b| b.0.cmp(&a.0));
210+
entries.truncate(5);
211+
212+
for (ts, entry) in entries {
213+
let mut label = entry
214+
.get("text")
215+
.and_then(|v| v.as_str())
216+
.map(|s| {
217+
let first_line = s.lines().next().unwrap_or("").trim();
218+
if first_line.len() > 40 {
219+
format!("{}…", &first_line[..40])
220+
} else if first_line.is_empty() {
221+
"(empty)".to_string()
222+
} else {
223+
first_line.to_string()
224+
}
225+
})
226+
.unwrap_or_else(|| "(unknown)".to_string());
227+
228+
if label.is_empty() { label = "(empty)".to_string(); }
229+
230+
let item = tauri::menu::MenuItem::with_id(
231+
app,
232+
&format!("recent_copy_{}", ts),
233+
label,
234+
true,
235+
None::<&str>,
236+
)?;
237+
recent_owned.push(item);
238+
}
239+
}
240+
}
241+
let mut recent_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = Vec::new();
242+
for item in &recent_owned { recent_refs.push(item); }
243+
244+
// Recording mode submenu (Toggle / Push-to-Talk)
245+
let (toggle_item, ptt_item) = {
246+
let recording_mode = match app.store("settings") {
247+
Ok(store) => store
248+
.get("recording_mode")
249+
.and_then(|v| v.as_str().map(|s| s.to_string()))
250+
.unwrap_or_else(|| "toggle".to_string()),
251+
Err(_) => "toggle".to_string(),
252+
};
253+
254+
let toggle = tauri::menu::CheckMenuItem::with_id(
255+
app,
256+
"recording_mode_toggle",
257+
"Toggle",
258+
true,
259+
recording_mode == "toggle",
260+
None::<&str>,
261+
)?;
262+
let ptt = tauri::menu::CheckMenuItem::with_id(
263+
app,
264+
"recording_mode_push_to_talk",
265+
"Push-to-Talk",
266+
true,
267+
recording_mode == "push_to_talk",
268+
None::<&str>,
269+
)?;
270+
(toggle, ptt)
271+
};
272+
198273
// Create menu items
199274
let separator1 = PredefinedMenuItem::separator(app)?;
200275
let settings_i = MenuItem::with_id(app, "settings", "Dashboard", true, None::<&str>)?;
276+
let check_updates_i = MenuItem::with_id(app, "check_updates", "Check for Updates", true, None::<&str>)?;
201277
let separator2 = PredefinedMenuItem::separator(app)?;
202278
let quit_i = MenuItem::with_id(app, "quit", "Quit VoiceTypr", true, None::<&str>)?;
203279

@@ -211,9 +287,27 @@ async fn build_tray_menu<R: tauri::Runtime>(
211287
menu_builder = menu_builder.item(&microphone_submenu);
212288
}
213289

290+
// Add Recent Transcriptions submenu if we have items
291+
if !recent_refs.is_empty() {
292+
let recent_submenu = Submenu::with_id_and_items(
293+
app,
294+
"recent",
295+
"Recent Transcriptions",
296+
true,
297+
&recent_refs,
298+
)?;
299+
menu_builder = menu_builder.item(&recent_submenu);
300+
}
301+
302+
// Recording mode submenu
303+
let mode_items: Vec<&dyn tauri::menu::IsMenuItem<_>> = vec![&toggle_item, &ptt_item];
304+
let mode_submenu = Submenu::with_id_and_items(app, "recording_mode", "Recording Mode", true, &mode_items)?;
305+
menu_builder = menu_builder.item(&mode_submenu);
306+
214307
let menu = menu_builder
215308
.item(&separator1)
216309
.item(&settings_i)
310+
.item(&check_updates_i)
217311
.item(&separator2)
218312
.item(&quit_i)
219313
.build()?;
@@ -1014,7 +1108,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
10141108
.menu(&menu)
10151109
.on_menu_event(move |app, event| {
10161110
log::info!("Tray menu event: {:?}", event.id);
1017-
let event_id = event.id.as_ref();
1111+
let event_id = event.id.as_ref().to_string();
10181112

10191113
if event_id == "settings" {
10201114
if let Some(window) = app.get_webview_window("main") {
@@ -1025,6 +1119,8 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
10251119
}
10261120
} else if event_id == "quit" {
10271121
app.exit(0);
1122+
} else if event_id == "check_updates" {
1123+
let _ = app.emit("tray-check-updates", ());
10281124
} else if event_id.starts_with("model_") {
10291125
// Handle model selection
10301126
let model_name = match event_id.strip_prefix("model_") {
@@ -1086,6 +1182,59 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
10861182
}
10871183
});
10881184
}
1185+
// Recent transcriptions copy handler
1186+
else if let Some(ts) = event_id.strip_prefix("recent_copy_") {
1187+
let ts_owned = ts.to_string();
1188+
let app_handle = app.app_handle().clone();
1189+
tauri::async_runtime::spawn(async move {
1190+
// Read text by timestamp and copy
1191+
match app_handle.store("transcriptions") {
1192+
Ok(store) => {
1193+
if let Some(val) = store.get(&ts_owned) {
1194+
if let Some(text) = val.get("text").and_then(|v| v.as_str()) {
1195+
if let Err(e) = crate::commands::text::copy_text_to_clipboard(text.to_string()).await {
1196+
log::error!("Failed to copy recent transcription: {}", e);
1197+
let _ = app_handle.emit("tray-action-error", &format!("Failed to copy: {}", e));
1198+
} else {
1199+
log::info!("Copied recent transcription to clipboard");
1200+
}
1201+
}
1202+
}
1203+
}
1204+
Err(e) => {
1205+
log::error!("Failed to open transcriptions store: {}", e);
1206+
}
1207+
}
1208+
});
1209+
}
1210+
// Recording mode switchers
1211+
else if event_id == "recording_mode_toggle" || event_id == "recording_mode_push_to_talk" {
1212+
let app_handle = app.app_handle().clone();
1213+
let mode = if event_id.ends_with("push_to_talk") { "push_to_talk" } else { "toggle" };
1214+
tauri::async_runtime::spawn(async move {
1215+
match crate::commands::settings::get_settings(app_handle.clone()).await {
1216+
Ok(mut s) => {
1217+
s.recording_mode = mode.to_string();
1218+
match crate::commands::settings::save_settings(app_handle.clone(), s).await {
1219+
Err(e) => {
1220+
log::error!("Failed to save recording mode from tray: {}", e);
1221+
let _ = app_handle.emit("tray-action-error", &format!("Failed to change recording mode: {}", e));
1222+
}
1223+
Ok(()) => {
1224+
if let Err(e) = crate::commands::settings::update_tray_menu(app_handle.clone()).await {
1225+
log::warn!("Failed to refresh tray after mode change: {}", e);
1226+
}
1227+
// Notify frontend so SettingsContext refreshes
1228+
let _ = app_handle.emit("settings-changed", ());
1229+
}
1230+
}
1231+
}
1232+
Err(e) => {
1233+
log::error!("Failed to get settings for mode change: {}", e);
1234+
}
1235+
}
1236+
});
1237+
}
10891238
})
10901239
.on_tray_icon_event(|tray, event| {
10911240
if let TrayIconEvent::Click {
@@ -1479,6 +1628,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
14791628
reset_app_data,
14801629
copy_image_to_clipboard,
14811630
save_image_to_file,
1631+
copy_text_to_clipboard,
14821632
get_ai_settings,
14831633
get_ai_settings_for_provider,
14841634
cache_ai_api_key,

src/components/AppContainer.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ export function AppContainer() {
8080
setActiveSection("overview");
8181
});
8282

83+
// Listen for manual update checks triggered from tray
84+
registerEvent("tray-check-updates", async () => {
85+
try {
86+
await updateService.checkForUpdatesManually();
87+
} catch (e) {
88+
console.error("Manual update check failed:", e);
89+
toast.error("Failed to check for updates");
90+
}
91+
});
92+
8393
// Listen for tray action errors
8494
registerEvent("tray-action-error", (event) => {
8595
console.error("Tray action error:", event.payload);

0 commit comments

Comments
 (0)