Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 34 additions & 1 deletion apps/desktop/src/components/settings/lab/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ import { arch, platform } from "@tauri-apps/plugin-os";
import { commands as openerCommands } from "@hypr/plugin-opener2";
import { commands as windowsCommands } from "@hypr/plugin-windows";
import { Button } from "@hypr/ui/components/ui/button";
import { Switch } from "@hypr/ui/components/ui/switch";

import { useConfigValue } from "../../../config/use-config";
import * as settings from "../../../store/tinybase/store/settings";

export function SettingsLab() {
const handleOpenControlWindow = async () => {
await windowsCommands.windowShow({ type: "control" });
};

return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 pt-3">
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<h3 className="text-sm font-medium mb-1">Control Overlay</h3>
Expand All @@ -25,11 +29,40 @@ export function SettingsLab() {
</Button>
</div>

<MeetingReminderToggle />

<DownloadNightlyButton />
</div>
);
}

function MeetingReminderToggle() {
const value = useConfigValue("notification_in_meeting_reminder");
const setValue = settings.UI.useSetValueCallback(
"notification_in_meeting_reminder",
(_event: unknown, value: boolean) => value,
[],
settings.STORE_ID,
);

return (
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<h3 className="text-sm font-medium mb-1">In-Meeting Reminder</h3>
<p className="text-xs text-neutral-600">
Get a nudge when an app like Zoom or Google Meet has been using your
mic for a few minutes without Hyprnote recording. Helps you never miss
capturing a meeting.
</p>
</div>
<Switch
checked={value}
onCheckedChange={(checked) => setValue(undefined, checked)}
/>
</div>
);
}

function DownloadNightlyButton() {
const platformName = platform();
const archQuery = useQuery({
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/config/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export type ConfigKey =
| "telemetry_consent"
| "current_llm_provider"
| "current_llm_model"
| "timezone";
| "timezone"
| "notification_in_meeting_reminder";

type ConfigValueType<K extends ConfigKey> =
(typeof CONFIG_REGISTRY)[K]["default"];
Expand Down Expand Up @@ -145,4 +146,9 @@ export const CONFIG_REGISTRY = {
key: "timezone",
default: undefined as string | undefined,
},

notification_in_meeting_reminder: {
key: "notification_in_meeting_reminder",
default: true,
},
} satisfies Record<ConfigKey, ConfigDefinition>;
27 changes: 27 additions & 0 deletions apps/desktop/src/contexts/listener.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,20 @@ const useHandleDetectEvents = (store: ListenerStore) => {
const stop = useStore(store, (state) => state.stop);
const setMuted = useStore(store, (state) => state.setMuted);
const notificationDetectEnabled = useConfigValue("notification_detect");
const inMeetingReminderEnabled = useConfigValue(
"notification_in_meeting_reminder",
);

const notificationDetectEnabledRef = useRef(notificationDetectEnabled);
useEffect(() => {
notificationDetectEnabledRef.current = notificationDetectEnabled;
}, [notificationDetectEnabled]);

const inMeetingReminderEnabledRef = useRef(inMeetingReminderEnabled);
useEffect(() => {
inMeetingReminderEnabledRef.current = inMeetingReminderEnabled;
}, [inMeetingReminderEnabled]);

useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
Expand Down Expand Up @@ -103,6 +111,25 @@ const useHandleDetectEvents = (store: ListenerStore) => {
}
} else if (payload.type === "micMuted") {
setMuted(payload.value);
} else if (payload.type === "micProlongedUsage") {
if (!inMeetingReminderEnabledRef.current) {
return;
}

const minutes = Math.round(payload.duration_secs / 60);
const appName = payload.app.name;

void notificationCommands.showNotification({
key: payload.key,
title: "Meeting in progress?",
message: `${appName} has been using the mic for ${minutes} min. Start listening?`,
timeout: { secs: 15, nanos: 0 },
event_id: null,
start_time: null,
participants: null,
event_details: null,
action_label: null,
});
}
})
.then((fn) => {
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/store/tinybase/store/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export const SETTINGS_MAPPING = {
type: "string",
path: ["general", "timezone"],
},
notification_in_meeting_reminder: {
type: "boolean",
path: ["notification", "in_meeting_reminder"],
},
},
tables: {
ai_providers: {
Expand Down
3 changes: 2 additions & 1 deletion plugins/detect/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ tauri-plugin = { workspace = true, features = ["build"] }

[dev-dependencies]
specta-typescript = { workspace = true }
tokio = { workspace = true, features = ["test-util"] }

[dependencies]
hypr-detect = { workspace = true, features = ["mic", "list", "language", "sleep"] }
hypr-host = { workspace = true }
hypr-notification-interface = { workspace = true }
tauri-plugin-listener = { workspace = true }

tauri = { workspace = true, features = ["specta", "test"] }
tauri-plugin-windows = { workspace = true }
Expand All @@ -36,4 +36,5 @@ thiserror = { workspace = true }
uuid = { workspace = true, features = ["v4"] }

tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
tokio-util = { workspace = true }
tracing = { workspace = true }
2 changes: 1 addition & 1 deletion plugins/detect/js/bindings.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ detectEvent: "plugin:detect:detect-event"

/** user-defined types **/

export type DetectEvent = { type: "micStarted"; key: string; apps: InstalledApp[] } | { type: "micStopped"; apps: InstalledApp[] } | { type: "micMuted"; value: boolean } | { type: "sleepStateChanged"; value: boolean }
export type DetectEvent = { type: "micStarted"; key: string; apps: InstalledApp[] } | { type: "micStopped"; apps: InstalledApp[] } | { type: "micMuted"; value: boolean } | { type: "sleepStateChanged"; value: boolean } | { type: "micProlongedUsage"; key: string; app: InstalledApp; duration_secs: number }
export type InstalledApp = { id: string; name: string }

/** tauri-specta globals **/
Expand Down
4 changes: 2 additions & 2 deletions plugins/detect/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ pub(crate) async fn set_ignored_bundle_ids<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
bundle_ids: Vec<String>,
) -> Result<(), String> {
app.detect().set_ignored_bundle_ids(bundle_ids).await;
app.detect().set_ignored_bundle_ids(bundle_ids);
Ok(())
}

Expand All @@ -40,7 +40,7 @@ pub(crate) async fn set_respect_do_not_disturb<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
enabled: bool,
) -> Result<(), String> {
app.detect().set_respect_do_not_disturb(enabled).await;
app.detect().set_respect_do_not_disturb(enabled);
Ok(())
}

Expand Down
73 changes: 73 additions & 0 deletions plugins/detect/src/env.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use tauri::{AppHandle, EventTarget, Runtime};
use tauri_plugin_windows::WindowImpl;
use tauri_specta::Event;

use crate::DetectEvent;

pub(crate) trait Env: Clone + Send + Sync + 'static {
fn emit(&self, event: DetectEvent);
fn is_do_not_disturb(&self) -> bool;
}

