From bf45127a42e6061a1d9ad62bff16842983d65c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Lorenc?= Date: Fri, 28 Nov 2025 10:42:21 +0100 Subject: [PATCH 01/14] Update function --- assets/discord.svg | 4 ++ assets/email.svg | 4 ++ assets/facebook.svg | 4 ++ assets/google-messages.svg | 9 +++ assets/imessage.svg | 4 ++ assets/instagram.svg | 4 ++ assets/messenger.svg | 4 ++ assets/signal.svg | 4 ++ assets/slack.svg | 15 +++++ assets/telegram.svg | 4 ++ assets/twitter.svg | 4 ++ assets/whatsapp.svg | 4 ++ package-lock.json | 29 +++++++++ package.json | 25 +++++++- scripts/generate-icons.js | 54 +++++++++++++++++ src/api.ts | 18 +++--- src/list-accounts.tsx | 12 ++-- src/list-chats.tsx | 117 +++++++++++++++++++++--------------- src/locales/cs.ts | 84 ++++++++++++++++++++++++++ src/locales/en.ts | 84 ++++++++++++++++++++++++++ src/locales/index.ts | 45 ++++++++++++++ src/search-chats.tsx | 119 +++++++++++++++++++++++-------------- src/send-message.tsx | 100 +++++++++++++++++++++++++++++++ src/unread-chats.tsx | 91 ++++++++++++++++++++++++++++ 24 files changed, 731 insertions(+), 111 deletions(-) create mode 100644 assets/discord.svg create mode 100644 assets/email.svg create mode 100644 assets/facebook.svg create mode 100644 assets/google-messages.svg create mode 100644 assets/imessage.svg create mode 100644 assets/instagram.svg create mode 100644 assets/messenger.svg create mode 100644 assets/signal.svg create mode 100644 assets/slack.svg create mode 100644 assets/telegram.svg create mode 100644 assets/twitter.svg create mode 100644 assets/whatsapp.svg create mode 100644 scripts/generate-icons.js create mode 100644 src/locales/cs.ts create mode 100644 src/locales/en.ts create mode 100644 src/locales/index.ts create mode 100644 src/send-message.tsx create mode 100644 src/unread-chats.tsx 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..ed03f98 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,27 @@ "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" } ], @@ -47,6 +65,7 @@ "@types/react": "19.0.10", "eslint": "^9.22.0", "prettier": "^3.5.3", + "simple-icons": "^15.22.0", "typescript": "^5.8.2" }, "scripts": { 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..a6793cf 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; @@ -63,17 +64,20 @@ export function getBeeperDesktop(): BeeperDesktop { return clientInstance; } -export function useBeeperDesktop(fn: (client: BeeperDesktop) => Promise) { - return usePromise(async () => fn(getBeeperDesktop())); +export function useBeeperDesktop(fn: (client: BeeperDesktop) => Promise, deps?: React.DependencyList) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - usePromise type overload issue with optional deps + return usePromise(async () => fn(getBeeperDesktop()), deps); } -export const focusApp = async (params: AppFocusParams = {}) => { +export const focusApp = async (params: AppOpenParams = {}) => { + const translations = t(); try { - await getBeeperDesktop().app.focus(params); + await getBeeperDesktop().app.open(params); await closeMainWindow(); - await showHUD("Beeper Desktop focused"); + await showHUD(translations.commands.focusApp.successMessage); } catch (error) { console.error("Failed to focus Beeper Desktop:", error); - await showHUD("Failed to focus Beeper Desktop"); + await showHUD(translations.commands.focusApp.errorMessage); } }; diff --git a/src/list-accounts.tsx b/src/list-accounts.tsx index 70adbec..1e99da9 100644 --- a/src/list-accounts.tsx +++ b/src/list-accounts.tsx @@ -2,8 +2,10 @@ import { ActionPanel, Detail, List, Action, Icon, Keyboard, Color } from "@rayca import { useCachedState, withAccessToken } from "@raycast/utils"; import type { BeeperDesktop } from "@beeper/desktop-api"; import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; +import { t } from "./locales"; function ListAccountsCommand() { + const translations = t(); const [isShowingDetail, setIsShowingDetail] = useCachedState("list-accounts:isShowingDetail", false); const { data: accounts, @@ -21,7 +23,7 @@ function ListAccountsCommand() { )} diff --git a/src/list-chats.tsx b/src/list-chats.tsx index 76dd7d2..2b54c50 100644 --- a/src/list-chats.tsx +++ b/src/list-chats.tsx @@ -1,74 +1,93 @@ -import { ActionPanel, Detail, List, Action, Icon } from "@raycast/api"; +import { useState } from "react"; +import { ActionPanel, Detail, List, Action, Icon, Image, Color } from "@raycast/api"; import { withAccessToken } from "@raycast/utils"; import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; +import { t } from "./locales"; + +function getNetworkIcon(network: string): Image.ImageLike { + const networkLower = network.toLowerCase().replace(/[\/\s-]/g, ""); + + const iconMap: Record = { + slack: "slack.svg", + whatsapp: "whatsapp.svg", + telegram: "telegram.svg", + discord: "discord.svg", + instagram: "instagram.svg", + facebook: "facebook.svg", + facebookmessenger: "messenger.svg", + messenger: "messenger.svg", + signal: "signal.svg", + imessage: "imessage.svg", + twitter: "twitter.svg", + email: "email.svg", + googlemessages: "google-messages.svg", + }; + + return iconMap[networkLower] || Icon.Message; +} function ListChatsCommand() { - const { - data: chats, - isLoading, - revalidate, - } = useBeeperDesktop(async (client) => { - const result = await client.chats.search(); - console.log("Fetched accounts:", JSON.stringify(result, null, 2)); - return result.data; - }); + const translations = t(); + const [searchText, setSearchText] = useState(""); + const { data: chats = [], isLoading } = useBeeperDesktop( + async (client) => { + const allChats = []; + const searchParams = searchText ? { query: searchText } : {}; + for await (const chat of client.chats.search(searchParams)) { + allChats.push(chat); + } + return allChats; + }, + [searchText] + ); return ( - - {(chats || []).map((chat) => ( + + {chats.map((chat) => ( 0 ? [{ text: `${chat.unreadCount} unread` }] : []), - ...(chat.isPinned ? [{ icon: Icon.Pin }] : []), - ...(chat.isMuted ? [{ icon: Icon.SpeakerOff }] : []), - ...(chat.isArchived ? [{ icon: Icon.AddPerson }] : []), - ]} + accessories={[{ text: chat.type }, chat.lastActivity ? { date: new Date(chat.lastActivity) } : {}].filter( + Boolean, + )} actions={ + focusApp({ chatID: chat.id })} + /> } /> - revalidate()} - /> - focusApp()} - /> + } /> ))} - {!isLoading && (!chats || chats.length === 0) && ( + {!isLoading && chats.length === 0 && ( )} diff --git a/src/locales/cs.ts b/src/locales/cs.ts new file mode 100644 index 0000000..aa09753 --- /dev/null +++ b/src/locales/cs.ts @@ -0,0 +1,84 @@ +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ěží", + }, + 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.", + 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", + }, + }, + + // Common + common: { + unnamedChat: "Nepojmenovaný chat", + openInBeeper: "Otevřít chat v Beeper", + copyChatId: "Kopírovat ID chatu", + 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..bb5bea4 --- /dev/null +++ b/src/locales/en.ts @@ -0,0 +1,84 @@ +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", + }, + 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.", + 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", + }, + }, + + // Common + common: { + unnamedChat: "Unnamed Chat", + openInBeeper: "Open Chat in Beeper", + copyChatId: "Copy Chat ID", + 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..e836f1f --- /dev/null +++ b/src/locales/index.ts @@ -0,0 +1,45 @@ +import { environment } from "@raycast/api"; +import { en, Translations } from "./en"; +import { cs } from "./cs"; + +const translations: Record = { + en, + cs, +}; + +function getLocale(): string { + // Try to get locale from Raycast environment + // Note: environment.locale may not be available in all Raycast versions + const raycastLocale = (environment as any).locale as string | undefined; + + // If locale is not available, default to English + if (!raycastLocale) { + return "en"; + } + + // Extract language code (e.g., "cs-CZ" -> "cs", "en_US" -> "en") + const languageCode = raycastLocale.split(/[-_]/)[0].toLowerCase(); + + // Return language code if we have translations for it, otherwise default to English + return translations[languageCode] ? languageCode : "en"; +} + +let currentLocale = getLocale(); +let currentTranslations = translations[currentLocale]; + +export function t(): Translations { + return currentTranslations; +} + +export function setLocale(locale: string): void { + if (translations[locale]) { + currentLocale = locale; + currentTranslations = translations[locale]; + } +} + +export function getCurrentLocale(): string { + return currentLocale; +} + +export type { Translations }; diff --git a/src/search-chats.tsx b/src/search-chats.tsx index 965eab9..ffc9c25 100644 --- a/src/search-chats.tsx +++ b/src/search-chats.tsx @@ -1,57 +1,86 @@ -import { useState } from "react"; -import { ActionPanel, Detail, List, Action, Icon } from "@raycast/api"; +import { ActionPanel, Action, List, Icon, Image, Color } from "@raycast/api"; import { withAccessToken } from "@raycast/utils"; -import { useBeeperDesktop, createBeeperOAuth } from "./api"; +import { useState } from "react"; +import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; +import { t } from "./locales"; + +function getNetworkIcon(network: string): Image.ImageLike { + const networkLower = network.toLowerCase().replace(/[\/\s-]/g, ""); + + const iconMap: Record = { + slack: "slack.svg", + whatsapp: "whatsapp.svg", + telegram: "telegram.svg", + discord: "discord.svg", + instagram: "instagram.svg", + facebook: "facebook.svg", + facebookmessenger: "messenger.svg", + messenger: "messenger.svg", + signal: "signal.svg", + imessage: "imessage.svg", + twitter: "twitter.svg", + email: "email.svg", + googlemessages: "google-messages.svg", + }; + + return iconMap[networkLower] || Icon.Message; +} function SearchChatsCommand() { + const translations = t(); const [searchText, setSearchText] = useState(""); - const { data: chats = [], isLoading } = useBeeperDesktop(async (client) => { - const result = await client.chats.search({ query: searchText }); - return result.data; - }); + const { data: chats = [], isLoading } = useBeeperDesktop( + async (client) => { + if (!searchText) { + return []; + } + const allChats = []; + for await (const chat of client.chats.search({ query: searchText })) { + allChats.push(chat); + } + return allChats; + }, + [searchText] + ); return ( - - {chats.map((chat) => ( - - - } - /> - - } + + {searchText === "" ? ( + - ))} - {!isLoading && chats.length === 0 && ( + ) : ( + chats.map((chat) => ( + 0 ? [{ text: translations.commands.unreadChats.unreadCount(chat.unreadCount) }] : []), + ...(chat.isPinned ? [{ icon: Icon.Pin }] : []), + ...(chat.isMuted ? [{ icon: Icon.SpeakerOff }] : []), + ]} + actions={ + + focusApp({ chatID: chat.id })} + /> + + + } + /> + )) + )} + {searchText !== "" && !isLoading && chats.length === 0 && ( )} diff --git a/src/send-message.tsx b/src/send-message.tsx new file mode 100644 index 0000000..cbb4ed6 --- /dev/null +++ b/src/send-message.tsx @@ -0,0 +1,100 @@ +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; +} + +function SendMessageForm() { + const translations = t(); + const [isLoading, setIsLoading] = useState(false); + const { data: chats = [], isLoading: chatsLoading } = useBeeperDesktop( + async (client) => { + const allChats = []; + for await (const chat of client.chats.search({})) { + allChats.push(chat); + } + // Sort by last activity (most recent first) + return allChats.sort((a, b) => { + const aTime = a.lastActivity ? new Date(a.lastActivity).getTime() : 0; + const bTime = b.lastActivity ? new Date(b.lastActivity).getTime() : 0; + return bTime - aTime; + }); + }, + [] + ); + + async function handleSubmit(values: SendMessageFormValues) { + if (!values.chatId || !values.message) { + await showToast({ + style: Toast.Style.Failure, + title: translations.commands.sendMessage.missingInfoTitle, + message: translations.commands.sendMessage.missingInfoMessage, + }); + return; + } + + setIsLoading(true); + try { + const client = getBeeperDesktop(); + await client.messages.send({ + chatID: values.chatId, + text: values.message, + }); + + await closeMainWindow(); + await showHUD(translations.commands.sendMessage.successMessage); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: translations.commands.sendMessage.errorTitle, + message: error instanceof Error ? error.message : "Unknown error occurred", + }); + } finally { + setIsLoading(false); + } + } + + return ( +
+ + + } + > + + {chats.map((chat) => ( + + ))} + + + + ); +} + +export default withAccessToken(createBeeperOAuth())(SendMessageForm); diff --git a/src/unread-chats.tsx b/src/unread-chats.tsx new file mode 100644 index 0000000..5e2d76b --- /dev/null +++ b/src/unread-chats.tsx @@ -0,0 +1,91 @@ +import { ActionPanel, Action, List, Icon, Image } from "@raycast/api"; +import { withAccessToken } from "@raycast/utils"; +import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; +import { t } from "./locales"; + +function getNetworkIcon(network: string): Image.ImageLike { + const networkLower = network.toLowerCase().replace(/[\/\s-]/g, ""); + + const iconMap: Record = { + slack: "slack.svg", + whatsapp: "whatsapp.svg", + telegram: "telegram.svg", + discord: "discord.svg", + instagram: "instagram.svg", + facebook: "facebook.svg", + facebookmessenger: "messenger.svg", + messenger: "messenger.svg", + signal: "signal.svg", + imessage: "imessage.svg", + twitter: "twitter.svg", + email: "email.svg", + googlemessages: "google-messages.svg", + }; + + return iconMap[networkLower] || Icon.Message; +} + +function UnreadChatsCommand() { + const translations = t(); + const { data: chats = [], isLoading } = useBeeperDesktop( + async (client) => { + const allChats = []; + for await (const chat of client.chats.search({})) { + // Filter only chats with unread messages + if (chat.unreadCount > 0) { + allChats.push(chat); + } + } + // Sort by unread count (highest first) + return allChats.sort((a, b) => b.unreadCount - a.unreadCount); + }, + [] + ); + + const totalUnread = chats.reduce((sum, chat) => sum + chat.unreadCount, 0); + + return ( + 0 ? translations.commands.unreadChats.totalCount(totalUnread) : ""}`} + > + {chats.map((chat) => ( + + focusApp({ chatID: chat.id })} + /> + + + } + /> + ))} + {!isLoading && chats.length === 0 && ( + + )} + + ); +} + +export default withAccessToken(createBeeperOAuth())(UnreadChatsCommand); From 70da2960e03031ef660a9e45a43579c4b6464cdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Lorenc?= Date: Fri, 28 Nov 2025 10:43:43 +0100 Subject: [PATCH 02/14] Remove FindChatCommand component and its associated logic --- src/find-chat.tsx | 49 ----------------------------------------------- 1 file changed, 49 deletions(-) delete mode 100644 src/find-chat.tsx 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); From 65c2f6b06b85c96e42a1e781373303a9322fefa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Lorenc?= Date: Fri, 28 Nov 2025 11:07:48 +0100 Subject: [PATCH 03/14] Add dropdown laguage select box --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 12 ++++++++++++ src/list-chats.tsx | 15 ++++++++++----- src/locales/cs.ts | 2 +- src/locales/en.ts | 2 +- src/locales/index.ts | 18 ++++++++--------- src/search-chats.tsx | 19 ++++++++++++------ src/send-message.tsx | 27 ++++++++++++-------------- src/unread-chats.tsx | 35 +++++++++++++++------------------ 9 files changed, 120 insertions(+), 56 deletions(-) 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/package.json b/package.json index ed03f98..cf0a5db 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,18 @@ "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": { diff --git a/src/list-chats.tsx b/src/list-chats.tsx index 2b54c50..1982b39 100644 --- a/src/list-chats.tsx +++ b/src/list-chats.tsx @@ -1,12 +1,12 @@ import { useState } from "react"; -import { ActionPanel, Detail, List, Action, Icon, Image, Color } from "@raycast/api"; +import { ActionPanel, Detail, List, Action, Icon, Image } from "@raycast/api"; import { withAccessToken } from "@raycast/utils"; import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; import { t } from "./locales"; function getNetworkIcon(network: string): Image.ImageLike { - const networkLower = network.toLowerCase().replace(/[\/\s-]/g, ""); - + const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); + const iconMap: Record = { slack: "slack.svg", whatsapp: "whatsapp.svg", @@ -38,11 +38,16 @@ function ListChatsCommand() { } return allChats; }, - [searchText] + [searchText], ); return ( - + {chats.map((chat) => ( = { }; function getLocale(): string { - // Try to get locale from Raycast environment - // Note: environment.locale may not be available in all Raycast versions + // 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 locale is not available, default to English if (!raycastLocale) { return "en"; } - - // Extract language code (e.g., "cs-CZ" -> "cs", "en_US" -> "en") const languageCode = raycastLocale.split(/[-_]/)[0].toLowerCase(); - - // Return language code if we have translations for it, otherwise default to English return translations[languageCode] ? languageCode : "en"; } diff --git a/src/search-chats.tsx b/src/search-chats.tsx index ffc9c25..743e24b 100644 --- a/src/search-chats.tsx +++ b/src/search-chats.tsx @@ -1,12 +1,12 @@ -import { ActionPanel, Action, List, Icon, Image, Color } from "@raycast/api"; +import { ActionPanel, Action, List, Icon, Image } from "@raycast/api"; import { withAccessToken } from "@raycast/utils"; import { useState } from "react"; import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; import { t } from "./locales"; function getNetworkIcon(network: string): Image.ImageLike { - const networkLower = network.toLowerCase().replace(/[\/\s-]/g, ""); - + const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); + const iconMap: Record = { slack: "slack.svg", whatsapp: "whatsapp.svg", @@ -40,11 +40,16 @@ function SearchChatsCommand() { } return allChats; }, - [searchText] + [searchText], ); return ( - + {searchText === "" ? ( 0 ? [{ text: translations.commands.unreadChats.unreadCount(chat.unreadCount) }] : []), + ...(chat.unreadCount > 0 + ? [{ text: translations.commands.unreadChats.unreadCount(chat.unreadCount) }] + : []), ...(chat.isPinned ? [{ icon: Icon.Pin }] : []), ...(chat.isMuted ? [{ icon: Icon.SpeakerOff }] : []), ]} diff --git a/src/send-message.tsx b/src/send-message.tsx index cbb4ed6..717a11c 100644 --- a/src/send-message.tsx +++ b/src/send-message.tsx @@ -12,21 +12,18 @@ interface SendMessageFormValues { 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; - }); - }, - [] - ); + 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) { diff --git a/src/unread-chats.tsx b/src/unread-chats.tsx index 5e2d76b..218072c 100644 --- a/src/unread-chats.tsx +++ b/src/unread-chats.tsx @@ -4,8 +4,8 @@ import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; import { t } from "./locales"; function getNetworkIcon(network: string): Image.ImageLike { - const networkLower = network.toLowerCase().replace(/[\/\s-]/g, ""); - + const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); + const iconMap: Record = { slack: "slack.svg", whatsapp: "whatsapp.svg", @@ -27,26 +27,23 @@ function getNetworkIcon(network: string): Image.ImageLike { function UnreadChatsCommand() { const translations = t(); - const { data: chats = [], isLoading } = useBeeperDesktop( - async (client) => { - const allChats = []; - for await (const chat of client.chats.search({})) { - // Filter only chats with unread messages - if (chat.unreadCount > 0) { - allChats.push(chat); - } + const { data: chats = [], isLoading } = useBeeperDesktop(async (client) => { + const allChats = []; + for await (const chat of client.chats.search({})) { + // Filter only chats with unread messages + if (chat.unreadCount > 0) { + allChats.push(chat); } - // Sort by unread count (highest first) - return allChats.sort((a, b) => b.unreadCount - a.unreadCount); - }, - [] - ); + } + // 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) : ""}`} > @@ -57,9 +54,9 @@ function UnreadChatsCommand() { title={chat.title || translations.common.unnamedChat} subtitle={chat.network} accessories={[ - { + { text: translations.commands.unreadChats.unreadCount(chat.unreadCount), - icon: Icon.Bubble + icon: Icon.Bubble, }, ...(chat.isPinned ? [{ icon: Icon.Pin }] : []), ...(chat.isMuted ? [{ icon: Icon.SpeakerOff }] : []), From b507f31c22a1c8208b919b10ad93fcb6fc2081a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Lorenc?= Date: Fri, 28 Nov 2025 13:20:28 +0100 Subject: [PATCH 04/14] Update coderabbit --- src/api.ts | 14 +++++++++++++- src/list-accounts.tsx | 9 ++++++++- src/list-chats.tsx | 15 ++++++++++++++- src/locales/index.ts | 24 +++++++++++++++++++++++- src/search-chats.tsx | 17 ++++++++++++++++- src/send-message.tsx | 11 ++++++++++- src/unread-chats.tsx | 19 ++++++++++++++++++- 7 files changed, 102 insertions(+), 7 deletions(-) diff --git a/src/api.ts b/src/api.ts index a6793cf..03dd52a 100644 --- a/src/api.ts +++ b/src/api.ts @@ -47,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(); @@ -64,6 +69,13 @@ export function getBeeperDesktop(): BeeperDesktop { return clientInstance; } +/** + * Execute an asynchronous operation using the current BeeperDesktop client and return its managed result. + * + * @param fn - Function that receives the current BeeperDesktop client and returns a promise for the desired value + * @param deps - Optional React dependency list that controls when the operation is re-run + * @returns The value produced by `fn` when executed with the current BeeperDesktop client; loading and error state are managed by the hook + */ export function useBeeperDesktop(fn: (client: BeeperDesktop) => Promise, deps?: React.DependencyList) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - usePromise type overload issue with optional deps @@ -80,4 +92,4 @@ export const focusApp = async (params: AppOpenParams = {}) => { console.error("Failed to focus Beeper Desktop:", error); await showHUD(translations.commands.focusApp.errorMessage); } -}; +}; \ No newline at end of file diff --git a/src/list-accounts.tsx b/src/list-accounts.tsx index 1e99da9..09db651 100644 --- a/src/list-accounts.tsx +++ b/src/list-accounts.tsx @@ -4,6 +4,13 @@ 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); @@ -106,4 +113,4 @@ function ListAccountsCommand() { ); } -export default withAccessToken(createBeeperOAuth())(ListAccountsCommand); +export default withAccessToken(createBeeperOAuth())(ListAccountsCommand); \ No newline at end of file diff --git a/src/list-chats.tsx b/src/list-chats.tsx index 1982b39..e25565f 100644 --- a/src/list-chats.tsx +++ b/src/list-chats.tsx @@ -4,6 +4,12 @@ import { withAccessToken } from "@raycast/utils"; import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; import { t } from "./locales"; +/** + * Selects an icon image that represents a messaging network. + * + * @param network - The network name or identifier (case-insensitive; spaces, slashes, and dashes are ignored) + * @returns The corresponding network icon image, or a generic message icon if the network is not recognized + */ function getNetworkIcon(network: string): Image.ImageLike { const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); @@ -26,6 +32,13 @@ function getNetworkIcon(network: string): Image.ImageLike { return iconMap[networkLower] || Icon.Message; } +/** + * Render a searchable list of Beeper chats with actions to open the chat in Beeper, view details, and copy the chat ID. + * + * The list is populated from Beeper Desktop search results filtered by the search bar; each item shows network-specific icon, title (or a localized unnamed fallback), type, and last activity when available. + * + * @returns A Raycast List component populated with chat items and an empty state when no chats are found. + */ function ListChatsCommand() { const translations = t(); const [searchText, setSearchText] = useState(""); @@ -99,4 +112,4 @@ function ListChatsCommand() { ); } -export default withAccessToken(createBeeperOAuth())(ListChatsCommand); +export default withAccessToken(createBeeperOAuth())(ListChatsCommand); \ No newline at end of file diff --git a/src/locales/index.ts b/src/locales/index.ts index f1a2c9d..fb61272 100644 --- a/src/locales/index.ts +++ b/src/locales/index.ts @@ -7,6 +7,13 @@ const translations: Record = { 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(); @@ -27,10 +34,20 @@ function getLocale(): string { 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; @@ -38,8 +55,13 @@ export function setLocale(locale: string): void { } } +/** + * 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 }; +export type { Translations }; \ No newline at end of file diff --git a/src/search-chats.tsx b/src/search-chats.tsx index 743e24b..3ce4c37 100644 --- a/src/search-chats.tsx +++ b/src/search-chats.tsx @@ -4,6 +4,12 @@ import { useState } from "react"; import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; import { t } from "./locales"; +/** + * Selects an icon representing the given chat/network name. + * + * @param network - Network name or identifier (case-insensitive; may include spaces, slashes, or hyphens) + * @returns The matching local SVG asset for the network, or `Icon.Message` when no specific icon is available + */ function getNetworkIcon(network: string): Image.ImageLike { const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); @@ -26,6 +32,15 @@ function getNetworkIcon(network: string): Image.ImageLike { return iconMap[networkLower] || Icon.Message; } +/** + * Render a search interface that finds and displays Beeper chats matching the user's query. + * + * Shows an initial empty view when the search box is empty, performs a client-side search as the + * user types, and renders matching chats with network icons, unread/pinned/muted accessories, and + * actions to open the chat in Beeper or copy its ID. + * + * @returns The List JSX element presenting the search bar, results, and appropriate empty states. + */ function SearchChatsCommand() { const translations = t(); const [searchText, setSearchText] = useState(""); @@ -94,4 +109,4 @@ function SearchChatsCommand() { ); } -export default withAccessToken(createBeeperOAuth())(SearchChatsCommand); +export default withAccessToken(createBeeperOAuth())(SearchChatsCommand); \ No newline at end of file diff --git a/src/send-message.tsx b/src/send-message.tsx index 717a11c..a548e0b 100644 --- a/src/send-message.tsx +++ b/src/send-message.tsx @@ -9,6 +9,15 @@ interface SendMessageFormValues { 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); @@ -94,4 +103,4 @@ function SendMessageForm() { ); } -export default withAccessToken(createBeeperOAuth())(SendMessageForm); +export default withAccessToken(createBeeperOAuth())(SendMessageForm); \ No newline at end of file diff --git a/src/unread-chats.tsx b/src/unread-chats.tsx index 218072c..30ea430 100644 --- a/src/unread-chats.tsx +++ b/src/unread-chats.tsx @@ -3,6 +3,14 @@ import { withAccessToken } from "@raycast/utils"; import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; import { t } from "./locales"; +/** + * Selects an icon representing a messaging network. + * + * The provided network name is normalized by lowercasing and removing spaces, slashes, and dashes before lookup. + * + * @param network - Network name (e.g., "Slack", "facebook-messenger", "Google Messages") + * @returns The SVG filename for a known network (e.g., `"slack.svg"`, `"messenger.svg"`) or `Icon.Message` when no match exists + */ function getNetworkIcon(network: string): Image.ImageLike { const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); @@ -25,6 +33,15 @@ function getNetworkIcon(network: string): Image.ImageLike { return iconMap[networkLower] || Icon.Message; } +/** + * Render a Raycast list of Beeper chats that currently have unread messages. + * + * Displays unread chats sorted by unread count (highest first). Each list item shows the chat icon, title, + * network, unread count, pin/mute indicators, and last activity date when available. Actions are provided to + * open the chat in Beeper and to copy the chat ID. An empty view is shown when there are no unread chats. + * + * @returns A Raycast `List` element containing unread chat items with accessories and actions + */ function UnreadChatsCommand() { const translations = t(); const { data: chats = [], isLoading } = useBeeperDesktop(async (client) => { @@ -85,4 +102,4 @@ function UnreadChatsCommand() { ); } -export default withAccessToken(createBeeperOAuth())(UnreadChatsCommand); +export default withAccessToken(createBeeperOAuth())(UnreadChatsCommand); \ No newline at end of file From 9714e2de8e93321cf5dc942e522ba548204b7499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Lorenc?= Date: Fri, 28 Nov 2025 13:36:21 +0100 Subject: [PATCH 05/14] Fix more CodeRabbit issues --- src/list-chats.tsx | 38 ++++++-------------------------------- src/locales/cs.ts | 1 + src/locales/en.ts | 1 + src/network-icons.ts | 29 +++++++++++++++++++++++++++++ src/search-chats.tsx | 29 +---------------------------- src/send-message.tsx | 2 +- 6 files changed, 39 insertions(+), 61 deletions(-) create mode 100644 src/network-icons.ts diff --git a/src/list-chats.tsx b/src/list-chats.tsx index e25565f..49eb6c5 100644 --- a/src/list-chats.tsx +++ b/src/list-chats.tsx @@ -3,34 +3,7 @@ import { ActionPanel, Detail, List, Action, Icon, Image } from "@raycast/api"; import { withAccessToken } from "@raycast/utils"; import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; import { t } from "./locales"; - -/** - * Selects an icon image that represents a messaging network. - * - * @param network - The network name or identifier (case-insensitive; spaces, slashes, and dashes are ignored) - * @returns The corresponding network icon image, or a generic message icon if the network is not recognized - */ -function getNetworkIcon(network: string): Image.ImageLike { - const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); - - const iconMap: Record = { - slack: "slack.svg", - whatsapp: "whatsapp.svg", - telegram: "telegram.svg", - discord: "discord.svg", - instagram: "instagram.svg", - facebook: "facebook.svg", - facebookmessenger: "messenger.svg", - messenger: "messenger.svg", - signal: "signal.svg", - imessage: "imessage.svg", - twitter: "twitter.svg", - email: "email.svg", - googlemessages: "google-messages.svg", - }; - - return iconMap[networkLower] || Icon.Message; -} +import { getNetworkIcon } from "./network-icons"; /** * Render a searchable list of Beeper chats with actions to open the chat in Beeper, view details, and copy the chat ID. @@ -67,9 +40,10 @@ function ListChatsCommand() { icon={getNetworkIcon(chat.network)} title={chat.title || translations.common.unnamedChat} subtitle={chat.network} - accessories={[{ text: chat.type }, chat.lastActivity ? { date: new Date(chat.lastActivity) } : {}].filter( - Boolean, - )} + accessories={[ + { text: chat.type }, + ...(chat.lastActivity ? [{ date: new Date(chat.lastActivity) }] : []), + ]} actions={ = { + slack: "slack.svg", + whatsapp: "whatsapp.svg", + telegram: "telegram.svg", + discord: "discord.svg", + instagram: "instagram.svg", + facebook: "facebook.svg", + facebookmessenger: "messenger.svg", + messenger: "messenger.svg", + signal: "signal.svg", + imessage: "imessage.svg", + twitter: "twitter.svg", + email: "email.svg", + googlemessages: "google-messages.svg", + }; + + return iconMap[networkLower] || Icon.Message; +} diff --git a/src/search-chats.tsx b/src/search-chats.tsx index 3ce4c37..e1d127d 100644 --- a/src/search-chats.tsx +++ b/src/search-chats.tsx @@ -3,34 +3,7 @@ import { withAccessToken } from "@raycast/utils"; import { useState } from "react"; import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; import { t } from "./locales"; - -/** - * Selects an icon representing the given chat/network name. - * - * @param network - Network name or identifier (case-insensitive; may include spaces, slashes, or hyphens) - * @returns The matching local SVG asset for the network, or `Icon.Message` when no specific icon is available - */ -function getNetworkIcon(network: string): Image.ImageLike { - const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); - - const iconMap: Record = { - slack: "slack.svg", - whatsapp: "whatsapp.svg", - telegram: "telegram.svg", - discord: "discord.svg", - instagram: "instagram.svg", - facebook: "facebook.svg", - facebookmessenger: "messenger.svg", - messenger: "messenger.svg", - signal: "signal.svg", - imessage: "imessage.svg", - twitter: "twitter.svg", - email: "email.svg", - googlemessages: "google-messages.svg", - }; - - return iconMap[networkLower] || Icon.Message; -} +import { getNetworkIcon } from "./network-icons"; /** * Render a search interface that finds and displays Beeper chats matching the user's query. diff --git a/src/send-message.tsx b/src/send-message.tsx index a548e0b..66d1c69 100644 --- a/src/send-message.tsx +++ b/src/send-message.tsx @@ -58,7 +58,7 @@ function SendMessageForm() { await showToast({ style: Toast.Style.Failure, title: translations.commands.sendMessage.errorTitle, - message: error instanceof Error ? error.message : "Unknown error occurred", + message: error instanceof Error ? error.message : translations.common.unknownError, }); } finally { setIsLoading(false); From e3071eb127b6d1828db34dd3f3ddf8110fefb78b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Lorenc?= Date: Fri, 28 Nov 2025 13:52:16 +0100 Subject: [PATCH 06/14] Nitpick comments fix --- src/list-chats.tsx | 2 +- src/locales/cs.ts | 2 +- src/locales/en.ts | 2 +- src/network-icons.ts | 35 +++++++++--------- src/search-chats.tsx | 85 +++++++++++++++++++++++--------------------- 5 files changed, 64 insertions(+), 62 deletions(-) diff --git a/src/list-chats.tsx b/src/list-chats.tsx index 49eb6c5..d4bd4f9 100644 --- a/src/list-chats.tsx +++ b/src/list-chats.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { ActionPanel, Detail, List, Action, Icon, Image } from "@raycast/api"; +import { ActionPanel, Detail, List, Action, Icon} from "@raycast/api"; import { withAccessToken } from "@raycast/utils"; import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; import { t } from "./locales"; diff --git a/src/locales/cs.ts b/src/locales/cs.ts index 06d3d7a..4eddb37 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -60,7 +60,7 @@ export const cs: Translations = { // Common common: { unnamedChat: "Nepojmenovaný chat", - unknownError: "Došlo k neznámé chybě", + unknownError: "Došlo k neznámé chybě", openInBeeper: "Otevřít chat v Beeper", copyChatId: "Kopírovat ID chatu", showDetails: "Zobrazit detaily", diff --git a/src/locales/en.ts b/src/locales/en.ts index 0e37377..a47fab3 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -58,7 +58,7 @@ export const en = { // Common common: { unnamedChat: "Unnamed Chat", - unknownError: "Unknown error occurred", + unknownError: "Unknown error occurred", openInBeeper: "Open Chat in Beeper", copyChatId: "Copy Chat ID", showDetails: "Show Details", diff --git a/src/network-icons.ts b/src/network-icons.ts index 6d342b8..1448212 100644 --- a/src/network-icons.ts +++ b/src/network-icons.ts @@ -1,5 +1,21 @@ import { Icon, Image } from "@raycast/api"; +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", +}; + /** * Selects an icon image that represents a messaging network. * @@ -8,22 +24,5 @@ import { Icon, Image } from "@raycast/api"; */ export function getNetworkIcon(network: string): Image.ImageLike { const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); - - const iconMap: Record = { - slack: "slack.svg", - whatsapp: "whatsapp.svg", - telegram: "telegram.svg", - discord: "discord.svg", - instagram: "instagram.svg", - facebook: "facebook.svg", - facebookmessenger: "messenger.svg", - messenger: "messenger.svg", - signal: "signal.svg", - imessage: "imessage.svg", - twitter: "twitter.svg", - email: "email.svg", - googlemessages: "google-messages.svg", - }; - - return iconMap[networkLower] || Icon.Message; + return NETWORK_ICON_MAP[networkLower] || Icon.Message; } diff --git a/src/search-chats.tsx b/src/search-chats.tsx index e1d127d..b578761 100644 --- a/src/search-chats.tsx +++ b/src/search-chats.tsx @@ -1,4 +1,4 @@ -import { ActionPanel, Action, List, Icon, Image } from "@raycast/api"; +import { ActionPanel, Action, List, Icon} from "@raycast/api"; import { withAccessToken } from "@raycast/utils"; import { useState } from "react"; import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; @@ -38,46 +38,49 @@ function SearchChatsCommand() { onSearchTextChange={setSearchText} throttle > - {searchText === "" ? ( - - ) : ( - chats.map((chat) => ( - 0 - ? [{ text: translations.commands.unreadChats.unreadCount(chat.unreadCount) }] - : []), - ...(chat.isPinned ? [{ icon: Icon.Pin }] : []), - ...(chat.isMuted ? [{ icon: Icon.SpeakerOff }] : []), - ]} - actions={ - - focusApp({ chatID: chat.id })} - /> - - - } - /> - )) - )} - {searchText !== "" && !isLoading && chats.length === 0 && ( - - )} + {searchText === "" + ? ( + + ) + : !isLoading && chats.length === 0 + ? ( + + ) + : ( + chats.map((chat) => ( + 0 + ? [{ text: translations.commands.unreadChats.unreadCount(chat.unreadCount) }] + : []), + ...(chat.isPinned ? [{ icon: Icon.Pin }] : []), + ...(chat.isMuted ? [{ icon: Icon.SpeakerOff }] : []), + ]} + actions={ + + focusApp({ chatID: chat.id })} + /> + + + } + /> + )) + )} ); } From c118e7fe52c81526654022f6d1b398ca95d3379c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Lorenc?= Date: Fri, 28 Nov 2025 14:03:27 +0100 Subject: [PATCH 07/14] Add unread count translations and refactor chat search functionality --- src/components/ChatListItem.tsx | 38 +++++++++++++++++++++++++++++ src/hooks/useChatSearch.ts | 16 ++++++++++++ src/locales/cs.ts | 1 + src/locales/en.ts | 1 + src/search-chats.tsx | 43 +++++++++------------------------ 5 files changed, 68 insertions(+), 31 deletions(-) create mode 100644 src/components/ChatListItem.tsx create mode 100644 src/hooks/useChatSearch.ts diff --git a/src/components/ChatListItem.tsx b/src/components/ChatListItem.tsx new file mode 100644 index 0000000..320fc09 --- /dev/null +++ b/src/components/ChatListItem.tsx @@ -0,0 +1,38 @@ +import { List, ActionPanel, Action, Icon } from "@raycast/api"; +import { getNetworkIcon } from "../network-icons"; + +interface ChatListItemProps { + chat: any; + translations: any; + accessories?: Array<{ text?: string; icon?: any; date?: Date }>; + showDetails?: boolean; +} + +export function ChatListItem({ chat, translations, accessories = [], showDetails = false }: ChatListItemProps) { + return ( + + chat.onOpen?.()} + /> + {showDetails && chat.detailsTarget && ( + + )} + + + } + /> + ); +} diff --git a/src/hooks/useChatSearch.ts b/src/hooks/useChatSearch.ts new file mode 100644 index 0000000..39f39b3 --- /dev/null +++ b/src/hooks/useChatSearch.ts @@ -0,0 +1,16 @@ +import { useBeeperDesktop } from "../api"; + +export function useChatSearch(searchText: string, includeEmpty = false) { + return useBeeperDesktop( + async (client) => { + if (!searchText && !includeEmpty) return []; + const allChats = []; + const params = searchText ? { query: searchText } : {}; + for await (const chat of client.chats.search(params)) { + allChats.push(chat); + } + return allChats; + }, + [searchText] + ); +} diff --git a/src/locales/cs.ts b/src/locales/cs.ts index 4eddb37..40ef0f6 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -60,6 +60,7 @@ export const cs: Translations = { // Common common: { unnamedChat: "Nepojmenovaný chat", + 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", diff --git a/src/locales/en.ts b/src/locales/en.ts index a47fab3..164666f 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -58,6 +58,7 @@ export const en = { // Common common: { unnamedChat: "Unnamed Chat", + unreadCount: (count: number) => `${count} unread`, unknownError: "Unknown error occurred", openInBeeper: "Open Chat in Beeper", copyChatId: "Copy Chat ID", diff --git a/src/search-chats.tsx b/src/search-chats.tsx index b578761..42b6325 100644 --- a/src/search-chats.tsx +++ b/src/search-chats.tsx @@ -1,9 +1,10 @@ import { ActionPanel, Action, List, Icon} from "@raycast/api"; import { withAccessToken } from "@raycast/utils"; import { useState } from "react"; -import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; +import { createBeeperOAuth, focusApp } from "./api"; import { t } from "./locales"; -import { getNetworkIcon } from "./network-icons"; +import { ChatListItem } from "./components/ChatListItem"; +import { useChatSearch } from "./hooks/useChatSearch"; /** * Render a search interface that finds and displays Beeper chats matching the user's query. @@ -17,19 +18,7 @@ import { getNetworkIcon } from "./network-icons"; function SearchChatsCommand() { const translations = t(); const [searchText, setSearchText] = useState(""); - const { data: chats = [], isLoading } = useBeeperDesktop( - async (client) => { - if (!searchText) { - return []; - } - const allChats = []; - for await (const chat of client.chats.search({ query: searchText })) { - allChats.push(chat); - } - return allChats; - }, - [searchText], - ); + const { data: chats = [], isLoading } = useChatSearch(searchText); return ( ( - focusApp({ chatID: chat.id }), + }} + translations={translations} accessories={[ ...(chat.unreadCount > 0 - ? [{ text: translations.commands.unreadChats.unreadCount(chat.unreadCount) }] + ? [{ text: translations.common.unreadCount(chat.unreadCount) }] : []), ...(chat.isPinned ? [{ icon: Icon.Pin }] : []), ...(chat.isMuted ? [{ icon: Icon.SpeakerOff }] : []), ]} - actions={ - - focusApp({ chatID: chat.id })} - /> - - - } + showDetails={false} /> )) )} From 8ce733a4a609413955fcca4622005b0fbf64c118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Lorenc?= Date: Fri, 28 Nov 2025 14:13:06 +0100 Subject: [PATCH 08/14] Refactor ChatListItem props and enhance useChatSearch dependencies --- src/components/ChatListItem.tsx | 13 +++++++++++-- src/hooks/useChatSearch.ts | 2 +- src/search-chats.tsx | 3 ++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/components/ChatListItem.tsx b/src/components/ChatListItem.tsx index 320fc09..690f48b 100644 --- a/src/components/ChatListItem.tsx +++ b/src/components/ChatListItem.tsx @@ -1,9 +1,18 @@ import { List, ActionPanel, Action, Icon } from "@raycast/api"; import { getNetworkIcon } from "../network-icons"; +import { Translations } from "../locales/en"; + +interface Chat { + id: string; + network: string; + title?: string; + onOpen?: () => void; + detailsTarget?: React.ReactNode; +} interface ChatListItemProps { - chat: any; - translations: any; + chat: Chat; + translations: Translations; accessories?: Array<{ text?: string; icon?: any; date?: Date }>; showDetails?: boolean; } diff --git a/src/hooks/useChatSearch.ts b/src/hooks/useChatSearch.ts index 39f39b3..273a97f 100644 --- a/src/hooks/useChatSearch.ts +++ b/src/hooks/useChatSearch.ts @@ -11,6 +11,6 @@ export function useChatSearch(searchText: string, includeEmpty = false) { } return allChats; }, - [searchText] + [searchText, includeEmpty] ); } diff --git a/src/search-chats.tsx b/src/search-chats.tsx index 42b6325..b00d708 100644 --- a/src/search-chats.tsx +++ b/src/search-chats.tsx @@ -1,4 +1,4 @@ -import { ActionPanel, Action, List, Icon} from "@raycast/api"; +import { List, Icon} from "@raycast/api"; import { withAccessToken } from "@raycast/utils"; import { useState } from "react"; import { createBeeperOAuth, focusApp } from "./api"; @@ -46,6 +46,7 @@ function SearchChatsCommand() { : ( chats.map((chat) => ( focusApp({ chatID: chat.id }), From 30584b4decb0a14bb35b5c36794f9a5a3a3e6772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Lorenc?= Date: Tue, 2 Dec 2025 16:46:22 +0100 Subject: [PATCH 09/14] Add avatar/photo to contact --- src/api.ts | 2 +- src/components/ChatListItem.tsx | 52 +++++++++++++----- src/hooks/useChatSearch.ts | 2 +- src/list-accounts.tsx | 2 +- src/list-chats.tsx | 54 ++++++++++++++++--- src/locales/index.ts | 2 +- src/network-icons.ts | 28 ---------- src/search-chats.tsx | 88 +++++++++++++++++-------------- src/send-message.tsx | 2 +- src/unread-chats.tsx | 32 ++++++++---- src/utils/avatar.ts | 93 +++++++++++++++++++++++++++++++++ 11 files changed, 255 insertions(+), 102 deletions(-) delete mode 100644 src/network-icons.ts create mode 100644 src/utils/avatar.ts diff --git a/src/api.ts b/src/api.ts index 03dd52a..512f4d7 100644 --- a/src/api.ts +++ b/src/api.ts @@ -92,4 +92,4 @@ export const focusApp = async (params: AppOpenParams = {}) => { console.error("Failed to focus Beeper Desktop:", error); await showHUD(translations.commands.focusApp.errorMessage); } -}; \ No newline at end of file +}; diff --git a/src/components/ChatListItem.tsx b/src/components/ChatListItem.tsx index 690f48b..6e859be 100644 --- a/src/components/ChatListItem.tsx +++ b/src/components/ChatListItem.tsx @@ -1,11 +1,34 @@ -import { List, ActionPanel, Action, Icon } from "@raycast/api"; -import { getNetworkIcon } from "../network-icons"; +import { List, ActionPanel, Action, Icon, Image } from "@raycast/api"; import { Translations } from "../locales/en"; +import { safeAvatarPath } from "../utils/avatar"; + +function getNetworkIcon(network: string): Image.ImageLike { + const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); + + const iconMap: Record = { + slack: "slack.svg", + whatsapp: "whatsapp.svg", + telegram: "telegram.svg", + discord: "discord.svg", + instagram: "instagram.svg", + facebook: "facebook.svg", + facebookmessenger: "messenger.svg", + messenger: "messenger.svg", + signal: "signal.svg", + imessage: "imessage.svg", + twitter: "twitter.svg", + email: "email.svg", + googlemessages: "google-messages.svg", + }; + + return iconMap[networkLower] || Icon.Message; +} interface Chat { id: string; network: string; title?: string; + avatarUrl?: string; onOpen?: () => void; detailsTarget?: React.ReactNode; } @@ -13,31 +36,34 @@ interface Chat { interface ChatListItemProps { chat: Chat; translations: Translations; + // eslint-disable-next-line @typescript-eslint/no-explicit-any accessories?: Array<{ text?: string; icon?: any; date?: Date }>; showDetails?: boolean; } +function getChatIcon(chat: Chat): Image.ImageLike { + if (chat.avatarUrl) { + const validatedPath = safeAvatarPath(chat.avatarUrl); + if (validatedPath) { + return { source: validatedPath, mask: Image.Mask.Circle }; + } + } + return getNetworkIcon(chat.network); +} + export function ChatListItem({ chat, translations, accessories = [], showDetails = false }: ChatListItemProps) { return ( - chat.onOpen?.()} - /> + chat.onOpen?.()} /> {showDetails && chat.detailsTarget && ( - + )} diff --git a/src/hooks/useChatSearch.ts b/src/hooks/useChatSearch.ts index 273a97f..5997887 100644 --- a/src/hooks/useChatSearch.ts +++ b/src/hooks/useChatSearch.ts @@ -11,6 +11,6 @@ export function useChatSearch(searchText: string, includeEmpty = false) { } return allChats; }, - [searchText, includeEmpty] + [searchText, includeEmpty], ); } diff --git a/src/list-accounts.tsx b/src/list-accounts.tsx index 09db651..ed46cde 100644 --- a/src/list-accounts.tsx +++ b/src/list-accounts.tsx @@ -113,4 +113,4 @@ function ListAccountsCommand() { ); } -export default withAccessToken(createBeeperOAuth())(ListAccountsCommand); \ No newline at end of file +export default withAccessToken(createBeeperOAuth())(ListAccountsCommand); diff --git a/src/list-chats.tsx b/src/list-chats.tsx index d4bd4f9..b47b26d 100644 --- a/src/list-chats.tsx +++ b/src/list-chats.tsx @@ -1,9 +1,50 @@ import { useState } from "react"; -import { ActionPanel, Detail, List, Action, Icon} from "@raycast/api"; +import { ActionPanel, Detail, List, Action, Icon, Image } from "@raycast/api"; import { withAccessToken } from "@raycast/utils"; import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; import { t } from "./locales"; -import { getNetworkIcon } from "./network-icons"; +import { safeAvatarPath } from "./utils/avatar"; + +function getNetworkIcon(network: string): Image.ImageLike { + const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); + + const iconMap: Record = { + slack: "slack.svg", + whatsapp: "whatsapp.svg", + telegram: "telegram.svg", + discord: "discord.svg", + instagram: "instagram.svg", + facebook: "facebook.svg", + facebookmessenger: "messenger.svg", + messenger: "messenger.svg", + signal: "signal.svg", + imessage: "imessage.svg", + twitter: "twitter.svg", + email: "email.svg", + googlemessages: "google-messages.svg", + }; + + return iconMap[networkLower] || Icon.Message; +} + +/** + * Returns chat icon - contact avatar for DMs, network icon for groups. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getChatIcon(chat: any): Image.ImageLike { + // For 1:1 chats, try to get the other person's avatar + if (chat.type !== "group" && 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); +} /** * Render a searchable list of Beeper chats with actions to open the chat in Beeper, view details, and copy the chat ID. @@ -37,13 +78,10 @@ function ListChatsCommand() { {chats.map((chat) => ( = { - 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", -}; - -/** - * Selects an icon image that represents a messaging network. - * - * @param network - The network name or identifier (case-insensitive; spaces, slashes, and dashes are ignored) - * @returns The corresponding network icon image, or a generic message icon if the network is not recognized - */ -export function getNetworkIcon(network: string): Image.ImageLike { - const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); - return NETWORK_ICON_MAP[networkLower] || Icon.Message; -} diff --git a/src/search-chats.tsx b/src/search-chats.tsx index b00d708..946b620 100644 --- a/src/search-chats.tsx +++ b/src/search-chats.tsx @@ -1,10 +1,27 @@ -import { List, Icon} from "@raycast/api"; +import { List, Icon } from "@raycast/api"; import { withAccessToken } from "@raycast/utils"; import { useState } from "react"; import { createBeeperOAuth, focusApp } from "./api"; import { t } from "./locales"; import { ChatListItem } from "./components/ChatListItem"; import { useChatSearch } from "./hooks/useChatSearch"; +import { safeAvatarPath } from "./utils/avatar"; + +/** + * Returns validated avatar path for 1:1 chats, undefined for groups or invalid paths. + */ +// 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) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const otherParticipant = chat.participants.items.find((p: any) => !p.isSelf); + if (otherParticipant?.imgURL) { + return safeAvatarPath(otherParticipant.imgURL); + } + } + return undefined; +} /** * Render a search interface that finds and displays Beeper chats matching the user's query. @@ -27,44 +44,39 @@ function SearchChatsCommand() { onSearchTextChange={setSearchText} throttle > - {searchText === "" - ? ( - - ) - : !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} - /> - )) - )} + {searchText === "" ? ( + + ) : !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} + /> + )) + )} ); } -export default withAccessToken(createBeeperOAuth())(SearchChatsCommand); \ No newline at end of file +export default withAccessToken(createBeeperOAuth())(SearchChatsCommand); diff --git a/src/send-message.tsx b/src/send-message.tsx index 66d1c69..8c0edd4 100644 --- a/src/send-message.tsx +++ b/src/send-message.tsx @@ -103,4 +103,4 @@ function SendMessageForm() { ); } -export default withAccessToken(createBeeperOAuth())(SendMessageForm); \ No newline at end of file +export default withAccessToken(createBeeperOAuth())(SendMessageForm); diff --git a/src/unread-chats.tsx b/src/unread-chats.tsx index 30ea430..495e9ee 100644 --- a/src/unread-chats.tsx +++ b/src/unread-chats.tsx @@ -2,15 +2,8 @@ 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 { safeAvatarPath } from "./utils/avatar"; -/** - * Selects an icon representing a messaging network. - * - * The provided network name is normalized by lowercasing and removing spaces, slashes, and dashes before lookup. - * - * @param network - Network name (e.g., "Slack", "facebook-messenger", "Google Messages") - * @returns The SVG filename for a known network (e.g., `"slack.svg"`, `"messenger.svg"`) or `Icon.Message` when no match exists - */ function getNetworkIcon(network: string): Image.ImageLike { const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); @@ -33,6 +26,25 @@ function getNetworkIcon(network: string): Image.ImageLike { return iconMap[networkLower] || Icon.Message; } +/** + * Returns chat icon - contact avatar for DMs, network icon for groups. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getChatIcon(chat: any): Image.ImageLike { + // For 1:1 chats, try to get the other person's avatar + if (chat.type !== "group" && 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); +} + /** * Render a Raycast list of Beeper chats that currently have unread messages. * @@ -67,7 +79,7 @@ function UnreadChatsCommand() { {chats.map((chat) => ( { + 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: + * - Uses start-anchored regex to strip file:// prefix + * - 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 or .. after normalization + */ +export function safeAvatarPath(url: string): string | undefined { + try { + // Only process file:// URLs, strip the prefix using start-anchored regex + let avatarPath = url.replace(/^file:\/\//, ""); + + // Decode URL encoding safely + try { + avatarPath = decodeURIComponent(avatarPath); + } catch { + // URIError: malformed URI - return undefined to fall back to network icon + 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; + } + + // Basic sanity check: path should not contain suspicious patterns after normalization + if (normalizedPath.includes("\0") || normalizedPath.includes("..")) { + return undefined; + } + + return normalizedPath; + } catch { + // Any unexpected error - fall back to network icon + return undefined; + } +} From cecc5a0bd6db1e91e525e71010417b86606be56f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Lorenc?= Date: Tue, 2 Dec 2025 17:45:01 +0100 Subject: [PATCH 10/14] Add fix for codereview --- src/api.ts | 10 +--- src/components/ChatListItem.tsx | 29 ++--------- src/list-chats.tsx | 23 +-------- src/locales/cs.ts | 2 + src/locales/en.ts | 2 + src/search-chats.tsx | 8 ++- src/unread-chats.tsx | 90 +++++++++++++++------------------ src/utils/avatar.ts | 23 +++++---- src/utils/networkIcons.ts | 40 +++++++++++++++ 9 files changed, 108 insertions(+), 119 deletions(-) create mode 100644 src/utils/networkIcons.ts diff --git a/src/api.ts b/src/api.ts index 512f4d7..5352dae 100644 --- a/src/api.ts +++ b/src/api.ts @@ -71,15 +71,9 @@ export function getBeeperDesktop(): BeeperDesktop { /** * Execute an asynchronous operation using the current BeeperDesktop client and return its managed result. - * - * @param fn - Function that receives the current BeeperDesktop client and returns a promise for the desired value - * @param deps - Optional React dependency list that controls when the operation is re-run - * @returns The value produced by `fn` when executed with the current BeeperDesktop client; loading and error state are managed by the hook */ -export function useBeeperDesktop(fn: (client: BeeperDesktop) => Promise, deps?: React.DependencyList) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - usePromise type overload issue with optional deps - return usePromise(async () => fn(getBeeperDesktop()), deps); +export function useBeeperDesktop(fn: (client: BeeperDesktop) => Promise) { + return usePromise(() => fn(getBeeperDesktop())); } export const focusApp = async (params: AppOpenParams = {}) => { diff --git a/src/components/ChatListItem.tsx b/src/components/ChatListItem.tsx index 6e859be..e12a288 100644 --- a/src/components/ChatListItem.tsx +++ b/src/components/ChatListItem.tsx @@ -1,28 +1,8 @@ import { List, ActionPanel, Action, Icon, Image } from "@raycast/api"; +import type { ReactNode } from "react"; import { Translations } from "../locales/en"; import { safeAvatarPath } from "../utils/avatar"; - -function getNetworkIcon(network: string): Image.ImageLike { - const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); - - const iconMap: Record = { - slack: "slack.svg", - whatsapp: "whatsapp.svg", - telegram: "telegram.svg", - discord: "discord.svg", - instagram: "instagram.svg", - facebook: "facebook.svg", - facebookmessenger: "messenger.svg", - messenger: "messenger.svg", - signal: "signal.svg", - imessage: "imessage.svg", - twitter: "twitter.svg", - email: "email.svg", - googlemessages: "google-messages.svg", - }; - - return iconMap[networkLower] || Icon.Message; -} +import { getNetworkIcon } from "../utils/networkIcons"; interface Chat { id: string; @@ -30,14 +10,13 @@ interface Chat { title?: string; avatarUrl?: string; onOpen?: () => void; - detailsTarget?: React.ReactNode; + detailsTarget?: ReactNode; } interface ChatListItemProps { chat: Chat; translations: Translations; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - accessories?: Array<{ text?: string; icon?: any; date?: Date }>; + accessories?: List.Item.Props["accessories"]; showDetails?: boolean; } diff --git a/src/list-chats.tsx b/src/list-chats.tsx index b47b26d..4f0266e 100644 --- a/src/list-chats.tsx +++ b/src/list-chats.tsx @@ -4,28 +4,7 @@ import { withAccessToken } from "@raycast/utils"; import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; import { t } from "./locales"; import { safeAvatarPath } from "./utils/avatar"; - -function getNetworkIcon(network: string): Image.ImageLike { - const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); - - const iconMap: Record = { - slack: "slack.svg", - whatsapp: "whatsapp.svg", - telegram: "telegram.svg", - discord: "discord.svg", - instagram: "instagram.svg", - facebook: "facebook.svg", - facebookmessenger: "messenger.svg", - messenger: "messenger.svg", - signal: "signal.svg", - imessage: "imessage.svg", - twitter: "twitter.svg", - email: "email.svg", - googlemessages: "google-messages.svg", - }; - - return iconMap[networkLower] || Icon.Message; -} +import { getNetworkIcon } from "./utils/networkIcons"; /** * Returns chat icon - contact avatar for DMs, network icon for groups. diff --git a/src/locales/cs.ts b/src/locales/cs.ts index 40ef0f6..8d9b6c1 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -33,6 +33,8 @@ export const cs: Translations = { 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)`, }, diff --git a/src/locales/en.ts b/src/locales/en.ts index 164666f..291a1c8 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -31,6 +31,8 @@ export const en = { 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)`, }, diff --git a/src/search-chats.tsx b/src/search-chats.tsx index 946b620..ed271d6 100644 --- a/src/search-chats.tsx +++ b/src/search-chats.tsx @@ -5,10 +5,10 @@ import { createBeeperOAuth, focusApp } from "./api"; import { t } from "./locales"; import { ChatListItem } from "./components/ChatListItem"; import { useChatSearch } from "./hooks/useChatSearch"; -import { safeAvatarPath } from "./utils/avatar"; /** - * Returns validated avatar path for 1:1 chats, undefined for groups or invalid paths. + * 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 { @@ -16,9 +16,7 @@ function getAvatarUrl(chat: any): string | undefined { if (chat.type !== "group" && 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) { - return safeAvatarPath(otherParticipant.imgURL); - } + return otherParticipant?.imgURL; } return undefined; } diff --git a/src/unread-chats.tsx b/src/unread-chats.tsx index 495e9ee..cc53d04 100644 --- a/src/unread-chats.tsx +++ b/src/unread-chats.tsx @@ -3,28 +3,7 @@ import { withAccessToken } from "@raycast/utils"; import { useBeeperDesktop, createBeeperOAuth, focusApp } from "./api"; import { t } from "./locales"; import { safeAvatarPath } from "./utils/avatar"; - -function getNetworkIcon(network: string): Image.ImageLike { - const networkLower = network.toLowerCase().replace(/[/\s-]/g, ""); - - const iconMap: Record = { - slack: "slack.svg", - whatsapp: "whatsapp.svg", - telegram: "telegram.svg", - discord: "discord.svg", - instagram: "instagram.svg", - facebook: "facebook.svg", - facebookmessenger: "messenger.svg", - messenger: "messenger.svg", - signal: "signal.svg", - imessage: "imessage.svg", - twitter: "twitter.svg", - email: "email.svg", - googlemessages: "google-messages.svg", - }; - - return iconMap[networkLower] || Icon.Message; -} +import { getNetworkIcon } from "./utils/networkIcons"; /** * Returns chat icon - contact avatar for DMs, network icon for groups. @@ -56,7 +35,11 @@ function getChatIcon(chat: any): Image.ImageLike { */ function UnreadChatsCommand() { const translations = t(); - const { data: chats = [], isLoading } = useBeeperDesktop(async (client) => { + const { + data: chats = [], + isLoading, + error, + } = useBeeperDesktop(async (client) => { const allChats = []; for await (const chat of client.chats.search({})) { // Filter only chats with unread messages @@ -76,39 +59,46 @@ function UnreadChatsCommand() { searchBarPlaceholder={translations.commands.unreadChats.searchPlaceholder} navigationTitle={`${translations.commands.unreadChats.navigationTitle}${totalUnread > 0 ? translations.commands.unreadChats.totalCount(totalUnread) : ""}`} > - {chats.map((chat) => ( - - focusApp({ chatID: chat.id })} - /> - - - } + {error ? ( + - ))} - {!isLoading && chats.length === 0 && ( + ) : !isLoading && chats.length === 0 ? ( + ) : ( + chats.map((chat) => ( + + focusApp({ chatID: chat.id })} + /> + + + } + /> + )) )} ); diff --git a/src/utils/avatar.ts b/src/utils/avatar.ts index 1b8969e..ad323e2 100644 --- a/src/utils/avatar.ts +++ b/src/utils/avatar.ts @@ -53,16 +53,21 @@ function isPathAllowed(normalizedPath: string): boolean { * Returns undefined if the URL is invalid or points outside the allowed directory. * * Security measures: - * - Uses start-anchored regex to strip file:// prefix + * - 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 or .. after normalization + * - Rejects paths with null bytes */ export function safeAvatarPath(url: string): string | undefined { try { - // Only process file:// URLs, strip the prefix using start-anchored regex - let avatarPath = url.replace(/^file:\/\//, ""); + // 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 { @@ -72,6 +77,11 @@ export function safeAvatarPath(url: string): string | undefined { 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)); @@ -80,11 +90,6 @@ export function safeAvatarPath(url: string): string | undefined { return undefined; } - // Basic sanity check: path should not contain suspicious patterns after normalization - if (normalizedPath.includes("\0") || normalizedPath.includes("..")) { - return undefined; - } - return normalizedPath; } catch { // Any unexpected error - fall back to network icon 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; +} From a604d5d4de4b5ba820250370483ea5dab32700fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Lorenc?= Date: Thu, 4 Dec 2025 08:42:46 +0100 Subject: [PATCH 11/14] feat: Add message search command with new `useMessageSearch` hook and updated `useBeeperDesktop` signature. --- package.json | 18 ++++++-- src/api.ts | 11 ++++- src/hooks/useChatSearch.ts | 8 ++-- src/hooks/useMessageSearch.ts | 46 +++++++++++++++++++ src/list-accounts.tsx | 3 +- src/list-chats.tsx | 4 +- src/locales/cs.ts | 11 +++++ src/locales/en.ts | 11 +++++ src/search-messages.tsx | 84 +++++++++++++++++++++++++++++++++++ src/send-message.tsx | 2 +- src/unread-chats.tsx | 2 +- 11 files changed, 185 insertions(+), 15 deletions(-) create mode 100644 src/hooks/useMessageSearch.ts create mode 100644 src/search-messages.tsx diff --git a/package.json b/package.json index cf0a5db..697841f 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,12 @@ "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" } ], "preferences": [ @@ -61,8 +67,14 @@ "required": false, "default": "en", "data": [ - { "title": "English", "value": "en" }, - { "title": "Čeština", "value": "cs" } + { + "title": "English", + "value": "en" + }, + { + "title": "Čeština", + "value": "cs" + } ] } ], @@ -88,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/src/api.ts b/src/api.ts index 5352dae..54ba8de 100644 --- a/src/api.ts +++ b/src/api.ts @@ -71,9 +71,16 @@ export function getBeeperDesktop(): BeeperDesktop { /** * 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) => Promise) { - return usePromise(() => fn(getBeeperDesktop())); +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: AppOpenParams = {}) => { diff --git a/src/hooks/useChatSearch.ts b/src/hooks/useChatSearch.ts index 5997887..080a6c8 100644 --- a/src/hooks/useChatSearch.ts +++ b/src/hooks/useChatSearch.ts @@ -2,15 +2,15 @@ import { useBeeperDesktop } from "../api"; export function useChatSearch(searchText: string, includeEmpty = false) { return useBeeperDesktop( - async (client) => { - if (!searchText && !includeEmpty) return []; + async (client, query, includeEmptyResults) => { + if (!query && !includeEmptyResults) return []; const allChats = []; - const params = searchText ? { query: searchText } : {}; + const params = query ? { query } : {}; for await (const chat of client.chats.search(params)) { allChats.push(chat); } return allChats; }, - [searchText, includeEmpty], + [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 ed46cde..d2b8068 100644 --- a/src/list-accounts.tsx +++ b/src/list-accounts.tsx @@ -1,6 +1,5 @@ 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"; @@ -19,7 +18,7 @@ function ListAccountsCommand() { isLoading, revalidate, error, - } = useBeeperDesktop(async (client) => { + } = useBeeperDesktop(async (client) => { const result = await client.accounts.list(); return result; }); diff --git a/src/list-chats.tsx b/src/list-chats.tsx index 4f0266e..150565e 100644 --- a/src/list-chats.tsx +++ b/src/list-chats.tsx @@ -36,9 +36,9 @@ function ListChatsCommand() { const translations = t(); const [searchText, setSearchText] = useState(""); const { data: chats = [], isLoading } = useBeeperDesktop( - async (client) => { + async (client, query) => { const allChats = []; - const searchParams = searchText ? { query: searchText } : {}; + const searchParams = query ? { query } : {}; for await (const chat of client.chats.search(searchParams)) { allChats.push(chat); } diff --git a/src/locales/cs.ts b/src/locales/cs.ts index 8d9b6c1..5ca3864 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -57,15 +57,26 @@ export const cs: Translations = { 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", diff --git a/src/locales/en.ts b/src/locales/en.ts index 291a1c8..17abfcb 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -55,15 +55,26 @@ export const en = { 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", diff --git a/src/search-messages.tsx b/src/search-messages.tsx new file mode 100644 index 0000000..b55821d --- /dev/null +++ b/src/search-messages.tsx @@ -0,0 +1,84 @@ +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 { safeAvatarPath } from "./utils/avatar"; +import { getNetworkIcon } from "./utils/networkIcons"; + +/** + * Returns chat icon - contact avatar for DMs, network icon for groups. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +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) { + // 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); +} + +function SearchMessagesCommand() { + const translations = t(); + const [searchText, setSearchText] = useState(""); + const { data: messages = [], isLoading } = useMessageSearch(searchText); + + return ( + + {searchText === "" ? ( + + ) : !isLoading && messages.length === 0 ? ( + + ) : ( + messages.map((message) => ( + + focusApp({ chatID: message.chatID, messageSortKey: String(message.sortKey) })} + /> + + + } + /> + )) + )} + + ); +} + +export default withAccessToken(createBeeperOAuth())(SearchMessagesCommand); diff --git a/src/send-message.tsx b/src/send-message.tsx index 8c0edd4..29a100b 100644 --- a/src/send-message.tsx +++ b/src/send-message.tsx @@ -32,7 +32,7 @@ function SendMessageForm() { const bTime = b.lastActivity ? new Date(b.lastActivity).getTime() : 0; return bTime - aTime; }); - }, []); + }); async function handleSubmit(values: SendMessageFormValues) { if (!values.chatId || !values.message) { diff --git a/src/unread-chats.tsx b/src/unread-chats.tsx index cc53d04..61345f3 100644 --- a/src/unread-chats.tsx +++ b/src/unread-chats.tsx @@ -49,7 +49,7 @@ function UnreadChatsCommand() { } // 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); From 6f8b2efede34da4cc6b9e161f781f5e3a771e23b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Lorenc?= Date: Mon, 8 Dec 2025 10:18:03 +0100 Subject: [PATCH 12/14] refactor: Extract `getChatIcon` into a shared utility and update chat-related components to use it. --- src/components/ChatListItem.tsx | 15 ++------------- src/list-chats.tsx | 22 +--------------------- src/search-messages.tsx | 24 +----------------------- src/unread-chats.tsx | 22 +--------------------- src/utils/chatIcon.ts | 24 ++++++++++++++++++++++++ 5 files changed, 29 insertions(+), 78 deletions(-) create mode 100644 src/utils/chatIcon.ts diff --git a/src/components/ChatListItem.tsx b/src/components/ChatListItem.tsx index e12a288..1f8b766 100644 --- a/src/components/ChatListItem.tsx +++ b/src/components/ChatListItem.tsx @@ -1,8 +1,7 @@ -import { List, ActionPanel, Action, Icon, Image } from "@raycast/api"; +import { List, ActionPanel, Action, Icon } from "@raycast/api"; import type { ReactNode } from "react"; import { Translations } from "../locales/en"; -import { safeAvatarPath } from "../utils/avatar"; -import { getNetworkIcon } from "../utils/networkIcons"; +import { getChatIcon } from "../utils/chatIcon"; interface Chat { id: string; @@ -20,16 +19,6 @@ interface ChatListItemProps { showDetails?: boolean; } -function getChatIcon(chat: Chat): Image.ImageLike { - if (chat.avatarUrl) { - const validatedPath = safeAvatarPath(chat.avatarUrl); - if (validatedPath) { - return { source: validatedPath, mask: Image.Mask.Circle }; - } - } - return getNetworkIcon(chat.network); -} - export function ChatListItem({ chat, translations, accessories = [], showDetails = false }: ChatListItemProps) { return ( !p.isSelf); - if (otherParticipant?.imgURL) { - const validatedPath = safeAvatarPath(otherParticipant.imgURL); - if (validatedPath) { - return { source: validatedPath, mask: Image.Mask.Circle }; - } - } - } - return getNetworkIcon(chat.network); -} +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. diff --git a/src/search-messages.tsx b/src/search-messages.tsx index b55821d..efa9dbc 100644 --- a/src/search-messages.tsx +++ b/src/search-messages.tsx @@ -4,29 +4,7 @@ import { useState } from "react"; import { createBeeperOAuth, focusApp } from "./api"; import { t } from "./locales"; import { useMessageSearch } from "./hooks/useMessageSearch"; -import { safeAvatarPath } from "./utils/avatar"; -import { getNetworkIcon } from "./utils/networkIcons"; - -/** - * Returns chat icon - contact avatar for DMs, network icon for groups. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -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) { - // 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); -} +import { getChatIcon } from "./utils/chatIcon"; function SearchMessagesCommand() { const translations = t(); diff --git a/src/unread-chats.tsx b/src/unread-chats.tsx index 61345f3..393bd3e 100644 --- a/src/unread-chats.tsx +++ b/src/unread-chats.tsx @@ -2,27 +2,7 @@ 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 { safeAvatarPath } from "./utils/avatar"; -import { getNetworkIcon } from "./utils/networkIcons"; - -/** - * Returns chat icon - contact avatar for DMs, network icon for groups. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function getChatIcon(chat: any): Image.ImageLike { - // For 1:1 chats, try to get the other person's avatar - if (chat.type !== "group" && 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); -} +import { getChatIcon } from "./utils/chatIcon"; /** * Render a Raycast list of Beeper chats that currently have unread messages. 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); +} From 6670b8914943f83ca09066d2934ba4120e6b7e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Lorenc?= Date: Mon, 8 Dec 2025 10:37:26 +0100 Subject: [PATCH 13/14] feat: Add `type` and `participants` to Chat interface and conditionally pass `messageSortKey` to `focusApp`. --- src/components/ChatListItem.tsx | 4 ++++ src/search-messages.tsx | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/ChatListItem.tsx b/src/components/ChatListItem.tsx index 1f8b766..f611944 100644 --- a/src/components/ChatListItem.tsx +++ b/src/components/ChatListItem.tsx @@ -6,6 +6,10 @@ 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; diff --git a/src/search-messages.tsx b/src/search-messages.tsx index efa9dbc..38cd848 100644 --- a/src/search-messages.tsx +++ b/src/search-messages.tsx @@ -47,7 +47,14 @@ function SearchMessagesCommand() { focusApp({ chatID: message.chatID, messageSortKey: String(message.sortKey) })} + onAction={() => { + const sortKey = message.sortKey != null ? String(message.sortKey) : undefined; + focusApp( + sortKey !== undefined + ? { chatID: message.chatID, messageSortKey: sortKey } + : { chatID: message.chatID } + ); + }} /> From 954e284d19b115a39c0b2d109dabfa77ed389cf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Lorenc?= Date: Mon, 8 Dec 2025 11:34:16 +0100 Subject: [PATCH 14/14] Add pages to list --- src/components/ChatListItem.tsx | 2 +- src/hooks/useChatSearch.ts | 26 +++++++++++-- src/list-chats.tsx | 69 ++++++++++++++++++++++++++++----- src/locales/cs.ts | 5 +++ src/locales/en.ts | 5 +++ src/search-chats.tsx | 2 +- src/unread-chats.tsx | 23 ++++++++--- 7 files changed, 113 insertions(+), 19 deletions(-) diff --git a/src/components/ChatListItem.tsx b/src/components/ChatListItem.tsx index f611944..068b2b1 100644 --- a/src/components/ChatListItem.tsx +++ b/src/components/ChatListItem.tsx @@ -8,7 +8,7 @@ interface Chat { network: string; type?: string; participants?: { - items?: Array<{ isSelf: boolean; imgURL?: string }>; + items?: Array<{ isSelf?: boolean; imgURL?: string }>; }; title?: string; avatarUrl?: string; diff --git a/src/hooks/useChatSearch.ts b/src/hooks/useChatSearch.ts index 080a6c8..c9dc971 100644 --- a/src/hooks/useChatSearch.ts +++ b/src/hooks/useChatSearch.ts @@ -4,11 +4,31 @@ export function useChatSearch(searchText: string, includeEmpty = false) { return useBeeperDesktop( async (client, query, includeEmptyResults) => { if (!query && !includeEmptyResults) return []; + const allChats = []; - const params = query ? { query } : {}; - for await (const chat of client.chats.search(params)) { - allChats.push(chat); + 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/list-chats.tsx b/src/list-chats.tsx index f35e8e9..34a6944 100644 --- a/src/list-chats.tsx +++ b/src/list-chats.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { ActionPanel, Detail, List, Action, Icon, Image } from "@raycast/api"; +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"; @@ -15,18 +15,57 @@ import { getChatIcon } from "./utils/chatIcon"; function ListChatsCommand() { const translations = t(); const [searchText, setSearchText] = useState(""); - const { data: chats = [], isLoading } = useBeeperDesktop( + 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) => { - const allChats = []; - const searchParams = query ? { query } : {}; - for await (const chat of client.chats.search(searchParams)) { - allChats.push(chat); - } - return allChats; + // 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 ( ))} + {!isLoading && hasMore && chats.length > 0 && ( + + + + } + /> + )} {!isLoading && chats.length === 0 && ( !p.isSelf); return otherParticipant?.imgURL; diff --git a/src/unread-chats.tsx b/src/unread-chats.tsx index 393bd3e..b91daf4 100644 --- a/src/unread-chats.tsx +++ b/src/unread-chats.tsx @@ -21,12 +21,25 @@ function UnreadChatsCommand() { error, } = useBeeperDesktop(async (client) => { const allChats = []; - for await (const chat of client.chats.search({})) { - // Filter only chats with unread messages - if (chat.unreadCount > 0) { - allChats.push(chat); - } + 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); });