diff --git a/.env.example b/.env.example index 696d3a061..32ca137ed 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,13 @@ RELAY_URL=ws://localhost:3000 # (use `just web` for Vite HMR instead). # BUZZ_WEB_DIR=./web/dist +# ----------------------------------------------------------------------------- +# Desktop integrations (OAuth) +# ----------------------------------------------------------------------------- +# Optional Google Calendar desktop OAuth client. +# BUZZ_GOOGLE_CALENDAR_CLIENT_ID= +# BUZZ_GOOGLE_CALENDAR_CLIENT_SECRET= + # ----------------------------------------------------------------------------- # Git (NIP-34 bare repositories) # ----------------------------------------------------------------------------- diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6086233e8..ce2c4d8d7 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -78,7 +78,7 @@ serde_yaml = "0.9" toml = "0.8" nostr = { version = "0.44", features = ["nip44"] } zeroize = "1" -reqwest = { version = "0.13", features = ["json", "query", "stream"] } +reqwest = { version = "0.13", features = ["form", "json", "query", "stream"] } url = "2" buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" } buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" } diff --git a/desktop/src-tauri/src/commands/calendar.rs b/desktop/src-tauri/src/commands/calendar.rs new file mode 100644 index 000000000..9abb00fb1 --- /dev/null +++ b/desktop/src-tauri/src/commands/calendar.rs @@ -0,0 +1,547 @@ +use std::time::Duration; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tauri::State; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use url::Url; +use uuid::Uuid; + +use crate::app_state::{AppState, KEYRING_SERVICE}; +use crate::secret_store::SecretStore; + +const GOOGLE_CALENDAR_CREDENTIAL_KEY: &str = "google-calendar-oauth"; +const GOOGLE_AUTH_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth"; +const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; +const GOOGLE_CALENDAR_EVENTS_READONLY_SCOPE: &str = + "https://www.googleapis.com/auth/calendar.events.readonly"; +const OAUTH_CALLBACK_PATH: &str = "/"; +const OAUTH_CALLBACK_TIMEOUT: Duration = Duration::from_secs(180); + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct GoogleCalendarCredential { + access_token: Option, + connected_at: i64, + expires_at: Option, + refresh_token: String, + scope: Option, + token_type: Option, +} + +#[derive(Debug, Serialize)] +pub struct GoogleCalendarStatus { + configured: bool, + connected: bool, + connected_at: Option, + scopes: Vec, +} + +#[derive(Debug, Serialize)] +pub struct GoogleCalendarEvent { + id: String, + title: String, + starts_at: String, + ends_at: String, + all_day: bool, + join_url: Option, + html_url: Option, + transparency: Option, +} + +#[derive(Debug, Deserialize)] +struct GoogleTokenResponse { + access_token: Option, + expires_in: Option, + refresh_token: Option, + scope: Option, + token_type: Option, +} + +#[derive(Debug, Deserialize)] +struct GoogleEventsResponse { + items: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GoogleRawEvent { + id: Option, + summary: Option, + status: Option, + start: Option, + end: Option, + hangout_link: Option, + html_link: Option, + conference_data: Option, + transparency: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GoogleRawEventTime { + date: Option, + date_time: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GoogleConferenceData { + entry_points: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GoogleConferenceEntryPoint { + entry_point_type: Option, + uri: Option, +} + +fn google_calendar_client_id() -> Option { + std::env::var("BUZZ_GOOGLE_CALENDAR_CLIENT_ID") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(|| { + option_env!("BUZZ_GOOGLE_CALENDAR_CLIENT_ID") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn google_calendar_client_secret() -> Option { + std::env::var("BUZZ_GOOGLE_CALENDAR_CLIENT_SECRET") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(|| { + option_env!("BUZZ_GOOGLE_CALENDAR_CLIENT_SECRET") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn credential_store() -> &'static SecretStore { + SecretStore::shared(KEYRING_SERVICE) +} + +fn now_ts() -> i64 { + Utc::now().timestamp() +} + +fn load_credential() -> Result, String> { + let Some(raw) = credential_store().load(GOOGLE_CALENDAR_CREDENTIAL_KEY)? else { + return Ok(None); + }; + serde_json::from_str(&raw).map_err(|e| format!("parse Google Calendar credential: {e}")) +} + +fn save_credential(credential: &GoogleCalendarCredential) -> Result<(), String> { + let raw = serde_json::to_string(credential) + .map_err(|e| format!("serialize Google Calendar credential: {e}"))?; + credential_store().store(GOOGLE_CALENDAR_CREDENTIAL_KEY, &raw) +} + +fn scopes(scope: Option<&str>) -> Vec { + scope + .unwrap_or("") + .split_whitespace() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn status_from_credential(credential: Option<&GoogleCalendarCredential>) -> GoogleCalendarStatus { + GoogleCalendarStatus { + configured: google_calendar_client_id().is_some(), + connected: credential.is_some(), + connected_at: credential.map(|value| value.connected_at), + scopes: scopes(credential.and_then(|value| value.scope.as_deref())), + } +} + +fn pkce_verifier() -> String { + [ + Uuid::new_v4().simple().to_string(), + Uuid::new_v4().simple().to_string(), + Uuid::new_v4().simple().to_string(), + ] + .join("") +} + +fn pkce_challenge(verifier: &str) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) +} + +fn oauth_state() -> String { + [ + Uuid::new_v4().simple().to_string(), + Uuid::new_v4().simple().to_string(), + ] + .join("") +} + +fn oauth_authorization_url( + client_id: &str, + redirect_uri: &str, + state: &str, + code_challenge: &str, +) -> Result { + let mut url = Url::parse(GOOGLE_AUTH_URL).map_err(|e| e.to_string())?; + url.query_pairs_mut() + .append_pair("client_id", client_id) + .append_pair("redirect_uri", redirect_uri) + .append_pair("response_type", "code") + .append_pair("scope", GOOGLE_CALENDAR_EVENTS_READONLY_SCOPE) + .append_pair("state", state) + .append_pair("code_challenge", code_challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("prompt", "consent") + .append_pair("access_type", "offline"); + Ok(url) +} + +fn callback_response(title: &str, body: &str) -> String { + let html = format!( + "{title}\ +
\ +

