From 0a09a882a4be9a419815674195d5047c7b9aa091 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Mon, 9 Mar 2026 09:54:53 -0400 Subject: [PATCH] chore: update jsdocs Signed-off-by: Adam Setch --- src/preload/index.ts | 121 ++++++++++ src/preload/types.ts | 5 + src/preload/utils.ts | 21 +- .../hooks/timers/useInactivityTimer.ts | 5 +- src/renderer/hooks/timers/useIntervalTimer.ts | 11 +- src/renderer/hooks/useNotifications.ts | 8 + src/renderer/utils/api/request.ts | 21 +- src/renderer/utils/auth/utils.ts | 217 +++++++++++++++++- .../utils/notifications/filters/filter.ts | 38 +++ .../utils/notifications/formatters.ts | 34 +++ src/renderer/utils/notifications/group.ts | 15 +- .../utils/notifications/handlers/utils.ts | 10 +- .../utils/notifications/notifications.ts | 12 +- src/renderer/utils/notifications/remove.ts | 6 + src/renderer/utils/system/audio.ts | 18 +- src/renderer/utils/system/comms.ts | 54 +++++ src/renderer/utils/ui/zoom.ts | 28 ++- 17 files changed, 581 insertions(+), 43 deletions(-) diff --git a/src/preload/index.ts b/src/preload/index.ts index 001d515c1..695d4ff8b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,7 +6,19 @@ import { isLinux, isMacOS, isWindows } from '../shared/platform'; import { invokeMainEvent, onRendererEvent, sendMainEvent } from './utils'; +/** + * The Gitify Bridge API exposed to the renderer via `contextBridge`. + * + * All renderer↔main IPC communication must go through this object. + * It is available on `window.gitify` inside the renderer process. + */ export const api = { + /** + * Open a URL in the user's default browser. + * + * @param url - The URL to open. + * @param openInForeground - When `true`, brings the browser to the foreground. + */ openExternalLink: (url: string, openInForeground: boolean) => { sendMainEvent(EVENTS.OPEN_EXTERNAL, { url: url, @@ -14,18 +26,40 @@ export const api = { }); }, + /** + * Encrypt a plaintext string using Electron's safe storage. + * + * @param value - The plaintext string to encrypt. + * @returns A promise resolving to the encrypted string. + */ encryptValue: (value: string) => invokeMainEvent(EVENTS.SAFE_STORAGE_ENCRYPT, value), + /** + * Decrypt an encrypted string using Electron's safe storage. + * + * @param value - The encrypted string to decrypt. + * @returns A promise resolving to the plaintext string. + */ decryptValue: (value: string) => invokeMainEvent(EVENTS.SAFE_STORAGE_DECRYPT, value), + /** + * Enable or disable launching the application at system login. + * + * @param value - `true` to enable auto-launch, `false` to disable. + */ setAutoLaunch: (value: boolean) => sendMainEvent(EVENTS.UPDATE_AUTO_LAUNCH, { openAtLogin: value, openAsHidden: value, }), + /** + * Register or unregister the global keyboard shortcut for toggling the app window. + * + * @param keyboardShortcut - `true` to register the shortcut, `false` to unregister. + */ setKeyboardShortcut: (keyboardShortcut: boolean) => { sendMainEvent(EVENTS.UPDATE_KEYBOARD_SHORTCUT, { enabled: keyboardShortcut, @@ -33,39 +67,89 @@ export const api = { }); }, + /** Tray icon controls. */ tray: { + /** + * Update the tray icon color based on unread notification count. + * + * Pass a negative number to set the error state color. + * + * @param notificationsCount - Number of unread notifications. + */ updateColor: (notificationsCount = 0) => { sendMainEvent(EVENTS.UPDATE_ICON_COLOR, notificationsCount); }, + /** + * Update the tray icon title (the text shown next to the icon). + * + * @param title - The title string to display. Pass an empty string to clear it. + */ updateTitle: (title = '') => sendMainEvent(EVENTS.UPDATE_ICON_TITLE, title), + /** + * Switch the tray icon to an alternate idle icon variant. + * + * @param value - `true` to use the alternate idle icon, `false` for the default. + */ useAlternateIdleIcon: (value: boolean) => sendMainEvent(EVENTS.USE_ALTERNATE_IDLE_ICON, value), + /** + * Switch the tray icon to an "active" variant when there are unread notifications. + * + * @param value - `true` to use the unread-active icon, `false` for the default. + */ useUnreadActiveIcon: (value: boolean) => sendMainEvent(EVENTS.USE_UNREAD_ACTIVE_ICON, value), }, + /** + * Resolve the absolute file path of the notification sound asset. + * + * @returns A promise resolving to the sound file path. + */ notificationSoundPath: () => invokeMainEvent(EVENTS.NOTIFICATION_SOUND_PATH), + /** + * Resolve the absolute directory path of the bundled Twemoji assets. + * + * @returns A promise resolving to the Twemoji directory path. + */ twemojiDirectory: () => invokeMainEvent(EVENTS.TWEMOJI_DIRECTORY), + /** Platform detection helpers. */ + /** Platform detection helpers. */ platform: { + /** Returns `true` when running on Linux. */ isLinux: () => isLinux(), + /** Returns `true` when running on macOS. */ isMacOS: () => isMacOS(), + /** Returns `true` when running on Windows. */ isWindows: () => isWindows(), }, + /** Application window and lifecycle controls. */ app: { + /** Hide the application window. */ hide: () => sendMainEvent(EVENTS.WINDOW_HIDE), + /** Show and focus the application window. */ show: () => sendMainEvent(EVENTS.WINDOW_SHOW), + /** Quit the application. */ quit: () => sendMainEvent(EVENTS.QUIT), + /** + * Return the application version string. + * + * Returns `"dev"` in development mode; otherwise returns `"v"` + * retrieved from the main process. + * + * @returns A promise resolving to the version string. + */ version: async () => { if (process.env.NODE_ENV === 'development') { return 'dev'; @@ -77,24 +161,61 @@ export const api = { }, }, + /** Electron web frame zoom controls. */ zoom: { + /** + * Return the current Electron zoom level. + * + * @returns The current zoom level (0 = 100%). + */ getLevel: () => webFrame.getZoomLevel(), + /** + * Set the Electron zoom level. + * + * @param zoomLevel - The zoom level to apply (0 = 100%). + */ setLevel: (zoomLevel: number) => webFrame.setZoomLevel(zoomLevel), }, + /** + * Register a callback invoked when the main process requests an app reset. + * + * @param callback - Called when the reset event is received. + */ onResetApp: (callback: () => void) => { onRendererEvent(EVENTS.RESET_APP, () => callback()); }, + /** + * Register a callback invoked when the main process delivers an OAuth callback URL. + * + * @param callback - Called with the full callback URL (e.g. `gitify://oauth?code=...`). + */ onAuthCallback: (callback: (url: string) => void) => { onRendererEvent(EVENTS.AUTH_CALLBACK, (_, url) => callback(url)); }, + /** + * Register a callback invoked when the OS system theme changes. + * + * @param callback - Called with the new theme string (`"light"` or `"dark"`). + */ onSystemThemeUpdate: (callback: (theme: string) => void) => { onRendererEvent(EVENTS.UPDATE_THEME, (_, theme) => callback(theme)); }, + /** + * Display a native OS notification. + * + * Clicking the notification opens `url` in the browser (hiding the app window), + * or shows the app window if no URL is provided. + * + * @param title - The notification title. + * @param body - The notification body text. + * @param url - Optional URL to open when the notification is clicked. + * @returns The created `Notification` instance. + */ raiseNativeNotification: (title: string, body: string, url?: string) => { const notification = new Notification(title, { body: body, silent: true }); notification.onclick = () => { diff --git a/src/preload/types.ts b/src/preload/types.ts index 9b3508825..2dcdad2ed 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -1,3 +1,8 @@ import type { api } from '.'; +/** + * The type of the Gitify Bridge API exposed to the renderer via `contextBridge`. + * + * Mirrors the shape of `window.gitify` as declared in `preload.d.ts`. + */ export type GitifyAPI = typeof api; diff --git a/src/preload/utils.ts b/src/preload/utils.ts index dcf6c681c..223e9bb1b 100644 --- a/src/preload/utils.ts +++ b/src/preload/utils.ts @@ -3,19 +3,21 @@ import { ipcRenderer } from 'electron'; import type { EventData, EventType } from '../shared/events'; /** - * Send renderer event without expecting a response - * @param event the type of event to send - * @param data the data to send with the event + * Send a fire-and-forget IPC message from the renderer to the main process. + * + * @param event - The IPC event type to send. + * @param data - Optional payload to include with the event. */ export function sendMainEvent(event: EventType, data?: EventData): void { ipcRenderer.send(event, data); } /** - * Send renderer event and expect a response - * @param event the type of event to send - * @param data the data to send with the event - * @returns + * Send an IPC message from the renderer to the main process and await a response. + * + * @param event - The IPC event type to invoke. + * @param data - Optional string payload to include with the event. + * @returns A promise that resolves to the string response from the main process. */ export function invokeMainEvent( event: EventType, @@ -25,7 +27,10 @@ export function invokeMainEvent( } /** - * Handle renderer event without expecting a response + * Register a listener for an IPC event sent from the main process to the renderer. + * + * @param event - The IPC event type to listen for. + * @param listener - The callback invoked when the event is received. */ export function onRendererEvent( event: EventType, diff --git a/src/renderer/hooks/timers/useInactivityTimer.ts b/src/renderer/hooks/timers/useInactivityTimer.ts index 1157ef3f7..161a1f4dd 100644 --- a/src/renderer/hooks/timers/useInactivityTimer.ts +++ b/src/renderer/hooks/timers/useInactivityTimer.ts @@ -6,7 +6,10 @@ const events = ['mousedown', 'keypress', 'click']; /** * Hook that triggers a callback after a specified period of user inactivity. - * User activity as defined in `events` will reset the timer. + * User activity (mousedown, keypress, click) resets the timer. + * + * @param callback - The function to call once inactivity exceeds `delay`. + * @param delay - Inactivity timeout in milliseconds. */ export const useInactivityTimer = (callback: () => void, delay: number) => { const savedCallback = useRef<(() => void) | null>(null); diff --git a/src/renderer/hooks/timers/useIntervalTimer.ts b/src/renderer/hooks/timers/useIntervalTimer.ts index 0946996a1..1e98a5ee0 100644 --- a/src/renderer/hooks/timers/useIntervalTimer.ts +++ b/src/renderer/hooks/timers/useIntervalTimer.ts @@ -1,11 +1,12 @@ import { useEffect, useRef } from 'react'; -import { isOnline } from '../../utils/system/network'; - /** - * Hook that triggers a callback after a specified period of time. + * Hook that triggers a callback on a recurring interval. * * Thanks to https://overreacted.io/making-setinterval-declarative-with-react-hooks/ + * + * @param callback - Function to call on each interval tick. Always uses the latest reference. + * @param delay - Interval duration in milliseconds. Pass `null` to disable. */ export const useIntervalTimer = (callback: () => void, delay: number) => { const savedCallback = useRef<(() => void) | null>(null); @@ -18,9 +19,7 @@ export const useIntervalTimer = (callback: () => void, delay: number) => { // Set up the interval. useEffect(() => { function tick() { - if (savedCallback.current && isOnline()) { - savedCallback.current(); - } + savedCallback.current(); } if (delay !== null) { diff --git a/src/renderer/hooks/useNotifications.ts b/src/renderer/hooks/useNotifications.ts index d61955987..aff00b272 100644 --- a/src/renderer/hooks/useNotifications.ts +++ b/src/renderer/hooks/useNotifications.ts @@ -58,6 +58,14 @@ interface NotificationsState { ) => Promise; } +/** + * Hook that manages all notification state and actions for the application. + * + * Handles fetching, filtering, sound/native notification triggering, + * mark-as-read, mark-as-done, and unsubscribe operations across all accounts. + * + * @returns The current notifications state and action callbacks. + */ export const useNotifications = (): NotificationsState => { const [status, setStatus] = useState('success'); const [globalError, setGlobalError] = useState(); diff --git a/src/renderer/utils/api/request.ts b/src/renderer/utils/api/request.ts index a15d75674..1e511f94f 100644 --- a/src/renderer/utils/api/request.ts +++ b/src/renderer/utils/api/request.ts @@ -7,12 +7,12 @@ import type { TypedDocumentString } from './graphql/generated/graphql'; import { createOctokitClient } from './octokit'; /** - * Perform a GraphQL API request with typed operation document + * Perform a GraphQL API request with typed operation document. * - * @param url The API url - * @param query The GraphQL operation/query statement - * @param variables The GraphQL operation variables - * @returns Resolves to a GitHub GraphQL response + * @param account - The authenticated account to make the request with. + * @param query - The typed GraphQL operation document. + * @param variables - The GraphQL operation variables. + * @returns Resolves to a typed GitHub GraphQL response. */ export async function performGraphQLRequest( account: Account, @@ -35,13 +35,12 @@ export async function performGraphQLRequest( /** * Perform a GraphQL API request using a raw query string instead of a TypedDocumentString. * - * Useful for dynamically composed queries (e.g: merged queries built at runtime). + * Useful for dynamically composed queries (e.g. merged queries built at runtime). * - * @param url The API url - * @param token The GitHub token (decrypted) - * @param query The GraphQL operation/query statement - * @param variables The GraphQL operation variables - * @returns Resolves to a GitHub GraphQL response + * @param account - The authenticated account to make the request with. + * @param query - The raw GraphQL operation/query string. + * @param variables - The GraphQL operation variables. + * @returns Resolves to a typed GitHub GraphQL response. */ export async function performGraphQLRequestString( account: Account, diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index e2c9447b9..12cef6ef8 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -42,6 +42,15 @@ import { import { encryptValue, openExternalLink } from '../system/comms'; import { getPlatformFromHostname } from './platform'; +/** + * Initiate a GitHub OAuth Web Flow (OAuth App) authentication. + * + * Opens the GitHub authorization URL in the user's browser, then waits for the + * app's custom `gitify://oauth` callback to receive the authorization code. + * + * @param authOptions - The OAuth App client configuration and target hostname. + * @returns Resolves with the authorization code and options on success. + */ export function performGitHubWebOAuth( authOptions: LoginOAuthWebOptions, ): Promise { @@ -92,6 +101,15 @@ export function performGitHubWebOAuth( }); } +/** + * Start a GitHub Device Flow authorization session. + * + * Requests a device code from GitHub and returns the session state + * (user code, verification URI, expiry) needed to complete the flow. + * + * @param hostname - The GitHub hostname to authenticate against. Defaults to github.com. + * @returns The device flow session data. + */ export async function startGitHubDeviceFlow( hostname: Hostname = Constants.GITHUB_HOSTNAME, ): Promise { @@ -115,6 +133,16 @@ export async function startGitHubDeviceFlow( } as DeviceFlowSession; } +/** + * Poll GitHub to exchange a device code for an access token. + * + * Returns `null` when authorization is still pending ("authorization_pending" + * or "slow_down" error codes), allowing the caller to retry later. + * Throws for any other error. + * + * @param session - The active device flow session. + * @returns The access token when granted, or `null` when still pending. + */ export async function pollGitHubDeviceFlow( session: DeviceFlowSession, ): Promise { @@ -149,6 +177,15 @@ export async function pollGitHubDeviceFlow( } } +/** + * Orchestrate a complete GitHub Device OAuth flow. + * + * Starts a device flow session, then polls at the session-specified interval + * until the user approves the request or the device code expires. + * + * @returns The access token on successful authorization. + * @throws If the device code expires before the user approves. + */ export async function performGitHubDeviceOAuth(): Promise { const session = await startGitHubDeviceFlow(); @@ -167,6 +204,17 @@ export async function performGitHubDeviceOAuth(): Promise { throw new Error('Device code expired before authorization completed'); } +/** + * Exchange an OAuth authorization code for an access token. + * + * `authOptions.clientSecret` is required; this step must be performed + * server-side or in a trusted context to keep the secret confidential. + * + * @param authCode - The authorization code received from the OAuth callback. + * @param authOptions - The OAuth App options, including the client secret. + * @returns The access token. + * @throws If `clientSecret` is absent. + */ export async function exchangeAuthCodeForAccessToken( authCode: AuthCode, authOptions: LoginOAuthWebOptions, @@ -190,6 +238,19 @@ export async function exchangeAuthCodeForAccessToken( return authentication.token as Token; } +/** + * Add or update an account in the auth state. + * + * Encrypts the token, refreshes the user profile, then upserts the account: + * if an account with the same UUID (hostname + user ID + method) already + * exists it is replaced (e.g. on re-authentication); otherwise it is appended. + * + * @param auth - The current auth state. + * @param method - The authentication method used. + * @param token - The plaintext access token to store (will be encrypted). + * @param hostname - The GitHub hostname for the account. + * @returns The updated auth state. + */ export async function addAccount( auth: AuthState, method: AuthMethod, @@ -232,6 +293,13 @@ export async function addAccount( }; } +/** + * Remove an account from the auth state. + * + * @param auth - The current auth state. + * @param account - The account to remove. + * @returns A new auth state with the account removed. + */ export function removeAccount(auth: AuthState, account: Account): AuthState { const updatedAccounts = auth.accounts.filter( (a) => a.token !== account.token, @@ -242,6 +310,16 @@ export function removeAccount(auth: AuthState, account: Account): AuthState { }; } +/** + * Refresh an account's user profile, version, and OAuth scopes from the API. + * + * Mutates the `account` object in-place and returns it. + * Re-throws any error encountered so callers can handle auth failures. + * + * @param account - The account to refresh. + * @returns The same account object with updated user, version, and scopes. + * @throws If the API call fails. + */ export async function refreshAccount(account: Account): Promise { try { const response = await fetchAuthenticatedUserDetails(account); @@ -286,6 +364,15 @@ export async function refreshAccount(account: Account): Promise { return account; } +/** + * Normalize a GitHub Enterprise Server version string to a semver string. + * + * Returns `"latest"` when `version` is null/empty (GitHub Cloud or unknown), + * which is treated as "supports all features" by feature-flag checks. + * + * @param version - The raw version string from the `x-github-enterprise-version` header. + * @returns A normalized semver string, or `"latest"` if unset. + */ export function extractHostVersion(version: string | null): string { if (version) { return semver.valid(semver.coerce(version)); @@ -294,6 +381,17 @@ export function extractHostVersion(version: string | null): string { return 'latest'; } +/** + * Build the GitHub authentication base URL for the given hostname. + * + * The URL structure differs by platform: + * - GitHub.com → `https://github.com/` + * - GitHub Enterprise Server → `https:///api/v3/` + * - GitHub Enterprise Cloud with Data Residency → `https://api./` + * + * @param hostname - The GitHub hostname. + * @returns The base URL to use for OAuth API requests. + */ export function getGitHubAuthBaseUrl(hostname: Hostname): URL { const platform = getPlatformFromHostname(hostname); const url = new URL(APPLICATION.GITHUB_BASE_URL); @@ -315,6 +413,16 @@ export function getGitHubAuthBaseUrl(hostname: Hostname): URL { return url; } +/** + * Return the GitHub developer settings URL appropriate for the account's auth method. + * + * - GitHub App → application connections page + * - OAuth App → developer settings page + * - Personal Access Token → tokens settings page + * + * @param account - The account whose settings URL to build. + * @returns The URL to the relevant GitHub developer settings page. + */ export function getDeveloperSettingsURL(account: Account): Link { const settingsURL = new URL(`https://${account.hostname}`); @@ -335,6 +443,15 @@ export function getDeveloperSettingsURL(account: Account): Link { return settingsURL.toString() as Link; } +/** + * Build a pre-filled URL for creating a new Personal Access Token. + * + * Pre-populates the token description (with app name and current date), + * the required OAuth scopes, and a 90-day expiry. + * + * @param hostname - The GitHub hostname to create the token on. + * @returns The URL with pre-filled query parameters. + */ export function getNewTokenURL(hostname: Hostname): Link { const date = format(new Date(), 'PP p'); const newTokenURL = new URL(`https://${hostname}/settings/tokens/new`); @@ -351,6 +468,15 @@ export function getNewTokenURL(hostname: Hostname): Link { return newTokenURL.toString() as Link; } +/** + * Build a pre-filled URL for registering a new OAuth App. + * + * Pre-populates the app name (with creation date), homepage URL, and + * the `gitify://oauth` callback URL. + * + * @param hostname - The GitHub hostname to register the app on. + * @returns The URL with pre-filled query parameters. + */ export function getNewOAuthAppURL(hostname: Hostname): Link { const date = format(new Date(), 'PP p'); const newOAuthAppURL = new URL( @@ -372,18 +498,48 @@ export function getNewOAuthAppURL(hostname: Hostname): Link { return newOAuthAppURL.toString() as Link; } +/** + * Return true if `hostname` is a valid DNS hostname (e.g. `github.com`). + * Accepts hostnames with 2-6 character TLDs; rejects IP addresses and bare labels. + * + * @param hostname - The hostname string to validate. + * @returns `true` if valid. + */ export function isValidHostname(hostname: Hostname) { return /^([A-Z0-9]([A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}$/i.test(hostname); } +/** + * Return true if `clientId` matches the expected GitHub OAuth App format + * (20 alphanumeric/underscore characters). + * + * @param clientId - The client ID string to validate. + * @returns `true` if valid. + */ export function isValidClientId(clientId: ClientID) { return /^[A-Z0-9_]{20}$/i.test(clientId); } +/** + * Return true if `token` matches the expected Personal Access Token format + * (40 alphanumeric/underscore characters). + * + * @param token - The token string to validate. + * @returns `true` if valid. + */ export function isValidToken(token: Token) { return /^[A-Z0-9_]{40}$/i.test(token); } +/** + * Derive a stable, unique identifier for an account. + * + * Encodes `"--"` as a base-64 string so the UUID + * is safe for use as a cache key or map key. + * + * @param account - The account to identify. + * @returns A base-64 encoded UUID string. + */ export function getAccountUUID(account: Account): AccountUUID { return btoa( `${account.hostname}-${account.user.id}-${account.method}`, @@ -391,58 +547,117 @@ export function getAccountUUID(account: Account): AccountUUID { } /** - * Return the primary (first) account hostname + * Return the primary (first) account hostname, or the default GitHub.com hostname + * if no accounts are present. + * + * @param auth - The current auth state. + * @returns The hostname of the first account. */ export function getPrimaryAccountHostname(auth: AuthState) { return auth.accounts[0]?.hostname ?? Constants.GITHUB_HOSTNAME; } +/** + * Return true if at least one account is authenticated. + * + * @param auth - The current auth state. + */ export function hasAccounts(auth: AuthState) { return auth.accounts.length > 0; } +/** + * Return true if more than one account is authenticated. + * + * @param auth - The current auth state. + */ export function hasMultipleAccounts(auth: AuthState) { return auth.accounts.length > 1; } +/** + * Return true if the account has all required OAuth scopes. + * + * @param account - The account whose scopes to check. + */ export function hasRequiredScopes(account: Account): boolean { return Constants.OAUTH_SCOPES.REQUIRED.every(({ name }) => (account.scopes ?? []).includes(name), ); } +/** + * Return true if the account has all recommended OAuth scopes. + * + * @param account - The account whose scopes to check. + */ export function hasRecommendedScopes(account: Account): boolean { return Constants.OAUTH_SCOPES.RECOMMENDED.every(({ name }) => (account.scopes ?? []).includes(name), ); } +/** + * Return true if the account has all alternate OAuth scopes. + * + * @param account - The account whose scopes to check. + */ export function hasAlternateScopes(account: Account): boolean { return Constants.OAUTH_SCOPES.ALTERNATE.every(({ name }) => (account.scopes ?? []).includes(name), ); } +/** + * Return the list of required OAuth scope names. + * + * @returns Array of required scope name strings. + */ export function getRequiredScopeNames(): string[] { return Constants.OAUTH_SCOPES.REQUIRED.map(({ name }) => name); } +/** + * Return the list of recommended OAuth scope names. + * + * @returns Array of recommended scope name strings. + */ export function getRecommendedScopeNames(): string[] { return Constants.OAUTH_SCOPES.RECOMMENDED.map(({ name }) => name); } +/** + * Return the list of alternate OAuth scope names. + * + * @returns Array of alternate scope name strings. + */ export function getAlternateScopeNames(): string[] { return Constants.OAUTH_SCOPES.ALTERNATE.map(({ name }) => name); } +/** + * Return the required OAuth scopes as a comma-separated string. + * + * @returns Comma-separated required scope names. + */ export function formatRequiredOAuthScopes(): string { return getRequiredScopeNames().join(', '); } +/** + * Return the recommended OAuth scopes as a comma-separated string. + * + * @returns Comma-separated recommended scope names. + */ export function formatRecommendedOAuthScopes(): string { return getRecommendedScopeNames().join(', '); } +/** + * Return the alternate OAuth scopes as a comma-separated string. + * + * @returns Comma-separated alternate scope names. + */ export function formatAlternateOAuthScopes(): string { return getAlternateScopeNames().join(', '); } diff --git a/src/renderer/utils/notifications/filters/filter.ts b/src/renderer/utils/notifications/filters/filter.ts index 45ae1a1c3..aaeca8f82 100644 --- a/src/renderer/utils/notifications/filters/filter.ts +++ b/src/renderer/utils/notifications/filters/filter.ts @@ -19,6 +19,16 @@ import { userTypeFilter, } from '.'; +/** + * Apply base (pre-enrichment) filters to a list of notifications. + * + * Filters by subject type, reason, and base search-token qualifiers + * (org, repo, etc.). Does NOT apply state or author filters, which + * require enriched data and are handled by `filterDetailedNotifications`. + * + * @param notifications - The raw/transformed notifications to filter. + * @returns The subset of notifications that pass all base filters. + */ export function filterBaseNotifications( notifications: GitifyNotification[], ): GitifyNotification[] { @@ -58,6 +68,16 @@ export function filterBaseNotifications( }); } +/** + * Apply detailed (post-enrichment) filters to a list of notifications. + * + * Only runs when `settings.detailedNotifications` is enabled. Applies + * user-type and state filters that depend on enriched subject data. + * + * @param notifications - The enriched notifications to filter. + * @param settings - Application settings controlling whether detailed filtering runs. + * @returns The subset of notifications that pass all detailed filters. + */ export function filterDetailedNotifications( notifications: GitifyNotification[], settings: SettingsState, @@ -155,12 +175,30 @@ function passesStateFilter(notification: GitifyNotification): boolean { return true; } +/** + * Return true if a notification with the given state would be filtered out + * by the current state filter settings. + * + * Convenience helper used by UI components to indicate filtered-out states. + * + * @param state - The notification state to check. + * @returns `true` if the state is currently filtered out. + */ export function isStateFilteredOut(state: GitifyNotificationState): boolean { const notification = { subject: { state: state } } as GitifyNotification; return !passesStateFilter(notification); } +/** + * Return true if a notification with the given user would be filtered out + * by the current user-type filter settings. + * + * Convenience helper used by UI components to indicate filtered-out users. + * + * @param user - The notification user to check. + * @returns `true` if the user is currently filtered out. + */ export function isUserFilteredOut(user: GitifyNotificationUser): boolean { const notification = { subject: { user: user } } as GitifyNotification; diff --git a/src/renderer/utils/notifications/formatters.ts b/src/renderer/utils/notifications/formatters.ts index 30ee16894..0b6ba4eda 100644 --- a/src/renderer/utils/notifications/formatters.ts +++ b/src/renderer/utils/notifications/formatters.ts @@ -30,6 +30,17 @@ export function formatNotification( }; } +/** + * Normalise an array of text segments into a single display-ready string. + * + * Joins the segments with spaces, inserts a space between a lowercase + * character followed by an uppercase one (camelCase splitting), replaces + * underscores with spaces, trims whitespace, and applies proper-case + * capitalisation. + * + * @param text - The array of raw text segments to format. + * @returns The formatted, human-readable string. + */ export function formatForDisplay(text: string[]): string { if (!text) { return ''; @@ -46,6 +57,9 @@ export function formatForDisplay(text: string[]): string { /** * Formats a string to proper case (capitalize the first letter of each word). + * + * @param text - The string to format. + * @returns The proper-cased string. */ export function formatProperCase(text: string) { if (!text) { @@ -60,6 +74,9 @@ export function formatProperCase(text: string) { /** * Return the formatted notification type for this notification. + * + * @param notification - The notification whose type to format. + * @returns A human-readable type string (e.g. "Open Pull Request"). */ export function formatNotificationType( notification: GitifyNotification, @@ -72,6 +89,9 @@ export function formatNotificationType( /** * Return the formatted (issue, pull request, discussion) number for this notification. + * + * @param num - The numeric issue/PR/discussion number. + * @returns The number prefixed with `#` (e.g. `"#42"`). */ export function formatGitHubNumber(num: number): string { return `#${num}`; @@ -79,6 +99,9 @@ export function formatGitHubNumber(num: number): string { /** * Return the formatted notification number for this notification. + * + * @param notification - The notification whose subject number to format. + * @returns A `"#N"` string when a subject number is present, otherwise `""`. */ export function formatNotificationNumber( notification: GitifyNotification, @@ -90,6 +113,12 @@ export function formatNotificationNumber( /** * Return the formatted notification title for this notification. + * + * Appends the subject number in brackets when present + * (e.g. `"Fix bug [#42]"`). + * + * @param notification - The notification whose title to format. + * @returns The display title string. */ export function formatNotificationTitle( notification: GitifyNotification, @@ -107,6 +136,11 @@ export function formatNotificationTitle( /** * Helper to format the metric description, determine singular or plural noun, * and apply a custom formatter if needed. + * + * @param count - The numeric count for the metric. + * @param singular - The singular noun for the metric (e.g. `"comment"`). + * @param formatter - Optional custom formatter `(count, noun) => string`. + * @returns The formatted description string, or `""` when `count` is falsy. */ export function formatMetricDescription( count: number, diff --git a/src/renderer/utils/notifications/group.ts b/src/renderer/utils/notifications/group.ts index d0265cfbb..739a7f5f1 100644 --- a/src/renderer/utils/notifications/group.ts +++ b/src/renderer/utils/notifications/group.ts @@ -2,6 +2,9 @@ import type { GitifyNotification, SettingsState } from '../../types'; /** * Returns true when settings say to group by date. + * + * @param settings - The application settings. + * @returns `true` when `groupBy` is `"DATE"`. */ export function isGroupByDate(settings: SettingsState) { return settings.groupBy === 'DATE'; @@ -9,6 +12,9 @@ export function isGroupByDate(settings: SettingsState) { /** * Returns true when settings say to group by repository. + * + * @param settings - The application settings. + * @returns `true` when `groupBy` is `"REPOSITORY"`. */ export function isGroupByRepository(settings: SettingsState) { return settings.groupBy === 'REPOSITORY'; @@ -18,6 +24,9 @@ export function isGroupByRepository(settings: SettingsState) { * Group notifications by repository.fullName preserving first-seen repository order. * Returns a Map where keys are repo fullNames and values are arrays of notifications. * Skips notifications without valid repository data. + * + * @param notifications - The notifications to group. + * @returns A Map of repository full names to their arrays of notifications. */ export function groupNotificationsByRepository( notifications: GitifyNotification[], @@ -44,8 +53,12 @@ export function groupNotificationsByRepository( return repoGroups; } /** * Returns a flattened, ordered notifications list according to: - * - repository-first-seen order (when grouped) + * - repository-first-seen order (when grouped by repository) * - natural notifications order otherwise + * + * @param notifications - The notifications to flatten and order. + * @param settings - Application settings controlling the groupBy strategy. + * @returns A flat, ordered array of notifications. */ export function getFlattenedNotificationsByRepo( notifications: GitifyNotification[], diff --git a/src/renderer/utils/notifications/handlers/utils.ts b/src/renderer/utils/notifications/handlers/utils.ts index cc6f6a60f..c8205232c 100644 --- a/src/renderer/utils/notifications/handlers/utils.ts +++ b/src/renderer/utils/notifications/handlers/utils.ts @@ -36,7 +36,15 @@ export function getNotificationAuthor( } /** - * Construct a GitHub Actions URL for a repository with optional filters. + * Construct a GitHub Actions URL for a repository with optional workflow filters. + * + * Appends the provided filter strings as a `+`-joined `query` search parameter. + * Note: `%2B` in the URL is un-encoded back to `+` because the GitHub Actions + * UI does not handle encoded plus signs correctly. + * + * @param repositoryURL - The base HTML URL of the repository. + * @param filters - Optional workflow filter strings to append as a query. + * @returns The GitHub Actions URL, with filters applied if provided. */ export function actionsURL(repositoryURL: string, filters: string[]): Link { const url = new URL(repositoryURL); diff --git a/src/renderer/utils/notifications/notifications.ts b/src/renderer/utils/notifications/notifications.ts index 030eda27c..a287ec89b 100644 --- a/src/renderer/utils/notifications/notifications.ts +++ b/src/renderer/utils/notifications/notifications.ts @@ -141,11 +141,15 @@ export async function getAllNotifications( } /** - * Enrich notification details + * Enrich notifications with detailed subject data (state, user, number, etc.). * - * @param notifications All Gitify inbox notifications - * @param settings - * @returns + * Only runs when `settings.detailedNotifications` is enabled; returns the + * original list unchanged otherwise. Details are fetched in batches via + * GraphQL to avoid overwhelming the API. + * + * @param notifications - The notifications to enrich. + * @param settings - Application settings; controls whether enrichment runs. + * @returns The same notifications with subject fields populated from the API. */ export async function enrichNotifications( notifications: GitifyNotification[], diff --git a/src/renderer/utils/notifications/remove.ts b/src/renderer/utils/notifications/remove.ts index c36a82e28..872bd0453 100644 --- a/src/renderer/utils/notifications/remove.ts +++ b/src/renderer/utils/notifications/remove.ts @@ -24,6 +24,12 @@ export function shouldRemoveNotificationsFromState( * * When `delayNotificationState` or `fetchReadNotifications` is enabled, * notifications are marked as read instead of being removed from the list. + * + * @param account - The account whose notifications should be updated. + * @param settings - Application settings controlling removal vs. mark-as-read behaviour. + * @param notificationsToRemove - The notifications to remove or mark as read. + * @param accountNotifications - The full list of account notifications to update. + * @returns A new account notifications array with the specified notifications removed or marked as read. */ export function removeNotificationsForAccount( account: Account, diff --git a/src/renderer/utils/system/audio.ts b/src/renderer/utils/system/audio.ts index e14719c2a..17a6e101c 100644 --- a/src/renderer/utils/system/audio.ts +++ b/src/renderer/utils/system/audio.ts @@ -4,6 +4,14 @@ const MINIMUM_VOLUME_PERCENTAGE = 0 as Percentage; const MAXIMUM_VOLUME_PERCENTAGE = 100 as Percentage; const VOLUME_STEP = 10 as Percentage; +/** + * Play the user's configured notification sound at the given volume. + * + * Resolves the notification sound file path from the main process, then + * plays it via the Web Audio API. + * + * @param volume - The playback volume as a percentage (0–100). + */ export async function raiseSoundNotification(volume: Percentage) { const path = await window.gitify.notificationSoundPath(); @@ -34,7 +42,10 @@ export function canIncreaseVolume(volumePercentage: Percentage) { } /** - * Decrease volume by step amount + * Decrease the volume by one step, clamped to the minimum. + * + * @param volume - The current volume percentage. + * @returns The new volume percentage after decrement, or the minimum if already at the floor. */ export function decreaseVolume(volume: Percentage) { if (canDecreaseVolume(volume)) { @@ -45,7 +56,10 @@ export function decreaseVolume(volume: Percentage) { } /** - * Increase volume by step amount + * Increase the volume by one step, clamped to the maximum. + * + * @param volume - The current volume percentage. + * @returns The new volume percentage after increment, or the maximum if already at the ceiling. */ export function increaseVolume(volume: Percentage) { if (canIncreaseVolume(volume)) { diff --git a/src/renderer/utils/system/comms.ts b/src/renderer/utils/system/comms.ts index 7d24930be..764621e32 100644 --- a/src/renderer/utils/system/comms.ts +++ b/src/renderer/utils/system/comms.ts @@ -4,6 +4,14 @@ import { type Link, OpenPreference } from '../../types'; import { loadState } from '../core/storage'; +/** + * Open a URL in the user's default browser. + * + * Only opens `https://` URLs. The `openLinks` setting controls whether + * the link opens in the foreground or background. + * + * @param url - The URL to open. + */ export function openExternalLink(url: Link): void { // Load the state from local storage to avoid having to pass settings as a parameter const { settings } = loadState(); @@ -19,42 +27,88 @@ export function openExternalLink(url: Link): void { } } +/** + * Return the application version string from the main process. + * + * @returns The version string (e.g. `"5.12.0"`). + */ export async function getAppVersion(): Promise { return await window.gitify.app.version(); } +/** + * Encrypt a plaintext value using Electron's safe storage. + * + * @param value - The plaintext string to encrypt. + * @returns The encrypted string. + */ export async function encryptValue(value: string): Promise { return await window.gitify.encryptValue(value); } +/** + * Decrypt an encrypted value using Electron's safe storage. + * + * @param value - The encrypted string to decrypt. + * @returns The plaintext string. + */ export async function decryptValue(value: string): Promise { return await window.gitify.decryptValue(value); } +/** + * Quit the Electron application. + */ export function quitApp(): void { window.gitify.app.quit(); } +/** + * Show the main application window. + */ export function showWindow(): void { window.gitify.app.show(); } +/** + * Hide the main application window. + */ export function hideWindow(): void { window.gitify.app.hide(); } +/** + * Enable or disable launching the application at system login. + * + * @param value - `true` to enable auto-launch, `false` to disable. + */ export function setAutoLaunch(value: boolean): void { window.gitify.setAutoLaunch(value); } +/** + * Switch the tray icon to an alternate idle icon variant. + * + * @param value - `true` to use the alternate idle icon, `false` for the default. + */ export function setUseAlternateIdleIcon(value: boolean): void { window.gitify.tray.useAlternateIdleIcon(value); } +/** + * Switch the tray icon to an "active" variant when there are unread notifications. + * + * @param value - `true` to use the unread-active icon, `false` for the default. + */ export function setUseUnreadActiveIcon(value: boolean): void { window.gitify.tray.useUnreadActiveIcon(value); } +/** + * Register or unregister the global keyboard shortcut for the application. + * + * @param keyboardShortcut - `true` to enable the shortcut, `false` to disable. + */ export function setKeyboardShortcut(keyboardShortcut: boolean): void { window.gitify.setKeyboardShortcut(keyboardShortcut); } diff --git a/src/renderer/utils/ui/zoom.ts b/src/renderer/utils/ui/zoom.ts index 564631efe..ff8ca8756 100644 --- a/src/renderer/utils/ui/zoom.ts +++ b/src/renderer/utils/ui/zoom.ts @@ -11,8 +11,9 @@ const ZOOM_STEP = 10 as Percentage; /** * Zoom percentage to level. 100% is the recommended zoom level (0). * If somehow the percentage is not set, it will return 0, the default zoom level. - * @param percentage 0-150 - * @returns zoomLevel -2 to 0.5 + * + * @param percentage - Zoom percentage (0–150). + * @returns Electron zoom level (-2 to 0.5). */ export function zoomPercentageToLevel(percentage: Percentage): number { if (percentage === undefined) { @@ -25,8 +26,9 @@ export function zoomPercentageToLevel(percentage: Percentage): number { /** * Zoom level to percentage. 0 is the recommended zoom level (100%). * If somehow the zoom level is not set, it will return 100, the default zoom percentage. - * @param zoom -2 to 0.5 - * @returns percentage 0-150 + * + * @param zoom - Electron zoom level (-2 to 0.5). + * @returns Zoom percentage (0–150). */ export function zoomLevelToPercentage(zoom: number): Percentage { if (zoom === undefined) { @@ -38,21 +40,29 @@ export function zoomLevelToPercentage(zoom: number): Percentage { } /** - * Returns true if can decrease zoom percentage further + * Returns true if can decrease zoom percentage further. + * + * @param zoomPercentage - The current zoom percentage. + * @returns `true` if decrementing by one step stays at or above the minimum. */ export function canDecreaseZoom(zoomPercentage: Percentage) { return zoomPercentage - ZOOM_STEP >= MINIMUM_ZOOM_PERCENTAGE; } /** - * Returns true if can increase zoom percentage further + * Returns true if can increase zoom percentage further. + * + * @param zoomPercentage - The current zoom percentage. + * @returns `true` if incrementing by one step stays at or below the maximum. */ export function canIncreaseZoom(zoomPercentage: Percentage) { return zoomPercentage + ZOOM_STEP <= MAXIMUM_ZOOM_PERCENTAGE; } /** - * Decrease zoom by step amount + * Decrease zoom by one step amount, if possible. + * + * @param zoomPercentage - The current zoom percentage. */ export function decreaseZoom(zoomPercentage: Percentage) { if (canDecreaseZoom(zoomPercentage)) { @@ -63,7 +73,9 @@ export function decreaseZoom(zoomPercentage: Percentage) { } /** - * Increase zoom by step amount + * Increase zoom by one step amount, if possible. + * + * @param zoomPercentage - The current zoom percentage. */ export function increaseZoom(zoomPercentage: Percentage) { if (canIncreaseZoom(zoomPercentage)) {