From 19cb5ff93e0145fa51786cc8a7273791772615b2 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:34:19 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`master`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @fery25. * https://github.com/beeper/raycast/pull/3#issuecomment-3588986607 The following files were modified: * `src/api.ts` * `src/list-accounts.tsx` * `src/list-chats.tsx` * `src/locales/index.ts` * `src/search-chats.tsx` * `src/send-message.tsx` * `src/unread-chats.tsx` --- src/api.ts | 32 +++++++--- src/list-accounts.tsx | 21 ++++--- src/list-chats.tsx | 137 +++++++++++++++++++++++++--------------- src/locales/index.ts | 67 ++++++++++++++++++++ src/search-chats.tsx | 143 ++++++++++++++++++++++++++++-------------- src/send-message.tsx | 106 +++++++++++++++++++++++++++++++ src/unread-chats.tsx | 105 +++++++++++++++++++++++++++++++ 7 files changed, 499 insertions(+), 112 deletions(-) create mode 100644 src/locales/index.ts create mode 100644 src/send-message.tsx create mode 100644 src/unread-chats.tsx diff --git a/src/api.ts b/src/api.ts index 5f4f2b0..03dd52a 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,7 +1,8 @@ import BeeperDesktop from "@beeper/desktop-api"; -import type { AppFocusParams } from "@beeper/desktop-api/resources/app"; +import type { AppOpenParams } from "@beeper/desktop-api/resources/app"; import { closeMainWindow, getPreferenceValues, OAuth, showHUD } from "@raycast/api"; import { OAuthService, usePromise, getAccessToken } from "@raycast/utils"; +import { t } from "./locales"; interface Preferences { baseURL?: string; @@ -46,6 +47,11 @@ export function createBeeperOAuth() { }); } +/** + * Returns a cached BeeperDesktop client, creating a new instance when the configured base URL or access token has changed. + * + * @returns A BeeperDesktop client configured with the current base URL and access token. + */ export function getBeeperDesktop(): BeeperDesktop { const baseURL = getBaseURL(); const { token: accessToken } = getAccessToken(); @@ -63,17 +69,27 @@ export function getBeeperDesktop(): BeeperDesktop { return clientInstance; } -export function useBeeperDesktop(fn: (client: BeeperDesktop) => Promise) { - return usePromise(async () => fn(getBeeperDesktop())); +/** + * Execute an asynchronous operation using the current BeeperDesktop client and return its managed result. + * + * @param fn - Function that receives the current BeeperDesktop client and returns a promise for the desired value + * @param deps - Optional React dependency list that controls when the operation is re-run + * @returns The value produced by `fn` when executed with the current BeeperDesktop client; loading and error state are managed by the hook + */ +export function useBeeperDesktop(fn: (client: BeeperDesktop) => Promise, deps?: React.DependencyList) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - usePromise type overload issue with optional deps + return usePromise(async () => fn(getBeeperDesktop()), deps); } -export const focusApp = async (params: AppFocusParams = {}) => { +export const focusApp = async (params: AppOpenParams = {}) => { + const translations = t(); try { - await getBeeperDesktop().app.focus(params); + await getBeeperDesktop().app.open(params); await closeMainWindow(); - await showHUD("Beeper Desktop focused"); + await showHUD(translations.commands.focusApp.successMessage); } catch (error) { console.error("Failed to focus Beeper Desktop:", error); - await showHUD("Failed to focus Beeper Desktop"); + await showHUD(translations.commands.focusApp.errorMessage); } -}; +}; \ No newline at end of file diff --git a/src/list-accounts.tsx b/src/list-accounts.tsx index 70adbec..09db651 100644 --- a/src/list-accounts.tsx +++ b/src/list-accounts.tsx @@ -2,8 +2,17 @@ import { ActionPanel, Detail, List, Action, Icon, Keyboard, Color } from "@rayca import { useCachedState, withAccessToken } from "@raycast/utils"; import type { BeeperDesktop } from "@beeper/desktop-api"; import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; +import { t } from "./locales"; +/** + * Render a searchable list of Beeper Desktop accounts with an optional inline detail pane. + * + * Displays accounts fetched from Beeper Desktop and provides actions to focus the app, toggle the inline details pane, open a legacy detail view, and refresh the list. When no accounts are available an appropriate empty view is shown. + * + * @returns A Raycast List component populated with account items and an empty-state view when no accounts exist + */ function ListAccountsCommand() { + const translations = t(); const [isShowingDetail, setIsShowingDetail] = useCachedState("list-accounts:isShowingDetail", false); const { data: accounts, @@ -21,7 +30,7 @@ function ListAccountsCommand() { )} ); } -export default withAccessToken(createBeeperOAuth())(ListAccountsCommand); +export default withAccessToken(createBeeperOAuth())(ListAccountsCommand); \ No newline at end of file diff --git a/src/list-chats.tsx b/src/list-chats.tsx index 76dd7d2..e25565f 100644 --- a/src/list-chats.tsx +++ b/src/list-chats.tsx @@ -1,78 +1,115 @@ -import { ActionPanel, Detail, List, Action, Icon } from "@raycast/api"; +import { useState } from "react"; +import { ActionPanel, Detail, List, Action, Icon, Image } from "@raycast/api"; import { withAccessToken } from "@raycast/utils"; import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; +import { t } from "./locales"; +/** + * Selects an icon image that represents a messaging network. + * + * @param network - The network name or identifier (case-insensitive; spaces, slashes, and dashes are ignored) + * @returns The corresponding network icon image, or a generic message icon if the network is not recognized + */ +function getNetworkIcon(network: string): Image.ImageLike { + const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); + + const iconMap: Record = { + slack: "slack.svg", + whatsapp: "whatsapp.svg", + telegram: "telegram.svg", + discord: "discord.svg", + instagram: "instagram.svg", + facebook: "facebook.svg", + facebookmessenger: "messenger.svg", + messenger: "messenger.svg", + signal: "signal.svg", + imessage: "imessage.svg", + twitter: "twitter.svg", + email: "email.svg", + googlemessages: "google-messages.svg", + }; + + return iconMap[networkLower] || Icon.Message; +} + +/** + * Render a searchable list of Beeper chats with actions to open the chat in Beeper, view details, and copy the chat ID. + * + * The list is populated from Beeper Desktop search results filtered by the search bar; each item shows network-specific icon, title (or a localized unnamed fallback), type, and last activity when available. + * + * @returns A Raycast List component populated with chat items and an empty state when no chats are found. + */ function ListChatsCommand() { - const { - data: chats, - isLoading, - revalidate, - } = useBeeperDesktop(async (client) => { - const result = await client.chats.search(); - console.log("Fetched accounts:", JSON.stringify(result, null, 2)); - return result.data; - }); + const translations = t(); + const [searchText, setSearchText] = useState(""); + const { data: chats = [], isLoading } = useBeeperDesktop( + async (client) => { + const allChats = []; + const searchParams = searchText ? { query: searchText } : {}; + for await (const chat of client.chats.search(searchParams)) { + allChats.push(chat); + } + return allChats; + }, + [searchText], + ); return ( - - {(chats || []).map((chat) => ( + + {chats.map((chat) => ( 0 ? [{ text: `${chat.unreadCount} unread` }] : []), - ...(chat.isPinned ? [{ icon: Icon.Pin }] : []), - ...(chat.isMuted ? [{ icon: Icon.SpeakerOff }] : []), - ...(chat.isArchived ? [{ icon: Icon.AddPerson }] : []), - ]} + accessories={[{ text: chat.type }, chat.lastActivity ? { date: new Date(chat.lastActivity) } : {}].filter( + Boolean, + )} actions={ + focusApp({ chatID: chat.id })} + /> } /> - revalidate()} - /> - focusApp()} - /> + } /> ))} - {!isLoading && (!chats || chats.length === 0) && ( + {!isLoading && chats.length === 0 && ( )} ); } -export default withAccessToken(createBeeperOAuth())(ListChatsCommand); +export default withAccessToken(createBeeperOAuth())(ListChatsCommand); \ No newline at end of file diff --git a/src/locales/index.ts b/src/locales/index.ts new file mode 100644 index 0000000..fb61272 --- /dev/null +++ b/src/locales/index.ts @@ -0,0 +1,67 @@ +import { environment, getPreferenceValues } from "@raycast/api"; +import { en, Translations } from "./en"; +import { cs } from "./cs"; + +const translations: Record = { + en, + cs, +}; + +/** + * Determine the active locale code to use for translations. + * + * Prefers the user's Raycast language preference when set and supported, otherwise derives the language from the Raycast environment locale, and defaults to `en` if no supported locale is found. + * + * @returns The locale code to use for translations (e.g., `en`, `cs`). `en` if no supported locale is found. + */ +function getLocale(): string { + // Try to get language from Raycast preferences first + const prefs = getPreferenceValues(); + if (typeof prefs.language === "string" && translations[prefs.language]) { + return prefs.language; + } + + // Fallback to Raycast environment locale + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const raycastLocale = (environment as any).locale as string | undefined; + if (!raycastLocale) { + return "en"; + } + const languageCode = raycastLocale.split(/[-_]/)[0].toLowerCase(); + return translations[languageCode] ? languageCode : "en"; +} + +let currentLocale = getLocale(); +let currentTranslations = translations[currentLocale]; + +/** + * Get the translations for the currently active locale. + * + * @returns The `Translations` object for the active locale. + */ +export function t(): Translations { + return currentTranslations; +} + +/** + * Switches the active locale and its translations when the given locale is supported. + * + * @param locale - Locale code (e.g., "en", "cs"); if this locale is present in the translations map the active locale and translations are updated, otherwise no change occurs. + */ +export function setLocale(locale: string): void { + if (translations[locale]) { + currentLocale = locale; + currentTranslations = translations[locale]; + } +} + +/** + * Get the currently active locale code. + * + * @returns The active locale code (for example `en` or `cs`). + */ +export function getCurrentLocale(): string { + return currentLocale; +} + +export type { Translations }; \ No newline at end of file diff --git a/src/search-chats.tsx b/src/search-chats.tsx index 965eab9..3ce4c37 100644 --- a/src/search-chats.tsx +++ b/src/search-chats.tsx @@ -1,61 +1,112 @@ -import { useState } from "react"; -import { ActionPanel, Detail, List, Action, Icon } from "@raycast/api"; +import { ActionPanel, Action, List, Icon, Image } from "@raycast/api"; import { withAccessToken } from "@raycast/utils"; -import { useBeeperDesktop, createBeeperOAuth } from "./api"; +import { useState } from "react"; +import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; +import { t } from "./locales"; + +/** + * Selects an icon representing the given chat/network name. + * + * @param network - Network name or identifier (case-insensitive; may include spaces, slashes, or hyphens) + * @returns The matching local SVG asset for the network, or `Icon.Message` when no specific icon is available + */ +function getNetworkIcon(network: string): Image.ImageLike { + const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); + + const iconMap: Record = { + slack: "slack.svg", + whatsapp: "whatsapp.svg", + telegram: "telegram.svg", + discord: "discord.svg", + instagram: "instagram.svg", + facebook: "facebook.svg", + facebookmessenger: "messenger.svg", + messenger: "messenger.svg", + signal: "signal.svg", + imessage: "imessage.svg", + twitter: "twitter.svg", + email: "email.svg", + googlemessages: "google-messages.svg", + }; + + return iconMap[networkLower] || Icon.Message; +} +/** + * Render a search interface that finds and displays Beeper chats matching the user's query. + * + * Shows an initial empty view when the search box is empty, performs a client-side search as the + * user types, and renders matching chats with network icons, unread/pinned/muted accessories, and + * actions to open the chat in Beeper or copy its ID. + * + * @returns The List JSX element presenting the search bar, results, and appropriate empty states. + */ function SearchChatsCommand() { + const translations = t(); const [searchText, setSearchText] = useState(""); - const { data: chats = [], isLoading } = useBeeperDesktop(async (client) => { - const result = await client.chats.search({ query: searchText }); - return result.data; - }); + const { data: chats = [], isLoading } = useBeeperDesktop( + async (client) => { + if (!searchText) { + return []; + } + const allChats = []; + for await (const chat of client.chats.search({ query: searchText })) { + allChats.push(chat); + } + return allChats; + }, + [searchText], + ); return ( - - {chats.map((chat) => ( - - - } - /> - - } + + {searchText === "" ? ( + - ))} - {!isLoading && chats.length === 0 && ( + ) : ( + chats.map((chat) => ( + 0 + ? [{ text: translations.commands.unreadChats.unreadCount(chat.unreadCount) }] + : []), + ...(chat.isPinned ? [{ icon: Icon.Pin }] : []), + ...(chat.isMuted ? [{ icon: Icon.SpeakerOff }] : []), + ]} + actions={ + + focusApp({ chatID: chat.id })} + /> + + + } + /> + )) + )} + {searchText !== "" && !isLoading && chats.length === 0 && ( )} ); } -export default withAccessToken(createBeeperOAuth())(SearchChatsCommand); +export default withAccessToken(createBeeperOAuth())(SearchChatsCommand); \ No newline at end of file diff --git a/src/send-message.tsx b/src/send-message.tsx new file mode 100644 index 0000000..a548e0b --- /dev/null +++ b/src/send-message.tsx @@ -0,0 +1,106 @@ +import { ActionPanel, Action, Form, showToast, Toast, Icon, showHUD, closeMainWindow } from "@raycast/api"; +import { withAccessToken } from "@raycast/utils"; +import { useState } from "react"; +import { useBeeperDesktop, createBeeperOAuth, getBeeperDesktop } from "./api"; +import { t } from "./locales"; + +interface SendMessageFormValues { + chatId: string; + message: string; +} + +/** + * Renders a form to select a chat and send a message via the Beeper desktop client. + * + * The form displays a dropdown of chats (sorted by most recent activity) and a message textarea. + * On submit it validates inputs, sends the message with the Beeper desktop client, closes the main window on success, + * and shows success or failure feedback to the user. + * + * @returns The React element containing the send-message form. + */ +function SendMessageForm() { + const translations = t(); + const [isLoading, setIsLoading] = useState(false); + const { data: chats = [], isLoading: chatsLoading } = useBeeperDesktop(async (client) => { + const allChats = []; + for await (const chat of client.chats.search({})) { + allChats.push(chat); + } + // Sort by last activity (most recent first) + return allChats.sort((a, b) => { + const aTime = a.lastActivity ? new Date(a.lastActivity).getTime() : 0; + const bTime = b.lastActivity ? new Date(b.lastActivity).getTime() : 0; + return bTime - aTime; + }); + }, []); + + async function handleSubmit(values: SendMessageFormValues) { + if (!values.chatId || !values.message) { + await showToast({ + style: Toast.Style.Failure, + title: translations.commands.sendMessage.missingInfoTitle, + message: translations.commands.sendMessage.missingInfoMessage, + }); + return; + } + + setIsLoading(true); + try { + const client = getBeeperDesktop(); + await client.messages.send({ + chatID: values.chatId, + text: values.message, + }); + + await closeMainWindow(); + await showHUD(translations.commands.sendMessage.successMessage); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: translations.commands.sendMessage.errorTitle, + message: error instanceof Error ? error.message : "Unknown error occurred", + }); + } finally { + setIsLoading(false); + } + } + + return ( +
+ + + } + > + + {chats.map((chat) => ( + + ))} + + + + ); +} + +export default withAccessToken(createBeeperOAuth())(SendMessageForm); \ No newline at end of file diff --git a/src/unread-chats.tsx b/src/unread-chats.tsx new file mode 100644 index 0000000..30ea430 --- /dev/null +++ b/src/unread-chats.tsx @@ -0,0 +1,105 @@ +import { ActionPanel, Action, List, Icon, Image } from "@raycast/api"; +import { withAccessToken } from "@raycast/utils"; +import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; +import { t } from "./locales"; + +/** + * Selects an icon representing a messaging network. + * + * The provided network name is normalized by lowercasing and removing spaces, slashes, and dashes before lookup. + * + * @param network - Network name (e.g., "Slack", "facebook-messenger", "Google Messages") + * @returns The SVG filename for a known network (e.g., `"slack.svg"`, `"messenger.svg"`) or `Icon.Message` when no match exists + */ +function getNetworkIcon(network: string): Image.ImageLike { + const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); + + const iconMap: Record = { + slack: "slack.svg", + whatsapp: "whatsapp.svg", + telegram: "telegram.svg", + discord: "discord.svg", + instagram: "instagram.svg", + facebook: "facebook.svg", + facebookmessenger: "messenger.svg", + messenger: "messenger.svg", + signal: "signal.svg", + imessage: "imessage.svg", + twitter: "twitter.svg", + email: "email.svg", + googlemessages: "google-messages.svg", + }; + + return iconMap[networkLower] || Icon.Message; +} + +/** + * Render a Raycast list of Beeper chats that currently have unread messages. + * + * Displays unread chats sorted by unread count (highest first). Each list item shows the chat icon, title, + * network, unread count, pin/mute indicators, and last activity date when available. Actions are provided to + * open the chat in Beeper and to copy the chat ID. An empty view is shown when there are no unread chats. + * + * @returns A Raycast `List` element containing unread chat items with accessories and actions + */ +function UnreadChatsCommand() { + const translations = t(); + const { data: chats = [], isLoading } = useBeeperDesktop(async (client) => { + const allChats = []; + for await (const chat of client.chats.search({})) { + // Filter only chats with unread messages + if (chat.unreadCount > 0) { + allChats.push(chat); + } + } + // Sort by unread count (highest first) + return allChats.sort((a, b) => b.unreadCount - a.unreadCount); + }, []); + + const totalUnread = chats.reduce((sum, chat) => sum + chat.unreadCount, 0); + + return ( + 0 ? translations.commands.unreadChats.totalCount(totalUnread) : ""}`} + > + {chats.map((chat) => ( + + focusApp({ chatID: chat.id })} + /> + + + } + /> + ))} + {!isLoading && chats.length === 0 && ( + + )} + + ); +} + +export default withAccessToken(createBeeperOAuth())(UnreadChatsCommand); \ No newline at end of file