{title}

{body}

" + ); + format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + html.len(), + html + ) +} + +async fn wait_for_oauth_callback( + listener: TcpListener, + expected_state: &str, +) -> Result { + let (mut stream, _) = tokio::time::timeout(OAUTH_CALLBACK_TIMEOUT, listener.accept()) + .await + .map_err(|_| "Timed out waiting for Google Calendar authorization.".to_string())? + .map_err(|e| format!("accept OAuth callback: {e}"))?; + + let mut buffer = [0_u8; 8192]; + let bytes_read = stream + .read(&mut buffer) + .await + .map_err(|e| format!("read OAuth callback: {e}"))?; + let request = String::from_utf8_lossy(&buffer[..bytes_read]); + let request_target = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .ok_or_else(|| "Invalid OAuth callback request.".to_string())?; + let callback_url = Url::parse(&format!("http://127.0.0.1{request_target}")) + .map_err(|e| format!("parse OAuth callback: {e}"))?; + + let mut code = None; + let mut state = None; + let mut error = None; + for (key, value) in callback_url.query_pairs() { + match key.as_ref() { + "code" => code = Some(value.into_owned()), + "state" => state = Some(value.into_owned()), + "error" => error = Some(value.into_owned()), + _ => {} + } + } + + let result = if callback_url.path() != OAUTH_CALLBACK_PATH { + Err("Google returned an unexpected OAuth callback path.".to_string()) + } else if let Some(error) = error { + Err(format!("Google Calendar authorization failed: {error}")) + } else if state.as_deref() != Some(expected_state) { + Err("Google Calendar authorization state did not match.".to_string()) + } else { + code.ok_or_else(|| "Google Calendar authorization returned no code.".to_string()) + }; + + let (title, body) = if result.is_ok() { + ( + "Google Calendar authorization received", + "Return to Buzz to finish connecting Google Calendar.", + ) + } else { + ( + "Google Calendar connection failed", + "Return to Buzz to try connecting again.", + ) + }; + let response = callback_response(title, body); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.shutdown().await; + + result +} + +async fn exchange_code_for_token( + code: &str, + code_verifier: &str, + redirect_uri: &str, + client_id: &str, + state: &AppState, +) -> Result { + let mut form = vec![ + ("client_id", client_id.to_string()), + ("code", code.to_string()), + ("code_verifier", code_verifier.to_string()), + ("grant_type", "authorization_code".to_string()), + ("redirect_uri", redirect_uri.to_string()), + ]; + if let Some(client_secret) = google_calendar_client_secret() { + form.push(("client_secret", client_secret)); + } + + let response = state + .http_client + .post(GOOGLE_TOKEN_URL) + .form(&form) + .send() + .await + .map_err(|e| format!("Google OAuth token exchange failed: {e}"))?; + parse_google_token_response(response).await +} + +async fn refresh_access_token( + credential: &GoogleCalendarCredential, + client_id: &str, + state: &AppState, +) -> Result { + let mut form = vec![ + ("client_id", client_id.to_string()), + ("refresh_token", credential.refresh_token.clone()), + ("grant_type", "refresh_token".to_string()), + ]; + if let Some(client_secret) = google_calendar_client_secret() { + form.push(("client_secret", client_secret)); + } + + let response = state + .http_client + .post(GOOGLE_TOKEN_URL) + .form(&form) + .send() + .await + .map_err(|e| format!("Google Calendar token refresh failed: {e}"))?; + parse_google_token_response(response).await +} + +async fn parse_google_token_response( + response: reqwest::Response, +) -> Result { + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("read Google token response: {e}"))?; + if !status.is_success() { + return Err(format!("Google token request failed ({status}): {body}")); + } + serde_json::from_str(&body).map_err(|e| format!("parse Google token response: {e}")) +} + +async fn access_token(state: &AppState) -> Result { + let client_id = google_calendar_client_id().ok_or_else(|| { + "Set BUZZ_GOOGLE_CALENDAR_CLIENT_ID to enable Google Calendar.".to_string() + })?; + let mut credential = load_credential()? + .ok_or_else(|| "Connect Google Calendar before reading events.".to_string())?; + + if let (Some(token), Some(expires_at)) = (&credential.access_token, credential.expires_at) { + if expires_at > now_ts() + 60 { + return Ok(token.clone()); + } + } + + let token = refresh_access_token(&credential, &client_id, state).await?; + let access_token = token + .access_token + .ok_or_else(|| "Google token refresh returned no access token.".to_string())?; + credential.access_token = Some(access_token.clone()); + credential.expires_at = token.expires_in.map(|seconds| now_ts() + seconds); + credential.scope = token.scope.or(credential.scope); + credential.token_type = token.token_type.or(credential.token_type); + save_credential(&credential)?; + Ok(access_token) +} + +fn is_http_url(value: &str) -> bool { + Url::parse(value) + .map(|url| url.scheme() == "http" || url.scheme() == "https") + .unwrap_or(false) +} + +fn event_join_url(event: &GoogleRawEvent) -> Option { + event + .conference_data + .as_ref() + .and_then(|data| data.entry_points.as_ref()) + .and_then(|entry_points| { + entry_points + .iter() + .find(|entry| entry.entry_point_type.as_deref() == Some("video")) + .and_then(|entry| entry.uri.as_deref()) + .filter(|value| is_http_url(value)) + .map(ToOwned::to_owned) + .or_else(|| { + entry_points + .iter() + .filter_map(|entry| entry.uri.as_deref()) + .find(|value| is_http_url(value)) + .map(ToOwned::to_owned) + }) + }) + .or_else(|| { + event + .hangout_link + .as_deref() + .filter(|value| is_http_url(value)) + .map(ToOwned::to_owned) + }) +} + +fn convert_google_event(event: GoogleRawEvent) -> Option { + if event.status.as_deref() == Some("cancelled") { + return None; + } + + let join_url = event_join_url(&event); + let html_url = event + .html_link + .as_ref() + .filter(|value| is_http_url(value)) + .cloned(); + let start = event.start?; + let end = event.end?; + let starts_at = start.date_time.or(start.date)?; + let ends_at = end.date_time.or(end.date)?; + let all_day = !starts_at.contains('T') && !ends_at.contains('T'); + + Some(GoogleCalendarEvent { + id: event + .id + .unwrap_or_else(|| Uuid::new_v4().simple().to_string()), + title: event.summary.unwrap_or_else(|| "Busy".to_string()), + starts_at, + ends_at, + all_day, + join_url, + html_url, + transparency: event.transparency, + }) +} + +#[tauri::command] +pub fn get_google_calendar_status() -> Result { + if google_calendar_client_id().is_none() { + return Ok(status_from_credential(None)); + } + + Ok(status_from_credential(load_credential()?.as_ref())) +} + +#[tauri::command] +pub async fn connect_google_calendar( + state: State<'_, AppState>, +) -> Result { + let client_id = google_calendar_client_id().ok_or_else(|| { + "Set BUZZ_GOOGLE_CALENDAR_CLIENT_ID to enable Google Calendar.".to_string() + })?; + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .map_err(|e| format!("bind Google Calendar OAuth callback: {e}"))?; + let port = listener + .local_addr() + .map_err(|e| format!("read OAuth callback address: {e}"))? + .port(); + let redirect_uri = format!("http://127.0.0.1:{port}"); + let code_verifier = pkce_verifier(); + let state_token = oauth_state(); + let auth_url = oauth_authorization_url( + &client_id, + &redirect_uri, + &state_token, + &pkce_challenge(&code_verifier), + )?; + + tauri_plugin_opener::open_url(auth_url.as_str(), None::<&str>) + .map_err(|e| format!("open Google authorization page: {e}"))?; + + let code = wait_for_oauth_callback(listener, &state_token).await?; + let token = + exchange_code_for_token(&code, &code_verifier, &redirect_uri, &client_id, &state).await?; + let refresh_token = token.refresh_token.ok_or_else(|| { + "Google did not return a refresh token. Disconnect and try again.".to_string() + })?; + let credential = GoogleCalendarCredential { + access_token: token.access_token, + connected_at: now_ts(), + expires_at: token.expires_in.map(|seconds| now_ts() + seconds), + refresh_token, + scope: token.scope, + token_type: token.token_type, + }; + save_credential(&credential)?; + + Ok(status_from_credential(Some(&credential))) +} + +#[tauri::command] +pub fn disconnect_google_calendar() -> Result { + credential_store().delete(GOOGLE_CALENDAR_CREDENTIAL_KEY)?; + Ok(status_from_credential(None)) +} + +#[tauri::command] +pub async fn get_google_calendar_events( + time_min: String, + time_max: String, + state: State<'_, AppState>, +) -> Result, String> { + let access_token = access_token(&state).await?; + let mut url = Url::parse("https://www.googleapis.com/calendar/v3/calendars/primary/events") + .map_err(|e| e.to_string())?; + url.query_pairs_mut() + .append_pair("timeMin", &time_min) + .append_pair("timeMax", &time_max) + .append_pair("singleEvents", "true") + .append_pair("orderBy", "startTime") + .append_pair("showDeleted", "false") + .append_pair("conferenceDataVersion", "1") + .append_pair("maxResults", "50"); + + let response = state + .http_client + .get(url) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Google Calendar events request failed: {e}"))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("read Google Calendar events response: {e}"))?; + if !status.is_success() { + return Err(format!( + "Google Calendar events request failed ({status}): {body}" + )); + } + + let raw: GoogleEventsResponse = + serde_json::from_str(&body).map_err(|e| format!("parse Google Calendar events: {e}"))?; + Ok(raw + .items + .unwrap_or_default() + .into_iter() + .filter_map(convert_google_event) + .collect()) +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 445fe2956..4a73f8c4e 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -3,6 +3,7 @@ mod agent_discovery; mod agent_models; mod agent_settings; mod agents; +mod calendar; mod canvas; mod channel_templates; mod channels; @@ -36,6 +37,7 @@ pub use agent_discovery::*; pub use agent_models::*; pub use agent_settings::*; pub use agents::*; +pub use calendar::*; pub use canvas::*; pub use channel_templates::*; pub use channels::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 369046a9d..e6db0b0a2 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -445,6 +445,10 @@ pub fn run() { get_relay_ws_url, get_relay_http_url, get_media_proxy_port, + get_google_calendar_status, + connect_google_calendar, + disconnect_google_calendar, + get_google_calendar_events, fetch_link_preview_title, discover_acp_providers, install_acp_runtime, diff --git a/desktop/src/features/calendar/api.ts b/desktop/src/features/calendar/api.ts new file mode 100644 index 000000000..e905b7b54 --- /dev/null +++ b/desktop/src/features/calendar/api.ts @@ -0,0 +1,88 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +export type GoogleCalendarStatus = { + configured: boolean; + connected: boolean; + connectedAt: number | null; + scopes: string[]; +}; + +export type GoogleCalendarEvent = { + id: string; + title: string; + startsAt: string; + endsAt: string; + allDay: boolean; + joinUrl: string | null; + htmlUrl: string | null; + transparency: string | null; +}; + +type RawGoogleCalendarStatus = { + configured: boolean; + connected: boolean; + connected_at: number | null; + scopes: string[]; +}; + +type RawGoogleCalendarEvent = { + id: string; + title: string; + starts_at: string; + ends_at: string; + all_day: boolean; + join_url: string | null; + html_url: string | null; + transparency: string | null; +}; + +function fromRawStatus(raw: RawGoogleCalendarStatus): GoogleCalendarStatus { + return { + configured: raw.configured, + connected: raw.connected, + connectedAt: raw.connected_at, + scopes: raw.scopes, + }; +} + +function fromRawEvent(raw: RawGoogleCalendarEvent): GoogleCalendarEvent { + return { + id: raw.id, + title: raw.title, + startsAt: raw.starts_at, + endsAt: raw.ends_at, + allDay: raw.all_day, + joinUrl: raw.join_url, + htmlUrl: raw.html_url, + transparency: raw.transparency, + }; +} + +export async function getGoogleCalendarStatus(): Promise { + return fromRawStatus( + await invokeTauri("get_google_calendar_status"), + ); +} + +export async function connectGoogleCalendar(): Promise { + return fromRawStatus( + await invokeTauri("connect_google_calendar"), + ); +} + +export async function disconnectGoogleCalendar(): Promise { + return fromRawStatus( + await invokeTauri("disconnect_google_calendar"), + ); +} + +export async function getGoogleCalendarEvents(input: { + timeMax: string; + timeMin: string; +}): Promise { + const raw = await invokeTauri( + "get_google_calendar_events", + input, + ); + return raw.map(fromRawEvent); +} diff --git a/desktop/src/features/calendar/hooks.ts b/desktop/src/features/calendar/hooks.ts new file mode 100644 index 000000000..7d4f112ff --- /dev/null +++ b/desktop/src/features/calendar/hooks.ts @@ -0,0 +1,203 @@ +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + connectGoogleCalendar, + disconnectGoogleCalendar, + getGoogleCalendarEvents, + getGoogleCalendarStatus, + type GoogleCalendarEvent, +} from "@/features/calendar/api"; + +export const googleCalendarStatusQueryKey = ["google-calendar-status"] as const; + +export const googleCalendarEventsQueryKey = ( + timeMin: string, + timeMax: string, +) => ["google-calendar-events", timeMin, timeMax] as const; + +const calendarBusyTimeFormatter = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", +}); + +function useCalendarNow(enabled: boolean): number { + const [now, setNow] = React.useState(() => Date.now()); + + React.useEffect(() => { + if (!enabled) return; + + setNow(Date.now()); + const interval = window.setInterval(() => setNow(Date.now()), 60_000); + return () => window.clearInterval(interval); + }, [enabled]); + + return now; +} + +export function useGoogleCalendarStatusQuery({ + enabled = true, +}: { + enabled?: boolean; +} = {}) { + return useQuery({ + enabled, + queryKey: googleCalendarStatusQueryKey, + queryFn: getGoogleCalendarStatus, + staleTime: 60_000, + }); +} + +export function useGoogleCalendarEventsQuery({ + enabled, + timeMax, + timeMin, +}: { + enabled: boolean; + timeMax: string; + timeMin: string; +}) { + return useQuery({ + enabled, + queryKey: googleCalendarEventsQueryKey(timeMin, timeMax), + queryFn: () => getGoogleCalendarEvents({ timeMax, timeMin }), + refetchInterval: enabled ? 60_000 : false, + staleTime: 45_000, + }); +} + +export function useGoogleCalendarConnectionMutations() { + const queryClient = useQueryClient(); + const invalidate = () => { + void queryClient.invalidateQueries({ + queryKey: googleCalendarStatusQueryKey, + }); + void queryClient.invalidateQueries({ + predicate: (query) => query.queryKey[0] === "google-calendar-events", + }); + }; + + const connect = useMutation({ + mutationFn: connectGoogleCalendar, + onSuccess: invalidate, + }); + const disconnect = useMutation({ + mutationFn: disconnectGoogleCalendar, + onSuccess: invalidate, + }); + + return { connect, disconnect }; +} + +export function eventStartDate(event: GoogleCalendarEvent): Date { + return parseCalendarDate(event.startsAt, event.allDay); +} + +export function eventEndDate(event: GoogleCalendarEvent): Date { + return parseCalendarDate(event.endsAt, event.allDay); +} + +export function isBusyCalendarEvent(event: GoogleCalendarEvent): boolean { + return !event.allDay && event.transparency !== "transparent"; +} + +export function isOngoingCalendarEvent( + event: GoogleCalendarEvent, + nowMs: number, +): boolean { + return ( + isBusyCalendarEvent(event) && + eventStartDate(event).getTime() <= nowMs && + eventEndDate(event).getTime() > nowMs + ); +} + +export function isUpcomingCalendarEvent( + event: GoogleCalendarEvent, + nowMs: number, +): boolean { + return isBusyCalendarEvent(event) && eventStartDate(event).getTime() > nowMs; +} + +export function formatCalendarBusyLabel(event: GoogleCalendarEvent): string { + return `In meeting until ${calendarBusyTimeFormatter.format( + eventEndDate(event), + )}`; +} + +export function parseCalendarDate(value: string, allDay: boolean): Date { + if (allDay && /^\d{4}-\d{2}-\d{2}$/.test(value)) { + return new Date(`${value}T00:00:00`); + } + return new Date(value); +} + +export function todayCalendarWindow(nowMs = Date.now()): { + timeMax: string; + timeMin: string; +} { + const start = new Date(nowMs); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + 1); + return { + timeMin: start.toISOString(), + timeMax: end.toISOString(), + }; +} + +export function currentCalendarWindow(nowMs = Date.now()): { + timeMax: string; + timeMin: string; +} { + const start = new Date(nowMs - 60 * 60 * 1_000); + const end = new Date(nowMs + 8 * 60 * 60 * 1_000); + return { + timeMin: start.toISOString(), + timeMax: end.toISOString(), + }; +} + +export function useCurrentGoogleCalendarEvent({ + enabled = true, +}: { + enabled?: boolean; +} = {}) { + const now = useCalendarNow(enabled); + const statusQuery = useGoogleCalendarStatusQuery({ enabled }); + const window = React.useMemo(() => todayCalendarWindow(now), [now]); + const eventsQuery = useGoogleCalendarEventsQuery({ + enabled: Boolean( + enabled && statusQuery.data?.configured && statusQuery.data.connected, + ), + timeMax: window.timeMax, + timeMin: window.timeMin, + }); + const events = eventsQuery.data ?? []; + const sortedEvents = React.useMemo( + () => + [...events].sort( + (a, b) => eventStartDate(a).getTime() - eventStartDate(b).getTime(), + ), + [events], + ); + const currentEvent = React.useMemo( + () => + sortedEvents.find((event) => isOngoingCalendarEvent(event, now)) ?? null, + [sortedEvents, now], + ); + const nextEvent = React.useMemo( + () => + sortedEvents.find((event) => isUpcomingCalendarEvent(event, now)) ?? null, + [sortedEvents, now], + ); + + return { + currentEvent, + eventsQuery, + isConnected: Boolean(statusQuery.data?.connected), + isConfigured: Boolean(statusQuery.data?.configured), + nextEvent, + statusQuery, + }; +} diff --git a/desktop/src/features/calendar/ui/CalendarSettingsCard.tsx b/desktop/src/features/calendar/ui/CalendarSettingsCard.tsx new file mode 100644 index 000000000..aeee6d450 --- /dev/null +++ b/desktop/src/features/calendar/ui/CalendarSettingsCard.tsx @@ -0,0 +1,134 @@ +import { CalendarDays, Check, ExternalLink, Plug, Unplug } from "lucide-react"; + +import { + useGoogleCalendarConnectionMutations, + useGoogleCalendarStatusQuery, +} from "@/features/calendar/hooks"; +import { Button } from "@/shared/ui/button"; +import { + SettingsOptionGroup, + SettingsOptionRow, +} from "@/features/settings/ui/SettingsOptionGroup"; +import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; + +function errorMessage(error: unknown): string | null { + if (!error) return null; + return error instanceof Error ? error.message : String(error); +} + +export function ConnectionsSettingsCard() { + const statusQuery = useGoogleCalendarStatusQuery(); + const { connect, disconnect } = useGoogleCalendarConnectionMutations(); + const status = statusQuery.data; + const isBusy = connect.isPending || disconnect.isPending; + const isLoading = statusQuery.isLoading; + const mutationError = + errorMessage(connect.error) ?? errorMessage(disconnect.error); + const statusError = errorMessage(statusQuery.error); + const description = isLoading + ? "Checking Google Calendar connection status." + : status?.connected + ? "Connected with read-only event access. Meeting details stay on this device." + : status?.configured + ? "Connect to read upcoming events and video meeting links." + : "Google Calendar is unavailable until this build includes a Google OAuth client ID."; + + return ( +
+ + +
+ + +
+ + + Google Calendar + +

