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 @@ + + Discord + + \ 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 @@ + + Gmail + + \ 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 @@ + + Facebook + + \ 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 @@ + + iMessage + + \ 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 @@ + + Instagram + + \ 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 @@ + + Messenger + + \ 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 @@ + + Signal + + \ 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 @@ + + Slack + + + + + \ 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 @@ + + Telegram + + \ 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 @@ + + X + + \ 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 @@ + + WhatsApp + + \ 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 = ` + ${icon.title} + +`; + + 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; +}