Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -63,17 +69,27 @@ export function getBeeperDesktop(): BeeperDesktop {
return clientInstance;
}

export function useBeeperDesktop<T>(fn: (client: BeeperDesktop) => Promise<T>) {
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<T>(fn: (client: BeeperDesktop) => Promise<T>, 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);
}
};
};
21 changes: 13 additions & 8 deletions src/list-accounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>("list-accounts:isShowingDetail", false);
const {
data: accounts,
Expand All @@ -21,7 +30,7 @@ function ListAccountsCommand() {
<List.Item
key={account.accountID}
icon={Icon.Person}
title={account.user?.fullName || account.user?.username || "Unnamed Account"}
title={account.user?.fullName || account.user?.username || translations.common.unnamedChat}
subtitle={!isShowingDetail ? account.network : undefined}
detail={
isShowingDetail ? (
Expand Down Expand Up @@ -96,16 +105,12 @@ function ListAccountsCommand() {
{!isLoading && (accounts?.length ?? 0) === 0 && (
<List.EmptyView
icon={error ? Icon.Warning : Icon.Person}
title={error ? "Failed to Load Accounts" : "No Accounts Found"}
description={
error
? "Could not load accounts. Make sure Beeper Desktop is running and the API is enabled, then try Refresh."
: "Make sure Beeper Desktop is running and you're authenticated"
}
title={translations.commands.listAccounts.emptyTitle}
description={translations.commands.listAccounts.emptyDescription}
/>
)}
</List>
);
}

export default withAccessToken(createBeeperOAuth())(ListAccountsCommand);
export default withAccessToken(createBeeperOAuth())(ListAccountsCommand);
137 changes: 87 additions & 50 deletions src/list-chats.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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 (
<List isLoading={isLoading} navigationTitle="Beeper Chats">
{(chats || []).map((chat) => (
<List
isLoading={isLoading}
searchBarPlaceholder={translations.commands.listChats.searchPlaceholder}
onSearchTextChange={setSearchText}
throttle
>
{chats.map((chat) => (
<List.Item
key={chat.chatID}
icon={Icon.Person}
title={chat.title || "Unnamed Chat"}
key={chat.id}
icon={getNetworkIcon(chat.network)}
title={chat.title || translations.common.unnamedChat}
subtitle={chat.network}
accessories={[
...(chat.unreadCount > 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={
<ActionPanel>
<Action
title={translations.common.openInBeeper}
icon={Icon.Window}
onAction={() => focusApp({ chatID: chat.id })}
/>
<Action.Push
title="Show Details"
title={translations.common.showDetails}
icon={Icon.Info}
target={
<Detail
markdown={`# ${chat.title || "Chat"}
markdown={`# ${chat.title}

**Chat ID:** ${chat.chatID}
**Account ID:** ${chat.accountID}
**Network:** ${chat.network}
**Type:** ${chat.type}
**Unread Count:** ${chat.unreadCount}
**Pinned:** ${chat.isPinned ? "Yes" : "No"}
**Muted:** ${chat.isMuted ? "Yes" : "No"}
**Archived:** ${chat.isArchived ? "Yes" : "No"}
**Last Activity:** ${chat.lastActivity || "N/A"}
`}
**${translations.common.details.id}:** ${chat.id}
**${translations.common.details.accountId}:** ${chat.accountID}
**${translations.common.details.network}:** ${chat.network}
**${translations.common.details.type}:** ${chat.type}
**${translations.common.details.unreadCount}:** ${chat.unreadCount}
**${translations.common.details.isPinned}:** ${chat.isPinned ? translations.common.yes : translations.common.no}
**${translations.common.details.isMuted}:** ${chat.isMuted ? translations.common.yes : translations.common.no}
**${translations.common.details.isArchived}:** ${chat.isArchived ? translations.common.yes : translations.common.no}
**${translations.common.details.lastActivity}:** ${chat.lastActivity || translations.common.details.na}`}
/>
}
/>
<Action
title="Refresh"
icon={Icon.ArrowClockwise}
shortcut={{ modifiers: ["cmd"], key: "r" }}
onAction={() => revalidate()}
/>
<Action
title="Focus Beeper Desktop"
icon={Icon.Window}
shortcut={{ modifiers: ["cmd"], key: "o" }}
onAction={() => focusApp()}
/>
<Action.CopyToClipboard title={translations.common.copyChatId} content={chat.id} />
</ActionPanel>
}
/>
))}
{!isLoading && (!chats || chats.length === 0) && (
{!isLoading && chats.length === 0 && (
<List.EmptyView
icon={Icon.Person}
title="No chats found"
description="Make sure Beeper Desktop is running and you're authenticated"
icon={Icon.Message}
title={translations.commands.listChats.emptyTitle}
description={translations.commands.listChats.emptyDescription}
/>
)}
</List>
);
}

export default withAccessToken(createBeeperOAuth())(ListChatsCommand);
export default withAccessToken(createBeeperOAuth())(ListChatsCommand);
67 changes: 67 additions & 0 deletions src/locales/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { environment, getPreferenceValues } from "@raycast/api";
import { en, Translations } from "./en";
import { cs } from "./cs";

const translations: Record<string, Translations> = {
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 };
Loading