diff --git a/README.md b/README.md
index fa2b494..1e51d09 100644
--- a/README.md
+++ b/README.md
@@ -20,3 +20,49 @@ Once enabled, you can use the Raycast extension to interact with your Beeper cha
## Setup
See the [Beeper Desktop API Getting Started guide](https://developers.beeper.com/desktop-api/#get-started) for additional setup instructions.
+
+## Available Commands
+
+The extension provides the following commands:
+
+### List Accounts
+
+- **Description**: Display a list of all connected Beeper accounts
+- **Usage**: Activate from Raycast and select "List Accounts"
+
+### List Chats
+
+- **Description**: Display a list of all your Beeper chats
+- **Usage**: Activate from Raycast and select "List Chats"
+
+### Search Chats
+
+- **Description**: Search through your Beeper chats
+- **Usage**: Activate from Raycast and select "Search Chats", then enter search text
+
+### Unread Chats
+
+- **Description**: Display all chats with unread messages
+- **Usage**: Activate from Raycast and select "Unread Chats"
+
+### Send Message
+
+- **Description**: Quickly send a message to any chat
+- **Usage**: Activate from Raycast and select "Send Message"
+
+### Focus Beeper Desktop
+
+- **Description**: Bring Beeper Desktop to the foreground
+- **Usage**: Activate from Raycast and select "Focus Beeper Desktop"
+
+## NPM Commands
+
+The project uses the following npm scripts:
+
+| Command | Description |
+|---------|-------------|
+| `npm run dev` | Runs the extension in development mode |
+| `npm run build` | Compiles the extension for distribution |
+| `npm run lint` | Validates code using ESLint |
+| `npm run fix-lint` | Automatically fixes linting issues |
+| `npm run publish` | Publishes the extension to the Raycast Store |
diff --git a/assets/discord.svg b/assets/discord.svg
new file mode 100644
index 0000000..ce3b4de
--- /dev/null
+++ b/assets/discord.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/assets/email.svg b/assets/email.svg
new file mode 100644
index 0000000..a108390
--- /dev/null
+++ b/assets/email.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/assets/facebook.svg b/assets/facebook.svg
new file mode 100644
index 0000000..976bd6b
--- /dev/null
+++ b/assets/facebook.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/assets/google-messages.svg b/assets/google-messages.svg
new file mode 100644
index 0000000..59155bc
--- /dev/null
+++ b/assets/google-messages.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/assets/imessage.svg b/assets/imessage.svg
new file mode 100644
index 0000000..201b9af
--- /dev/null
+++ b/assets/imessage.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/assets/instagram.svg b/assets/instagram.svg
new file mode 100644
index 0000000..be77209
--- /dev/null
+++ b/assets/instagram.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/assets/messenger.svg b/assets/messenger.svg
new file mode 100644
index 0000000..17476a1
--- /dev/null
+++ b/assets/messenger.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/assets/signal.svg b/assets/signal.svg
new file mode 100644
index 0000000..2f01d47
--- /dev/null
+++ b/assets/signal.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/assets/slack.svg b/assets/slack.svg
new file mode 100644
index 0000000..bb0b1f1
--- /dev/null
+++ b/assets/slack.svg
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/assets/telegram.svg b/assets/telegram.svg
new file mode 100644
index 0000000..ee61f29
--- /dev/null
+++ b/assets/telegram.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/assets/twitter.svg b/assets/twitter.svg
new file mode 100644
index 0000000..052dde9
--- /dev/null
+++ b/assets/twitter.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/assets/whatsapp.svg b/assets/whatsapp.svg
new file mode 100644
index 0000000..d0f7ec5
--- /dev/null
+++ b/assets/whatsapp.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 35f1ed9..981fb34 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"@types/react": "19.0.10",
"eslint": "^9.22.0",
"prettier": "^3.5.3",
+ "simple-icons": "^15.22.0",
"typescript": "^5.8.2"
}
},
@@ -1136,6 +1137,7 @@
"resolved": "https://registry.npmjs.org/@raycast/api/-/api-1.102.6.tgz",
"integrity": "sha512-P6j4nSOJe8wlsaf8gU/fsEnB4/oO8AXywcX3NiVQGiG9PWb/OyCCWQA8SFNdARsYyzNsJRlR+XRfMVzCBNv8Rw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@oclif/core": "^4.4.1",
"@oclif/plugin-autocomplete": "^3.2.31",
@@ -1237,6 +1239,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
"integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~6.20.0"
}
@@ -1296,6 +1299,7 @@
"integrity": "sha512-r1XG74QgShUgXph1BYseJ+KZd17bKQib/yF3SR+demvytiRXrwd12Blnz5eYGm8tXaeRdd4x88MlfwldHoudGg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.42.0",
"@typescript-eslint/types": "8.42.0",
@@ -1501,6 +1505,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1874,6 +1879,7 @@
"integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -2756,6 +2762,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -2904,6 +2911,26 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/simple-icons": {
+ "version": "15.22.0",
+ "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-15.22.0.tgz",
+ "integrity": "sha512-i/w5Ie4tENfGYbdCo2iJ+oies0vOFd8QXWHopKOUzudfLCvnmeheF2PpHp89Z2azpc+c2su3lMiWO/SpP+429A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/simple-icons"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/simple-icons"
+ }
+ ],
+ "license": "CC0-1.0",
+ "engines": {
+ "node": ">=0.12.18"
+ }
+ },
"node_modules/stream-chain": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz",
@@ -3011,6 +3038,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -3081,6 +3109,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
diff --git a/package.json b/package.json
index 5cd4378..697841f 100644
--- a/package.json
+++ b/package.json
@@ -20,9 +20,33 @@
"mode": "no-view"
},
{
- "name": "find-chat",
- "title": "Find Chat",
- "description": "Find chats from Beeper Desktop",
+ "name": "list-chats",
+ "title": "List Chats",
+ "description": "List all your Beeper chats",
+ "mode": "view"
+ },
+ {
+ "name": "search-chats",
+ "title": "Search Chats",
+ "description": "Search through your Beeper chats",
+ "mode": "view"
+ },
+ {
+ "name": "unread-chats",
+ "title": "Unread Chats",
+ "description": "View all chats with unread messages",
+ "mode": "view"
+ },
+ {
+ "name": "send-message",
+ "title": "Send Message",
+ "description": "Quickly send a message to any chat",
+ "mode": "view"
+ },
+ {
+ "name": "search-messages",
+ "title": "Search Messages",
+ "description": "Search through your Beeper messages",
"mode": "view"
}
],
@@ -34,6 +58,24 @@
"type": "textfield",
"required": false,
"default": "http://localhost:23373"
+ },
+ {
+ "name": "language",
+ "title": "Language",
+ "description": "Select the extension language",
+ "type": "dropdown",
+ "required": false,
+ "default": "en",
+ "data": [
+ {
+ "title": "English",
+ "value": "en"
+ },
+ {
+ "title": "Čeština",
+ "value": "cs"
+ }
+ ]
}
],
"dependencies": {
@@ -47,6 +89,7 @@
"@types/react": "19.0.10",
"eslint": "^9.22.0",
"prettier": "^3.5.3",
+ "simple-icons": "^15.22.0",
"typescript": "^5.8.2"
},
"scripts": {
@@ -57,4 +100,4 @@
"prepublishOnly": "echo \"\\n\\nIt seems like you are trying to publish the Raycast extension to npm.\\n\\nIf you did intend to publish it to npm, remove the \\`prepublishOnly\\` script and rerun \\`npm publish\\` again.\\nIf you wanted to publish it to the Raycast Store instead, use \\`npm run publish\\` instead.\\n\\n\" && exit 1",
"publish": "npx @raycast/api@latest publish"
}
-}
+}
\ No newline at end of file
diff --git a/scripts/generate-icons.js b/scripts/generate-icons.js
new file mode 100644
index 0000000..af95b3a
--- /dev/null
+++ b/scripts/generate-icons.js
@@ -0,0 +1,54 @@
+#!/usr/bin/env node
+
+/**
+ * Script to generate network icons from simple-icons
+ * Run: node scripts/generate-icons.js
+ */
+
+const fs = require('fs');
+const path = require('path');
+const simpleIcons = require('simple-icons');
+
+// Get icons from simple-icons
+const icons = {
+ slack: simpleIcons.siSlack,
+ whatsapp: simpleIcons.siWhatsapp,
+ telegram: simpleIcons.siTelegram,
+ discord: simpleIcons.siDiscord,
+ instagram: simpleIcons.siInstagram,
+ facebook: simpleIcons.siFacebook,
+ messenger: simpleIcons.siMessenger,
+ signal: simpleIcons.siSignal,
+ linkedin: simpleIcons.siLinkedin,
+ twitter: simpleIcons.siX,
+ imessage: simpleIcons.siImessage,
+ sms: simpleIcons.siAndroidmessages,
+ email: simpleIcons.siGmail,
+};
+
+const outputDir = path.join(__dirname, '..', 'assets', 'icons');
+
+// Ensure output directory exists
+if (!fs.existsSync(outputDir)) {
+ fs.mkdirSync(outputDir, { recursive: true });
+}
+
+// Generate SVG files with proper styling for Raycast
+// Use a dark color that Raycast can tint for light/dark mode
+Object.entries(icons).forEach(([name, icon]) => {
+ if (!icon) {
+ console.log(`Skipping ${name} - icon not found`);
+ return;
+ }
+
+ const svg = ``;
+
+ const filename = `${name}.png`;
+ fs.writeFileSync(path.join(outputDir, filename.replace('.png', '.svg')), svg);
+ console.log(`Generated ${filename.replace('.png', '.svg')}`);
+});
+
+console.log('\nAll icons generated successfully!');
diff --git a/src/api.ts b/src/api.ts
index 5f4f2b0..54ba8de 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,28 @@ 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.
+ * The operation will be re-executed whenever any value in the args array changes.
+ *
+ * @param fn - Function that receives the BeeperDesktop client and args, returns a Promise
+ * @param args - Optional array of dependencies that trigger re-execution when changed
+ */
+export function useBeeperDesktop(
+ fn: (client: BeeperDesktop, ...args: A) => Promise,
+ args?: A,
+) {
+ return usePromise((...a: A) => fn(getBeeperDesktop(), ...a), (args ?? []) as A);
}
-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);
}
};
diff --git a/src/components/ChatListItem.tsx b/src/components/ChatListItem.tsx
new file mode 100644
index 0000000..068b2b1
--- /dev/null
+++ b/src/components/ChatListItem.tsx
@@ -0,0 +1,45 @@
+import { List, ActionPanel, Action, Icon } from "@raycast/api";
+import type { ReactNode } from "react";
+import { Translations } from "../locales/en";
+import { getChatIcon } from "../utils/chatIcon";
+
+interface Chat {
+ id: string;
+ network: string;
+ type?: string;
+ participants?: {
+ items?: Array<{ isSelf?: boolean; imgURL?: string }>;
+ };
+ title?: string;
+ avatarUrl?: string;
+ onOpen?: () => void;
+ detailsTarget?: ReactNode;
+}
+
+interface ChatListItemProps {
+ chat: Chat;
+ translations: Translations;
+ accessories?: List.Item.Props["accessories"];
+ showDetails?: boolean;
+}
+
+export function ChatListItem({ chat, translations, accessories = [], showDetails = false }: ChatListItemProps) {
+ return (
+
+ chat.onOpen?.()} />
+ {showDetails && chat.detailsTarget && (
+
+ )}
+
+
+ }
+ />
+ );
+}
diff --git a/src/find-chat.tsx b/src/find-chat.tsx
deleted file mode 100644
index c2a368e..0000000
--- a/src/find-chat.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { ActionPanel, Action, List, Icon } from "@raycast/api";
-import { withAccessToken } from "@raycast/utils";
-import { useState } from "react";
-import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api";
-
-function FindChatCommand() {
- const [searchText, setSearchText] = useState("");
- const { data: chats = [], isLoading } = useBeeperDesktop(async (client) => {
- const result = await client.chats.search({ query: searchText });
- return result.data;
- });
-
- return (
-
- {chats.map((chat) => (
- 0 ? [{ text: `${chat.unreadCount} unread` }] : []),
- ...(chat.isPinned ? [{ icon: Icon.Pin }] : []),
- ...(chat.isMuted ? [{ icon: Icon.SpeakerOff }] : []),
- ]}
- actions={
-
- focusApp({ chatID: chat.chatID })}
- />
-
-
- }
- />
- ))}
- {!isLoading && chats.length === 0 && (
-
- )}
-
- );
-}
-
-export default withAccessToken(createBeeperOAuth())(FindChatCommand);
diff --git a/src/hooks/useChatSearch.ts b/src/hooks/useChatSearch.ts
new file mode 100644
index 0000000..c9dc971
--- /dev/null
+++ b/src/hooks/useChatSearch.ts
@@ -0,0 +1,36 @@
+import { useBeeperDesktop } from "../api";
+
+export function useChatSearch(searchText: string, includeEmpty = false) {
+ return useBeeperDesktop(
+ async (client, query, includeEmptyResults) => {
+ if (!query && !includeEmptyResults) return [];
+
+ const allChats = [];
+ let cursor: string | null = null;
+ let hasMore = true;
+ const MAX_PAGES = 10; // Safety limit: max 10 pages * 100 = 1000 results
+ let pageCount = 0;
+
+ // For search, load more results initially (100 per batch)
+ // since users expect comprehensive results when searching
+ while (hasMore && pageCount < MAX_PAGES) {
+ const params = query
+ ? { query, limit: 100, ...(cursor ? { cursor, direction: "older" as const } : {}) }
+ : { limit: 100, ...(cursor ? { cursor, direction: "older" as const } : {}) };
+
+ const page = await client.chats.search(params);
+ allChats.push(...page.items);
+
+ cursor = page.oldestCursor;
+ hasMore = page.hasMore;
+ pageCount++;
+
+ // Early exit if we have enough results
+ if (allChats.length >= 200) break;
+ }
+
+ return allChats;
+ },
+ [searchText, includeEmpty] as [string, boolean],
+ );
+}
diff --git a/src/hooks/useMessageSearch.ts b/src/hooks/useMessageSearch.ts
new file mode 100644
index 0000000..a6a4e26
--- /dev/null
+++ b/src/hooks/useMessageSearch.ts
@@ -0,0 +1,46 @@
+import { useBeeperDesktop } from "../api";
+
+export function useMessageSearch(searchText: string) {
+ return useBeeperDesktop(
+ async (client, query) => {
+ if (!query) return [];
+ const allMessages = [];
+ // Search for messages matching the query
+ // We limit to a reasonable number or let the iterator handle it,
+ // but for a simple list view, fetching the first batch is usually enough.
+ // The API returns a PagePromise, so we can iterate.
+ for await (const message of client.messages.search({ query })) {
+ allMessages.push(message);
+ if (allMessages.length >= 50) break;
+ }
+
+ // Deduplicate chat IDs
+ const uniqueChatIDs = Array.from(new Set(allMessages.map((m) => m.chatID)));
+
+ // Fetch chats with simple concurrency control (batch size of 5)
+ const chatMap = new Map();
+ const BATCH_SIZE = 5;
+
+ for (let i = 0; i < uniqueChatIDs.length; i += BATCH_SIZE) {
+ const batch = uniqueChatIDs.slice(i, i + BATCH_SIZE);
+ await Promise.all(
+ batch.map(async (chatID) => {
+ try {
+ const chat = await client.chats.retrieve({ chatID });
+ chatMap.set(chatID, chat);
+ } catch (e) {
+ console.warn(`Failed to load chat ${chatID}`, e);
+ }
+ }),
+ );
+ }
+
+ // Map messages to include chat details
+ return allMessages.map((message) => ({
+ ...message,
+ chat: chatMap.get(message.chatID) || null,
+ }));
+ },
+ [searchText] as [string],
+ );
+}
diff --git a/src/list-accounts.tsx b/src/list-accounts.tsx
index 70adbec..d2b8068 100644
--- a/src/list-accounts.tsx
+++ b/src/list-accounts.tsx
@@ -1,16 +1,24 @@
import { ActionPanel, Detail, List, Action, Icon, Keyboard, Color } from "@raycast/api";
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,
isLoading,
revalidate,
error,
- } = useBeeperDesktop(async (client) => {
+ } = useBeeperDesktop(async (client) => {
const result = await client.accounts.list();
return result;
});
@@ -21,7 +29,7 @@ function ListAccountsCommand() {
)}
diff --git a/src/list-chats.tsx b/src/list-chats.tsx
index 76dd7d2..34a6944 100644
--- a/src/list-chats.tsx
+++ b/src/list-chats.tsx
@@ -1,74 +1,133 @@
-import { ActionPanel, Detail, List, Action, Icon } from "@raycast/api";
+import { useState } from "react";
+import { ActionPanel, Detail, List, Action, Icon, Image, showToast, Toast } from "@raycast/api";
import { withAccessToken } from "@raycast/utils";
-import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api";
+import { useBeeperDesktop, getBeeperDesktop, createBeeperOAuth, focusApp } from "./api";
+import { t } from "./locales";
+import { getChatIcon } from "./utils/chatIcon";
+/**
+ * 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 [chats, setChats] = useState([]);
+ const [cursor, setCursor] = useState(null);
+ const [hasMore, setHasMore] = useState(true);
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
+
+ const { isLoading } = useBeeperDesktop(
+ async (client, query) => {
+ // Reset state when search changes
+ setChats([]);
+ setCursor(null);
+ setHasMore(true);
+
+ const searchParams = query ? { query, limit: 50 } : { limit: 50 };
+ const page = await client.chats.search(searchParams);
+
+ setChats(page.items);
+ setCursor(page.oldestCursor);
+ setHasMore(page.hasMore);
+
+ return page.items;
+ },
+ [searchText],
+ );
+
+ const loadMore = async () => {
+ if (!hasMore || isLoadingMore || !cursor) return;
+
+ setIsLoadingMore(true);
+ try {
+ const client = getBeeperDesktop();
+ const searchParams = searchText
+ ? { query: searchText, limit: 50, cursor, direction: "older" as const }
+ : { limit: 50, cursor, direction: "older" as const };
+
+ const page = await client.chats.search(searchParams);
+
+ setChats((prev) => [...prev, ...page.items]);
+ setCursor(page.oldestCursor);
+ setHasMore(page.hasMore);
+ } catch (error) {
+ console.error("Failed to load more chats:", error);
+ await showToast({
+ style: Toast.Style.Failure,
+ title: translations.commands.listChats.loadMoreError || "Failed to Load More Chats",
+ message: translations.commands.listChats.loadMoreErrorMessage || "Please try again",
+ });
+ } finally {
+ setIsLoadingMore(false);
+ }
+ };
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) }] : [])]}
actions={
+ focusApp({ chatID: chat.id })}
+ />
}
/>
- revalidate()}
- />
- focusApp()}
- />
+
}
/>
))}
- {!isLoading && (!chats || chats.length === 0) && (
+ {!isLoading && hasMore && chats.length > 0 && (
+
+
+
+ }
+ />
+ )}
+ {!isLoading && chats.length === 0 && (
)}
diff --git a/src/locales/cs.ts b/src/locales/cs.ts
new file mode 100644
index 0000000..b537305
--- /dev/null
+++ b/src/locales/cs.ts
@@ -0,0 +1,104 @@
+import { Translations } from "./en";
+
+export const cs: Translations = {
+ // Commands
+ commands: {
+ listAccounts: {
+ title: "Seznam účtů",
+ description: "Zobrazit všechny připojené Beeper účty",
+ navigationTitle: "Beeper účty",
+ emptyTitle: "Žádné účty nenalezeny",
+ emptyDescription: "Nejsou připojeny žádné Beeper účty",
+ },
+ listChats: {
+ title: "Seznam chatů",
+ description: "Zobrazit všechny vaše Beeper chaty",
+ searchPlaceholder: "Filtrovat chaty...",
+ emptyTitle: "Žádné chaty nenalezeny",
+ emptyDescription: "Zkuste upravit hledání nebo se ujistěte, že Beeper Desktop běží",
+ loadMoreError: "Nepodařilo se načíst další chaty",
+ loadMoreErrorMessage: "Zkuste to prosím znovu",
+ loading: "Načítání...",
+ loadMoreChats: "Načíst další chaty",
+ loadMoreAction: "Načíst další",
+ },
+ searchChats: {
+ title: "Hledat chaty",
+ description: "Prohledat vaše Beeper chaty",
+ searchPlaceholder: "Hledat chaty...",
+ emptyTitle: "Vyhledat chaty",
+ emptyDescription: "Začněte psát pro vyhledávání v Beeper chatech",
+ noResultsTitle: "Žádné chaty nenalezeny",
+ noResultsDescription: "Zkuste změnit hledání nebo se ujistěte, že Beeper Desktop běží",
+ },
+ unreadChats: {
+ title: "Nepřečtené chaty",
+ description: "Zobrazit všechny chaty s nepřečtenými zprávami",
+ navigationTitle: "Nepřečtené chaty",
+ searchPlaceholder: "Filtrovat nepřečtené chaty...",
+ emptyTitle: "Žádné nepřečtené zprávy",
+ emptyDescription: "Vše vyřízeno! Nemáte žádné nepřečtené chaty.",
+ errorTitle: "Nepodařilo se načíst chaty",
+ errorDescription: "Nelze se připojit k Beeper Desktop. Ujistěte se, že běží a zkuste to znovu.",
+ unreadCount: (count: number) => `${count} nepřečtených`,
+ totalCount: (count: number) => ` (${count} celkem)`,
+ },
+ sendMessage: {
+ title: "Odeslat zprávu",
+ description: "Rychle odeslat zprávu do libovolného chatu",
+ chatLabel: "Chat",
+ chatPlaceholder: "Vyberte chat",
+ messageLabel: "Zpráva",
+ messagePlaceholder: "Napište svou zprávu zde...",
+ submitButton: "Odeslat zprávu",
+ successMessage: "✓ Zpráva úspěšně odeslána",
+ errorTitle: "Nepodařilo se odeslat zprávu",
+ missingInfoTitle: "Chybějící informace",
+ missingInfoMessage: "Vyberte prosím chat a napište zprávu",
+ },
+ focusApp: {
+ title: "Zaměřit Beeper Desktop",
+ description: "Přenést Beeper Desktop do popředí",
+ successMessage: "Beeper Desktop zaměřen",
+ errorMessage: "Nepodařilo se zaměřit Beeper Desktop",
+ },
+ searchMessages: {
+ title: "Hledat zprávy",
+ description: "Prohledat vaše Beeper zprávy",
+ searchPlaceholder: "Hledat zprávy...",
+ emptyTitle: "Vyhledat zprávy",
+ emptyDescription: "Začněte psát pro vyhledávání v Beeper zprávách",
+ noResultsTitle: "Žádné zprávy nenalezeny",
+ noResultsDescription: "Zkuste změnit hledání nebo se ujistěte, že Beeper Desktop běží",
+ },
+ },
+
+ // Common
+ common: {
+ unnamedChat: "Nepojmenovaný chat",
+ unknownMessage: "Neznámá zpráva",
+ unreadCount: (count: number) => `${count} nepřečtených`,
+ unknownError: "Došlo k neznámé chybě",
+ openInBeeper: "Otevřít chat v Beeper",
+ copyChatId: "Kopírovat ID chatu",
+ copyMessageText: "Kopírovat text zprávy",
+ showDetails: "Zobrazit detaily",
+ pinned: "Připnuto",
+ muted: "Ztišeno",
+ archived: "Archivováno",
+ yes: "Ano",
+ no: "Ne",
+ details: {
+ id: "ID",
+ accountId: "ID účtu",
+ network: "Síť",
+ type: "Typ",
+ unreadCount: "Počet nepřečtených",
+ isPinned: "Připnuto",
+ isMuted: "Ztišeno",
+ isArchived: "Archivováno",
+ lastActivity: "Poslední aktivita",
+ na: "N/A",
+ },
+ },
+};
diff --git a/src/locales/en.ts b/src/locales/en.ts
new file mode 100644
index 0000000..ef631b3
--- /dev/null
+++ b/src/locales/en.ts
@@ -0,0 +1,104 @@
+export const en = {
+ // Commands
+ commands: {
+ listAccounts: {
+ title: "List Accounts",
+ description: "List all connected Beeper accounts",
+ navigationTitle: "Beeper Accounts",
+ emptyTitle: "No Accounts Found",
+ emptyDescription: "No Beeper accounts are connected",
+ },
+ listChats: {
+ title: "List Chats",
+ description: "List all your Beeper chats",
+ searchPlaceholder: "Filter chats...",
+ emptyTitle: "No chats found",
+ emptyDescription: "Try adjusting your search or make sure Beeper Desktop is running",
+ loadMoreError: "Failed to Load More Chats",
+ loadMoreErrorMessage: "Please try again",
+ loading: "Loading...",
+ loadMoreChats: "Load More Chats",
+ loadMoreAction: "Load More",
+ },
+ searchChats: {
+ title: "Search Chats",
+ description: "Search through your Beeper chats",
+ searchPlaceholder: "Search chats...",
+ emptyTitle: "Search for chats",
+ emptyDescription: "Start typing to search through your Beeper chats",
+ noResultsTitle: "No chats found",
+ noResultsDescription: "Try changing your search or ensure Beeper Desktop is running",
+ },
+ unreadChats: {
+ title: "Unread Chats",
+ description: "View all chats with unread messages",
+ navigationTitle: "Unread Chats",
+ searchPlaceholder: "Filter unread chats...",
+ emptyTitle: "No Unread Messages",
+ emptyDescription: "All caught up! You have no unread chats.",
+ errorTitle: "Failed to Load Chats",
+ errorDescription: "Could not connect to Beeper Desktop. Make sure it's running and try again.",
+ unreadCount: (count: number) => `${count} unread`,
+ totalCount: (count: number) => ` (${count} total)`,
+ },
+ sendMessage: {
+ title: "Send Message",
+ description: "Quickly send a message to any chat",
+ chatLabel: "Chat",
+ chatPlaceholder: "Select a chat",
+ messageLabel: "Message",
+ messagePlaceholder: "Type your message here...",
+ submitButton: "Send Message",
+ successMessage: "✓ Message sent successfully",
+ errorTitle: "Failed to Send Message",
+ missingInfoTitle: "Missing Information",
+ missingInfoMessage: "Please select a chat and enter a message",
+ },
+ focusApp: {
+ title: "Focus Beeper Desktop",
+ description: "Bring Beeper Desktop to the foreground",
+ successMessage: "Beeper Desktop focused",
+ errorMessage: "Failed to focus Beeper Desktop",
+ },
+ searchMessages: {
+ title: "Search Messages",
+ description: "Search through your Beeper messages",
+ searchPlaceholder: "Search messages...",
+ emptyTitle: "Search for messages",
+ emptyDescription: "Start typing to search through your Beeper messages",
+ noResultsTitle: "No messages found",
+ noResultsDescription: "Try changing your search or ensure Beeper Desktop is running",
+ },
+ },
+
+ // Common
+ common: {
+ unnamedChat: "Unnamed Chat",
+ unknownMessage: "Unknown Message",
+ unreadCount: (count: number) => `${count} unread`,
+ unknownError: "Unknown error occurred",
+ openInBeeper: "Open Chat in Beeper",
+ copyChatId: "Copy Chat ID",
+ copyMessageText: "Copy Message Text",
+ showDetails: "Show Details",
+ pinned: "Pinned",
+ muted: "Muted",
+ archived: "Archived",
+ yes: "Yes",
+ no: "No",
+ details: {
+ id: "ID",
+ accountId: "Account ID",
+ network: "Network",
+ type: "Type",
+ unreadCount: "Unread Count",
+ isPinned: "Pinned",
+ isMuted: "Muted",
+ isArchived: "Archived",
+ lastActivity: "Last Activity",
+ na: "N/A",
+ },
+ },
+};
+
+export type Translations = typeof en;
diff --git a/src/locales/index.ts b/src/locales/index.ts
new file mode 100644
index 0000000..dfce28e
--- /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 };
diff --git a/src/search-chats.tsx b/src/search-chats.tsx
index 965eab9..647107f 100644
--- a/src/search-chats.tsx
+++ b/src/search-chats.tsx
@@ -1,58 +1,77 @@
-import { useState } from "react";
-import { ActionPanel, Detail, List, Action, Icon } from "@raycast/api";
+import { List, Icon } from "@raycast/api";
import { withAccessToken } from "@raycast/utils";
-import { useBeeperDesktop, createBeeperOAuth } from "./api";
+import { useState } from "react";
+import { createBeeperOAuth, focusApp } from "./api";
+import { t } from "./locales";
+import { ChatListItem } from "./components/ChatListItem";
+import { useChatSearch } from "./hooks/useChatSearch";
+
+/**
+ * Returns raw avatar URL for 1:1 chats, undefined for groups.
+ * Note: The URL is not sanitized here - ChatListItem handles sanitization via safeAvatarPath.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function getAvatarUrl(chat: any): string | undefined {
+ // Only show avatar for 1:1 chats, not groups
+ if (chat.type !== "group" && chat.participants?.items && Array.isArray(chat.participants.items)) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const otherParticipant = chat.participants.items.find((p: any) => !p.isSelf);
+ return otherParticipant?.imgURL;
+ }
+ return undefined;
+}
+/**
+ * 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 } = useChatSearch(searchText);
return (
-
- {chats.map((chat) => (
-
-
- }
- />
-
- }
+
+ {searchText === "" ? (
+
- ))}
- {!isLoading && chats.length === 0 && (
+ ) : !isLoading && chats.length === 0 ? (
+ ) : (
+ chats.map((chat) => (
+ focusApp({ chatID: chat.id }),
+ }}
+ translations={translations}
+ accessories={[
+ ...(chat.unreadCount > 0 ? [{ text: translations.common.unreadCount(chat.unreadCount) }] : []),
+ ...(chat.isPinned ? [{ icon: Icon.Pin }] : []),
+ ...(chat.isMuted ? [{ icon: Icon.SpeakerOff }] : []),
+ ]}
+ showDetails={false}
+ />
+ ))
)}
);
diff --git a/src/search-messages.tsx b/src/search-messages.tsx
new file mode 100644
index 0000000..38cd848
--- /dev/null
+++ b/src/search-messages.tsx
@@ -0,0 +1,69 @@
+import { List, Icon, ActionPanel, Action, Image } from "@raycast/api";
+import { withAccessToken } from "@raycast/utils";
+import { useState } from "react";
+import { createBeeperOAuth, focusApp } from "./api";
+import { t } from "./locales";
+import { useMessageSearch } from "./hooks/useMessageSearch";
+import { getChatIcon } from "./utils/chatIcon";
+
+function SearchMessagesCommand() {
+ const translations = t();
+ const [searchText, setSearchText] = useState("");
+ const { data: messages = [], isLoading } = useMessageSearch(searchText);
+
+ return (
+
+ {searchText === "" ? (
+
+ ) : !isLoading && messages.length === 0 ? (
+
+ ) : (
+ messages.map((message) => (
+
+ {
+ const sortKey = message.sortKey != null ? String(message.sortKey) : undefined;
+ focusApp(
+ sortKey !== undefined
+ ? { chatID: message.chatID, messageSortKey: sortKey }
+ : { chatID: message.chatID }
+ );
+ }}
+ />
+
+
+ }
+ />
+ ))
+ )}
+
+ );
+}
+
+export default withAccessToken(createBeeperOAuth())(SearchMessagesCommand);
diff --git a/src/send-message.tsx b/src/send-message.tsx
new file mode 100644
index 0000000..29a100b
--- /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 : translations.common.unknownError,
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ return (
+
+ {chats.map((chat) => (
+
+ ))}
+
+
+
+ );
+}
+
+export default withAccessToken(createBeeperOAuth())(SendMessageForm);
diff --git a/src/unread-chats.tsx b/src/unread-chats.tsx
new file mode 100644
index 0000000..b91daf4
--- /dev/null
+++ b/src/unread-chats.tsx
@@ -0,0 +1,100 @@
+import { ActionPanel, Action, List, Icon, Image } from "@raycast/api";
+import { withAccessToken } from "@raycast/utils";
+import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api";
+import { t } from "./locales";
+import { getChatIcon } from "./utils/chatIcon";
+
+/**
+ * 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,
+ error,
+ } = useBeeperDesktop(async (client) => {
+ const allChats = [];
+ let cursor: string | null = null;
+ let hasMore = true;
+ const MAX_PAGES = 20; // Safety limit to prevent infinite loops
+ let pageCount = 0;
+
+ // Use API's native unreadOnly filter instead of client-side filtering
+ while (hasMore && pageCount < MAX_PAGES) {
+ const searchParams = cursor
+ ? { unreadOnly: true, limit: 50, cursor, direction: "older" as const }
+ : { unreadOnly: true, limit: 50 };
+
+ const page = await client.chats.search(searchParams);
+ allChats.push(...page.items);
+
+ cursor = page.oldestCursor;
+ hasMore = page.hasMore;
+ pageCount++;
+ }
+
+ // 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) : ""}`}
+ >
+ {error ? (
+
+ ) : !isLoading && chats.length === 0 ? (
+
+ ) : (
+ chats.map((chat) => (
+
+ focusApp({ chatID: chat.id })}
+ />
+
+
+ }
+ />
+ ))
+ )}
+
+ );
+}
+
+export default withAccessToken(createBeeperOAuth())(UnreadChatsCommand);
diff --git a/src/utils/avatar.ts b/src/utils/avatar.ts
new file mode 100644
index 0000000..ad323e2
--- /dev/null
+++ b/src/utils/avatar.ts
@@ -0,0 +1,98 @@
+import { homedir } from "os";
+import { resolve, normalize, sep } from "path";
+
+/**
+ * Returns the platform-specific allowed base directories for avatar files.
+ * Supports macOS, Windows, and Linux.
+ */
+function getAllowedAvatarBases(): string[] {
+ const home = homedir();
+ const platform = process.platform;
+
+ switch (platform) {
+ case "darwin":
+ // macOS: ~/Library/Application Support/BeeperTexts/media
+ return [normalize(resolve(home, "Library/Application Support/BeeperTexts/media"))];
+
+ case "win32":
+ // Windows: %APPDATA%/BeeperTexts/media (typically ~/AppData/Roaming/BeeperTexts/media)
+ return [normalize(resolve(home, "AppData", "Roaming", "BeeperTexts", "media"))];
+
+ case "linux":
+ default: {
+ // Linux: $XDG_DATA_HOME/BeeperTexts/media or ~/.local/share/BeeperTexts/media
+ const bases: string[] = [];
+ const xdgDataHome = process.env.XDG_DATA_HOME;
+ if (xdgDataHome) {
+ bases.push(normalize(resolve(xdgDataHome, "BeeperTexts", "media")));
+ }
+ // Always include the fallback path
+ bases.push(normalize(resolve(home, ".local", "share", "BeeperTexts", "media")));
+ return bases;
+ }
+ }
+}
+
+// Allowed base directories for avatar files (platform-specific)
+const ALLOWED_AVATAR_BASES = getAllowedAvatarBases();
+
+/**
+ * Checks if a normalized path is within any of the allowed avatar directories.
+ * Prevents prefix attacks by requiring exact match or path separator after base.
+ */
+function isPathAllowed(normalizedPath: string): boolean {
+ return ALLOWED_AVATAR_BASES.some((base) => {
+ const isExactMatch = normalizedPath === base;
+ const isSubpath = normalizedPath.startsWith(base + sep);
+ return isExactMatch || isSubpath;
+ });
+}
+
+/**
+ * Safely converts a file:// URL to a validated filesystem path.
+ * Returns undefined if the URL is invalid or points outside the allowed directory.
+ *
+ * Security measures:
+ * - Only accepts file:// URLs, rejects all other schemes
+ * - Wraps decodeURIComponent in try/catch for malformed URIs
+ * - Normalizes and resolves path to prevent path traversal
+ * - Validates path is exactly the allowed directory or a true subpath (prevents prefix attacks)
+ * - Rejects paths with null bytes
+ */
+export function safeAvatarPath(url: string): string | undefined {
+ try {
+ // Only accept file:// URLs - explicitly reject other schemes
+ if (!url.startsWith("file://")) {
+ return undefined;
+ }
+
+ // Strip the file:// prefix
+ let avatarPath = url.slice(7); // "file://".length === 7
+
+ // Decode URL encoding safely
+ try {
+ avatarPath = decodeURIComponent(avatarPath);
+ } catch {
+ // URIError: malformed URI - return undefined to fall back to network icon
+ return undefined;
+ }
+
+ // Reject paths with null bytes (could bypass string checks)
+ if (avatarPath.includes("\0")) {
+ return undefined;
+ }
+
+ // Normalize and resolve the path to prevent path traversal
+ const normalizedPath = normalize(resolve(avatarPath));
+
+ // Validate that the path is within one of the allowed avatar directories
+ if (!isPathAllowed(normalizedPath)) {
+ return undefined;
+ }
+
+ return normalizedPath;
+ } catch {
+ // Any unexpected error - fall back to network icon
+ return undefined;
+ }
+}
diff --git a/src/utils/chatIcon.ts b/src/utils/chatIcon.ts
new file mode 100644
index 0000000..0fb0b3e
--- /dev/null
+++ b/src/utils/chatIcon.ts
@@ -0,0 +1,24 @@
+import { Image, Icon } from "@raycast/api";
+import { safeAvatarPath } from "./avatar";
+import { getNetworkIcon } from "./networkIcons";
+
+/**
+ * Returns chat icon - contact avatar for DMs, network icon for groups.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function getChatIcon(chat: any): Image.ImageLike {
+ if (!chat) return Icon.Bubble;
+
+ // For 1:1 chats, try to get the other person's avatar
+ if (chat.type !== "group" && chat.participants?.items && Array.isArray(chat.participants.items)) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const otherParticipant = chat.participants.items.find((p: any) => !p.isSelf);
+ if (otherParticipant?.imgURL) {
+ const validatedPath = safeAvatarPath(otherParticipant.imgURL);
+ if (validatedPath) {
+ return { source: validatedPath, mask: Image.Mask.Circle };
+ }
+ }
+ }
+ return getNetworkIcon(chat.network);
+}
diff --git a/src/utils/networkIcons.ts b/src/utils/networkIcons.ts
new file mode 100644
index 0000000..0bbbe18
--- /dev/null
+++ b/src/utils/networkIcons.ts
@@ -0,0 +1,40 @@
+import { Icon, Image } from "@raycast/api";
+
+/**
+ * Maps network identifiers to their corresponding icon asset filenames.
+ * These filenames are relative to the extension's assets directory.
+ */
+const NETWORK_ICON_MAP: 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",
+};
+
+/**
+ * Returns the icon for a messaging network.
+ * Falls back to a generic message icon if the network is not recognized.
+ *
+ * @param network - The network name (case-insensitive, spaces/slashes/dashes are ignored)
+ * @returns The corresponding network icon image
+ */
+export function getNetworkIcon(network: string): Image.ImageLike {
+ const networkLower = network.toLowerCase().replace(/[/\s-]/g, "");
+ const iconFilename = NETWORK_ICON_MAP[networkLower];
+
+ if (iconFilename) {
+ // Return an Image.Source object pointing to the asset file
+ return { source: iconFilename };
+ }
+
+ return Icon.Message;
+}