pub(crate) struct TauriEnv<R: Runtime> {
pub(crate) app_handle: AppHandle<R>,
}

impl<R: Runtime> Clone for TauriEnv<R> {
fn clone(&self) -> Self {
Self {
app_handle: self.app_handle.clone(),
}
}
}

impl<R: Runtime> Env for TauriEnv<R> {
fn emit(&self, event: DetectEvent) {
let _ = event.emit_to(
&self.app_handle,
EventTarget::AnyLabel {
label: tauri_plugin_windows::AppWindow::Main.label(),
},
);
}

fn is_do_not_disturb(&self) -> bool {
crate::dnd::is_do_not_disturb()
}
}

#[cfg(test)]
pub(crate) mod test_support {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};

#[derive(Clone)]
pub(crate) struct TestEnv {
pub(crate) events: Arc<std::sync::Mutex<Vec<DetectEvent>>>,
dnd: Arc<AtomicBool>,
}

impl TestEnv {
pub(crate) fn new() -> Self {
Self {
events: Arc::new(std::sync::Mutex::new(Vec::new())),
dnd: Arc::new(AtomicBool::new(false)),
}
}

pub(crate) fn set_dnd(&self, value: bool) {
self.dnd.store(value, Ordering::Relaxed);
}
}

impl Env for TestEnv {
fn emit(&self, event: DetectEvent) {
self.events.lock().unwrap().push(event);
}

fn is_do_not_disturb(&self) -> bool {
self.dnd.load(Ordering::Relaxed)
}
}
}
26 changes: 6 additions & 20 deletions plugins/detect/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,11 @@ common_event_derives! {
MicMuteStateChanged { value: bool },
#[serde(rename = "sleepStateChanged")]
SleepStateChanged { value: bool },
}
}

impl From<hypr_detect::DetectEvent> for DetectEvent {
fn from(event: hypr_detect::DetectEvent) -> Self {
match event {
hypr_detect::DetectEvent::MicStarted(apps) => Self::MicStarted {
key: uuid::Uuid::new_v4().to_string(),
apps,
},
hypr_detect::DetectEvent::MicStopped(apps) => Self::MicStopped { apps },
#[cfg(all(target_os = "macos", feature = "zoom"))]
hypr_detect::DetectEvent::ZoomMuteStateChanged { value } => {
Self::MicMuteStateChanged { value }
}
#[cfg(all(target_os = "macos", feature = "sleep"))]
hypr_detect::DetectEvent::SleepStateChanged { value } => {
Self::SleepStateChanged { value }
}
}
#[serde(rename = "micProlongedUsage")]
MicProlongedUsage {
key: String,
app: hypr_detect::InstalledApp,
duration_secs: u64,
},
}
}
17 changes: 10 additions & 7 deletions plugins/detect/src/ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,18 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager<R>> Detect<'a, R, M> {
crate::policy::default_ignored_bundle_ids()
}

pub async fn set_ignored_bundle_ids(&self, bundle_ids: Vec<String>) {
let state = self.manager.state::<crate::SharedState>();
let mut state_guard = state.lock().await;
state_guard.policy.user_ignored_bundle_ids = bundle_ids;
pub fn set_ignored_bundle_ids(&self, bundle_ids: Vec<String>) {
let state = self.manager.state::<crate::ProcessorState>();
let mut state_guard = state.lock().unwrap_or_else(|e| e.into_inner());
for id in &bundle_ids {
state_guard.mic_usage_tracker.cancel_app(id);
}
state_guard.policy.user_ignored_bundle_ids = bundle_ids.into_iter().collect();
}

pub async fn set_respect_do_not_disturb(&self, enabled: bool) {
let state = self.manager.state::<crate::SharedState>();
let mut state_guard = state.lock().await;
pub fn set_respect_do_not_disturb(&self, enabled: bool) {
let state = self.manager.state::<crate::ProcessorState>();
let mut state_guard = state.lock().unwrap_or_else(|e| e.into_inner());
state_guard.policy.respect_dnd = enabled;
}
}
Expand Down
Loading
Loading