users/ryan/modules/simple-bar/simple-bar-source/lib/components/data/next-meeting.jsx

d06784958d73159b5e4abafe5114804662114ed7 · 9.2 KB · 310 lines raw

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 */
14 import * as Uebersicht from "uebersicht";
15 import * as DataWidget from "./data-widget.jsx";
16 import * as DataWidgetLoader from "./data-widget-loader.jsx";
17 import * as Icons from "../icons/icons.jsx";
18 import useWidgetRefresh from "../../hooks/use-widget-refresh";
19 import useServerSocket from "../../hooks/use-server-socket";
20 import { useSimpleBarContext } from "../simple-bar-context.jsx";
21 import * as Utils from "../../utils";
22
23 export { nextMeetingStyles as styles } from "../../styles/components/data/next-meeting";
24
25 const { React } = Uebersicht;
26
27 const DEFAULT_REFRESH_FREQUENCY = 60000; // 1 minute
28
29 // Timing thresholds (in minutes)
30 const URGENT_THRESHOLD = 5; // Red pulsing when ≤5 min until meeting
31 const 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
35 const 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 */
43 function 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
54 const 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)
62 const 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 */
70 function 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 */
83 function 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 */
97 function 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 */
141 function 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 */
156 function 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 */
182 const 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
207 JoinButton.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 */
214 export 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
310 Widget.displayName = "NextMeeting";