+ {description} +

+
+ {status?.connected ? ( + + ) : ( + + )} +
+
+ + {status?.connected ? ( +
+ + + Google Calendar is ready. The sidebar will show current and + upcoming meetings. + +
+ ) : null} + + {!isLoading && !status?.configured ? ( +
+ Connect is unavailable because this build is missing its Google + OAuth client ID. End users do not create this themselves; local and + staging builds need{" "} + + BUZZ_GOOGLE_CALENDAR_CLIENT_ID + + . If the OAuth app has a client secret, also provide{" "} + + BUZZ_GOOGLE_CALENDAR_CLIENT_SECRET + + . +
+ ) : null} + + {!status?.connected && status?.configured ? ( +
+ Connect opens Google OAuth in your browser. After you approve + read-only calendar access, Buzz stores the refresh token in the + system keychain. +
+ ) : null} + + {statusError || mutationError ? ( +
+ {mutationError ?? statusError} +
+ ) : null} + + + Google OAuth desktop app setup + + +
+
+ ); +} + +export const CalendarSettingsCard = ConnectionsSettingsCard; diff --git a/desktop/src/features/profile/ui/ProfilePopover.tsx b/desktop/src/features/profile/ui/ProfilePopover.tsx index 7b6478767..b06f08377 100644 --- a/desktop/src/features/profile/ui/ProfilePopover.tsx +++ b/desktop/src/features/profile/ui/ProfilePopover.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { ChevronRight, Smile } from "lucide-react"; +import { CalendarDays, ChevronRight, Smile } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; @@ -18,6 +18,7 @@ interface ProfilePopoverProps { avatarDataUrl?: string | null; currentStatus: PresenceStatus; isStatusPending?: boolean; + calendarBusyLabel?: string | null; userStatusText?: string; userStatusEmoji?: string; onSetStatus: (status: PresenceStatus) => void; @@ -48,6 +49,7 @@ export function ProfilePopover({ avatarDataUrl, currentStatus, isStatusPending, + calendarBusyLabel, userStatusText, userStatusEmoji, onSetStatus, @@ -62,6 +64,7 @@ export function ProfilePopover({ const [presenceMenuOpen, setPresenceMenuOpen] = React.useState(false); const presenceHoverTimer = React.useRef(null); const hasUserStatus = Boolean(userStatusText || userStatusEmoji); + const hasCalendarBusy = Boolean(calendarBusyLabel); const settingsShortcutLabel = isMacPlatform() ? "⌘," : "Ctrl+,"; function clearPresenceHoverTimer() { @@ -167,7 +170,11 @@ export function ProfilePopover({ role="menuitem" type="button" > - + {hasCalendarBusy && !hasUserStatus ? ( + + ) : ( + + )} {hasUserStatus ? ( {userStatusEmoji ? ( @@ -178,6 +185,10 @@ export function ProfilePopover({ ) : null} {userStatusText} + ) : hasCalendarBusy ? ( + + {calendarBusyLabel} + ) : ( Update your status diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index be6b1f6c3..ca27216b3 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -1,6 +1,11 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { Activity, Headphones, MessageSquare } from "lucide-react"; +import { + Activity, + CalendarDays, + Headphones, + MessageSquare, +} from "lucide-react"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; @@ -42,6 +47,10 @@ import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; import { useNow } from "@/shared/lib/useNow"; import { Button } from "@/shared/ui/button"; import { Spinner } from "@/shared/ui/spinner"; +import { + formatCalendarBusyLabel, + useCurrentGoogleCalendarEvent, +} from "@/features/calendar/hooks"; type UserProfilePopoverProps = { children: React.ReactNode; @@ -216,6 +225,13 @@ export function UserProfilePopover({ const isSelf = currentPubkey !== undefined && currentPubkey.toLowerCase() === pubkey.toLowerCase(); + const { currentEvent: selfCalendarEvent } = useCurrentGoogleCalendarEvent({ + enabled: open && isSelf, + }); + const selfCalendarBusyLabel = selfCalendarEvent + ? formatCalendarBusyLabel(selfCalendarEvent) + : null; + const hasSelfCalendarBusy = Boolean(selfCalendarBusyLabel); const showProfileActions = currentPubkey !== undefined && !isSelf; const selfProfileQuery = useProfileQuery(open && showProfileActions); const isCurrentUserOwner = @@ -573,7 +589,7 @@ export function UserProfilePopover({ ) : null} - {hasUserStatus || showProfileActions ? ( + {hasUserStatus || hasSelfCalendarBusy || showProfileActions ? ( <>