aboutsummaryrefslogtreecommitdiff
path: root/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/next-meeting.jsx
diff options
context:
space:
mode:
Diffstat (limited to 'users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/next-meeting.jsx')
-rwxr-xr-xusers/ryan/modules/simple-bar/simple-bar-source/lib/components/data/next-meeting.jsx310
1 files changed, 310 insertions, 0 deletions
diff --git a/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/next-meeting.jsx b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/next-meeting.jsx
new file mode 100755
index 0000000..74f6bf8
--- /dev/null
+++ b/users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/next-meeting.jsx
@@ -0,0 +1,310 @@
1/**
2 * Next Meeting Widget
3 *
4 * Displays the next upcoming calendar event with time until start and optional join button.
5 * Uses icalBuddy for fast calendar access with proper recurring event support.
6 *
7 * Requirements:
8 * - macOS with Calendar.app or compatible calendar
9 * - icalBuddy installed (brew install ical-buddy)
10 * - Calendar access permissions granted
11 *
12 * @module next-meeting
13 */
14import * as Uebersicht from "uebersicht";
15import * as DataWidget from "./data-widget.jsx";
16import * as DataWidgetLoader from "./data-widget-loader.jsx";
17import * as Icons from "../icons/icons.jsx";
18import useWidgetRefresh from "../../hooks/use-widget-refresh";
19import useServerSocket from "../../hooks/use-server-socket";
20import { useSimpleBarContext } from "../simple-bar-context.jsx";
21import * as Utils from "../../utils";
22
23export { nextMeetingStyles as styles } from "../../styles/components/data/next-meeting";
24
25const { React } = Uebersicht;
26
27const DEFAULT_REFRESH_FREQUENCY = 60000; // 1 minute
28
29// Timing thresholds (in minutes)
30const URGENT_THRESHOLD = 5; // Red pulsing when ≤5 min until meeting
31const UPCOMING_THRESHOLD = 15; // Yellow when ≤15 min until meeting
32
33// Uses icalBuddy for fast calendar access (handles recurring events properly)
34// Note: Shell script automatically skips meetings that started more than 15 minutes ago
35const BASE_COMMAND = `./simple-bar/lib/scripts/next-meeting.sh`;
36
37/**
38 * Builds the shell command with optional custom icalBuddy path and look-ahead hours.
39 * @param {string} icalBuddyPath - Optional custom path to icalBuddy binary
40 * @param {number} lookAheadHours - Hours to look ahead for meetings (default: 12)
41 * @returns {string} Complete shell command
42 */
43function buildCommand(icalBuddyPath, lookAheadHours) {
44 const hours = lookAheadHours || 12;
45 if (icalBuddyPath && icalBuddyPath.trim()) {
46 // Escape special shell characters to prevent injection
47 const safePath = icalBuddyPath.replace(/["$`\\]/g, "\\$&");
48 return `${BASE_COMMAND} "${safePath}" ${hours}`;
49 }
50 return `${BASE_COMMAND} "" ${hours}`;
51}
52
53// Patterns to extract meeting URLs from notes
54const MEETING_URL_PATTERNS = [
55 /https?:\/\/[\w-]*\.?zoom\.us\/[^\s<>"]+/gi,
56 /https?:\/\/teams\.microsoft\.com\/l\/meetup-join\/[^\s<>"]+/gi,
57 /https?:\/\/meet\.google\.com\/[^\s<>"]+/gi,
58 /https?:\/\/[\w-]*\.?webex\.com\/[^\s<>"]+/gi,
59];
60
61// SafeLinks pattern (Microsoft Outlook protection wrapper)
62const SAFELINKS_PATTERN =
63 /https?:\/\/[\w.-]*safelinks\.protection\.outlook\.com\/[^\s<>">\]]+/gi;
64
65/**
66 * Decode a URL that may be URL-encoded
67 * @param {string} url - Possibly encoded URL
68 * @returns {string} Decoded URL
69 */
70function decodeUrl(url) {
71 try {
72 return decodeURIComponent(url);
73 } catch {
74 return url;
75 }
76}
77
78/**
79 * Extract the real URL from a SafeLink wrapper
80 * @param {string} safeLink - SafeLink URL
81 * @returns {string} Extracted real URL or original
82 */
83function extractFromSafeLink(safeLink) {
84 const urlParam = safeLink.match(/[?&]url=([^&]+)/i);
85 if (urlParam && urlParam[1]) {
86 return decodeUrl(urlParam[1]);
87 }
88 return safeLink;
89}
90
91/**
92 * Extract meeting URL from event URL or notes
93 * @param {string} url - Event URL
94 * @param {string} notes - Event notes/description
95 * @returns {string|null} Meeting URL or null
96 */
97function extractMeetingUrl(url, notes) {
98 // First check direct URL
99 if (url && url.trim() && url !== "missing value") {
100 for (const pattern of MEETING_URL_PATTERNS) {
101 pattern.lastIndex = 0;
102 const match = url.match(pattern);
103 if (match) return match[0];
104 }
105 if (url.startsWith("http")) return url;
106 }
107
108 // Check notes for meeting links
109 if (notes && notes.trim()) {
110 // First try direct meeting URLs
111 for (const pattern of MEETING_URL_PATTERNS) {
112 pattern.lastIndex = 0;
113 const match = notes.match(pattern);
114 if (match) return match[0];
115 }
116
117 // Check for SafeLinks that contain meeting URLs
118 SAFELINKS_PATTERN.lastIndex = 0;
119 const safeLinks = notes.match(SAFELINKS_PATTERN);
120 if (safeLinks) {
121 for (const safeLink of safeLinks) {
122 const realUrl = extractFromSafeLink(safeLink);
123 for (const pattern of MEETING_URL_PATTERNS) {
124 pattern.lastIndex = 0;
125 if (pattern.test(realUrl)) {
126 return realUrl;
127 }
128 }
129 }
130 }
131 }
132
133 return null;
134}
135
136/**
137 * Format time until meeting
138 * @param {number} minutes - Minutes until meeting
139 * @returns {string} Formatted time string
140 */
141function formatTimeUntil(minutes) {
142 if (minutes < 0) return "now";
143 if (minutes < 1) return "<1m";
144 if (minutes < 60) return `${minutes}m`;
145 const hours = Math.floor(minutes / 60);
146 const mins = minutes % 60;
147 if (mins === 0) return `${hours}h`;
148 return `${hours}h ${mins}m`;
149}
150
151/**
152 * Parse meeting data from shell script output
153 * @param {string} output - Raw command output
154 * @returns {Object|null} Meeting object or null
155 */
156function parseMeetingData(output) {
157 if (!output || !output.trim()) return null;
158
159 const parts = output.trim().split("|");
160 if (parts.length < 5) return null;
161
162 const [title, startTime, url, notes, minutesUntil] = parts;
163 if (!title) return null;
164
165 const meetingUrl = extractMeetingUrl(url, notes);
166
167 return {
168 title: title.trim(),
169 startTime: startTime,
170 url: meetingUrl,
171 minutesUntil: parseInt(minutesUntil, 10) || 0,
172 };
173}
174
175/**
176 * Join button sub-component for meeting URLs.
177 * @component
178 * @param {Object} props - Component props
179 * @param {string} props.url - Meeting URL to open
180 * @returns {JSX.Element} Join button element
181 */
182const JoinButton = React.memo(({ url }) => {
183 /**
184 * Opens the meeting URL when clicked.
185 * @param {React.MouseEvent} e - Click event
186 */
187 const handleJoin = async (e) => {
188 e.stopPropagation();
189 Utils.clickEffect(e);
190 // Escape special shell characters to prevent injection
191 const safeUrl = url.replace(/["$`\\]/g, "\\$&");
192 await Uebersicht.run(`open "${safeUrl}"`);
193 };
194
195 return (
196 <button
197 className="next-meeting__join"
198 onClick={handleJoin}
199 title="Join meeting"
200 aria-label="Join video meeting"
201 >
202 Join
203 </button>
204 );
205});
206
207JoinButton.displayName = "JoinButton";
208
209/**
210 * Next Meeting widget component
211 * Shows the next upcoming meeting with optional join button
212 * @returns {JSX.Element|null} The next meeting widget
213 */
214export const Widget = React.memo(() => {
215 const { displayIndex, settings } = useSimpleBarContext();
216 const { widgets, nextMeetingWidgetOptions, dateWidgetOptions } = settings;
217 const { nextMeetingWidget } = widgets;
218 const {
219 refreshFrequency,
220 showOnDisplay,
221 showJoinButton,
222 showTimeOnly,
223 icalBuddyPath,
224 lookAheadHours,
225 } = nextMeetingWidgetOptions;
226
227 // Calculate the refresh frequency for the widget
228 const refresh = React.useMemo(
229 () =>
230 Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY),
231 [refreshFrequency],
232 );
233
234 // Determine if the widget should be visible based on display settings
235 const visible =
236 Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && nextMeetingWidget;
237
238 const [state, setState] = React.useState(null);
239 const [loading, setLoading] = React.useState(visible);
240
241 /**
242 * Resets the widget state.
243 */
244 const resetWidget = React.useCallback(() => {
245 setState(null);
246 setLoading(false);
247 }, []);
248
249 /**
250 * Fetches next meeting and updates the state.
251 */
252 const getMeeting = React.useCallback(async () => {
253 if (!visible) return;
254 try {
255 const command = buildCommand(icalBuddyPath, lookAheadHours);
256 const output = await Uebersicht.run(command);
257 const meeting = parseMeetingData(output);
258 setState(meeting);
259 } catch (error) {
260 // eslint-disable-next-line no-console
261 console.error("Error fetching next meeting:", error);
262 setState(null);
263 }
264 setLoading(false);
265 }, [visible, icalBuddyPath, lookAheadHours]);
266
267 // Server socket for real-time updates
268 useServerSocket("next-meeting", visible, getMeeting, resetWidget, setLoading);
269
270 // Refresh the widget at the specified interval
271 useWidgetRefresh(visible, getMeeting, refresh);
272
273 if (loading) return <DataWidgetLoader.Widget className="next-meeting" />;
274 if (!state) return null;
275
276 const { title, minutesUntil, url } = state;
277 const timeDisplay = formatTimeUntil(minutesUntil);
278 const isUrgent = minutesUntil <= URGENT_THRESHOLD; // Red pulsing: ≤5 min or in progress (up to 15 min)
279 const isUpcoming = minutesUntil <= UPCOMING_THRESHOLD; // Yellow: ≤15 min (but not urgent/in-progress)
280
281 const widgetClasses = Utils.classNames("next-meeting", {
282 "next-meeting--urgent": isUrgent,
283 "next-meeting--upcoming": isUpcoming && !isUrgent,
284 });
285
286 /**
287 * Handle click event to open the calendar application
288 * @param {Event} e - The click event
289 */
290 const openCalendar = async (e) => {
291 Utils.clickEffect(e);
292 const calendarApp = dateWidgetOptions?.calendarApp || "Calendar";
293 await Uebersicht.run(`open -a "${calendarApp}"`);
294 };
295
296 return (
297 <DataWidget.Widget
298 classes={widgetClasses}
299 Icon={Icons.Calendar}
300 onClick={openCalendar}
301 useDivForClick
302 >
303 {!showTimeOnly && <span className="next-meeting__title">{title}</span>}
304 <span className="next-meeting__time">{timeDisplay}</span>
305 {showJoinButton && url && <JoinButton url={url} />}
306 </DataWidget.Widget>
307 );
308});
309
310Widget.displayName = "NextMeeting";