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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions apps/desktop/src/settings/ai/stt/select.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useQueries, useQuery } from "@tanstack/react-query";
import { arch } from "@tauri-apps/plugin-os";
import { Check, Loader2 } from "lucide-react";
import { useRef } from "react";
import { useMemo, useRef } from "react";

import { commands as listenerCommands } from "@hypr/plugin-listener";
import {
Expand Down Expand Up @@ -37,6 +37,7 @@ import {
requiresEntitlement,
} from "~/settings/ai/shared/eligibility";
import { useConfigValues } from "~/shared/config";
import { normalizeSpokenLanguageCodes } from "~/shared/language";
import * as settings from "~/store/tinybase/store/settings";

export function SelectProviderAndModel() {
Expand All @@ -53,23 +54,27 @@ export function SelectProviderAndModel() {

const isConfigured = !!(current_stt_provider && current_stt_model);
const hasError = isConfigured && health.status === "error";
const normalizedSpokenLanguages = useMemo(
() => normalizeSpokenLanguageCodes(spoken_languages ?? []),
[spoken_languages],
);

const languageSupport = useQuery({
queryKey: [
"stt-language-support",
current_stt_provider,
current_stt_model,
spoken_languages,
normalizedSpokenLanguages,
],
queryFn: async () => {
const result = await listenerCommands.isSupportedLanguagesLive(
current_stt_provider!,
current_stt_model ?? null,
spoken_languages ?? [],
normalizedSpokenLanguages,
);
return result.status === "ok" ? result.data : true;
},
enabled: !!(current_stt_provider && spoken_languages?.length),
enabled: !!(current_stt_provider && normalizedSpokenLanguages.length),
});

const hasLanguageWarning =
Expand Down
31 changes: 13 additions & 18 deletions apps/desktop/src/settings/general/language.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
const displayNames = new Intl.DisplayNames(["en"], { type: "language" });

export function getLanguageDisplayName(code: string): string {
return displayNames.of(code) ?? code;
}

export function getBaseLanguageDisplayName(code: string): string {
const { language } = parseLocale(code);
return displayNames.of(language) ?? code;
}

export function parseLocale(code: string): {
language: string;
region?: string;
} {
const locale = new Intl.Locale(code);
return { language: locale.language, region: locale.region };
}
export {
getBaseLanguageOptions,
getBaseLanguageDisplayName,
getLanguageDisplayName,
getSpokenLanguageDisplayName,
getSpokenLanguageOptions,
normalizeBaseLanguageCode,
normalizeBaseLanguageCodes,
normalizeSelectedSpokenLanguages,
normalizeSpokenLanguageCode,
normalizeSpokenLanguageCodes,
parseLocale,
} from "~/shared/language";
26 changes: 7 additions & 19 deletions apps/desktop/src/settings/general/main-language.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useMemo } from "react";

import { getBaseLanguageDisplayName, parseLocale } from "./language";
import { getBaseLanguageOptions, normalizeBaseLanguageCode } from "./language";
import {
SearchableSelect,
type SearchableSelectOption,
Expand All @@ -15,29 +15,17 @@ export function MainLanguageView({
onChange: (value: string) => void;
supportedLanguages: readonly string[];
}) {
const deduped = useMemo(() => {
const map = new Map<string, string>();
for (const code of supportedLanguages) {
const { language } = parseLocale(code);
if (!map.has(language)) {
map.set(language, code);
}
}
return map;
}, [supportedLanguages]);

const normalizedValue = useMemo(() => {
const { language } = parseLocale(value);
return deduped.get(language) ?? value;
}, [value, deduped]);
return normalizeBaseLanguageCode(value);
}, [value]);

const options: SearchableSelectOption[] = useMemo(
() =>
[...deduped.values()].map((code) => ({
value: code,
label: getBaseLanguageDisplayName(code),
getBaseLanguageOptions(supportedLanguages).map((option) => ({
value: option.value,
label: option.label,
})),
[deduped],
[supportedLanguages],
);

return (
Expand Down
113 changes: 58 additions & 55 deletions apps/desktop/src/settings/general/spoken-languages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,11 @@ import { Badge } from "@hypr/ui/components/ui/badge";
import { Button } from "@hypr/ui/components/ui/button";
import { cn } from "@hypr/utils";

import { getLanguageDisplayName } from "./language";

function hasRegionVariant(langCode: string): boolean {
return langCode.includes("-");
}

function getBaseLanguage(langCode: string): string {
return langCode.split("-")[0];
}

function isLanguageDisabled(
langCode: string,
selectedLanguages: string[],
): boolean {
const base = getBaseLanguage(langCode);
const isVariant = hasRegionVariant(langCode);

for (const selected of selectedLanguages) {
const selectedBase = getBaseLanguage(selected);
if (selectedBase !== base) continue;

if (isVariant) {
return selected === base || hasRegionVariant(selected);
} else {
return hasRegionVariant(selected);
}
}
return false;
}
import {
getSpokenLanguageDisplayName,
getSpokenLanguageOptions,
normalizeSelectedSpokenLanguages,
} from "./language";

interface SpokenLanguagesViewProps {
value: string[];
Expand All @@ -49,28 +25,51 @@ export function SpokenLanguagesView({
const [languageSearchQuery, setLanguageSearchQuery] = useState("");
const [languageInputFocused, setLanguageInputFocused] = useState(false);
const [languageSelectedIndex, setLanguageSelectedIndex] = useState(-1);
const selectedLanguages = useMemo(
() => normalizeSelectedSpokenLanguages(value, supportedLanguages),
[supportedLanguages, value],
);
const languageOptions = useMemo(
() => getSpokenLanguageOptions(supportedLanguages),
[supportedLanguages],
);
const selectedSelectionKeys = useMemo(
() =>
new Set(
languageOptions
.filter((option) => selectedLanguages.includes(option.value))
.map((option) => option.selectionKey),
),
[languageOptions, selectedLanguages],
);

const filteredLanguages = useMemo(() => {
if (!languageSearchQuery.trim()) {
return [];
const availableOptions = languageOptions.filter(
(option) => !selectedSelectionKeys.has(option.selectionKey),
);
const query = languageSearchQuery.trim().toLowerCase();

if (!query) {
return availableOptions;
}
const query = languageSearchQuery.toLowerCase();
return supportedLanguages.filter((langCode) => {
if (value.includes(langCode)) return false;
if (isLanguageDisabled(langCode, value)) return false;
const langName = getLanguageDisplayName(langCode);
return langName.toLowerCase().includes(query);
});
}, [languageSearchQuery, value, supportedLanguages]);

return availableOptions.filter((option) =>
option.searchTerms.some((term) => term.toLowerCase().includes(query)),
);
}, [languageOptions, languageSearchQuery, selectedSelectionKeys]);

const handleLanguageKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Backspace" && !languageSearchQuery && value.length > 0) {
if (
e.key === "Backspace" &&
!languageSearchQuery &&
selectedLanguages.length > 0
) {
e.preventDefault();
onChange(value.slice(0, -1));
onChange(selectedLanguages.slice(0, -1));
return;
}

if (!languageSearchQuery.trim() || filteredLanguages.length === 0) {
if (filteredLanguages.length === 0) {
return;
}

Expand All @@ -88,8 +87,12 @@ export function SpokenLanguagesView({
languageSelectedIndex >= 0 &&
languageSelectedIndex < filteredLanguages.length
) {
const selectedCode = filteredLanguages[languageSelectedIndex];
onChange([...value, selectedCode]);
const selectedCode = filteredLanguages[languageSelectedIndex]?.value;
if (!selectedCode) {
return;
}

onChange([...selectedLanguages, selectedCode]);
setLanguageSearchQuery("");
setLanguageSelectedIndex(-1);
}
Expand All @@ -116,28 +119,28 @@ export function SpokenLanguagesView({
document.getElementById("language-search-input")?.focus()
}
>
{value.map((code) => (
{selectedLanguages.map((code) => (
<Badge
key={code}
variant="secondary"
className="bg-muted flex items-center gap-1 px-2 py-0.5 text-xs"
>
{getLanguageDisplayName(code)}
{getSpokenLanguageDisplayName(code)}
<Button
type="button"
variant="ghost"
size="sm"
className="ml-0.5 h-3 w-3 p-0 hover:bg-transparent"
onClick={(e) => {
e.stopPropagation();
onChange(value.filter((c) => c !== code));
onChange(selectedLanguages.filter((c) => c !== code));
}}
>
<X className="h-2.5 w-2.5" />
</Button>
</Badge>
))}
{value.length === 0 && (
{selectedLanguages.length === 0 && (
<Search className="size-4 shrink-0 text-neutral-700" />
)}
<input
Expand All @@ -153,35 +156,35 @@ export function SpokenLanguagesView({
onBlur={() => setLanguageInputFocused(false)}
role="combobox"
aria-haspopup="listbox"
aria-expanded={languageInputFocused && !!languageSearchQuery.trim()}
aria-expanded={languageInputFocused}
aria-controls="language-options"
aria-activedescendant={
languageSelectedIndex >= 0
? `language-option-${languageSelectedIndex}`
: undefined
}
aria-label="Add spoken language"
placeholder={value.length === 0 ? "Add language" : ""}
placeholder={selectedLanguages.length === 0 ? "Add language" : ""}
className="min-w-[120px] flex-1 bg-transparent text-sm placeholder:text-neutral-500 focus:outline-hidden"
/>
</div>

{languageInputFocused && languageSearchQuery.trim() && (
{languageInputFocused && (
<div
id="language-options"
role="listbox"
className="absolute top-full right-0 left-0 z-10 mt-1 flex max-h-60 w-full flex-col overflow-hidden overflow-y-auto rounded-xs border border-neutral-200 bg-white shadow-md"
>
{filteredLanguages.length > 0 ? (
filteredLanguages.map((langCode, index) => (
filteredLanguages.map((option, index) => (
<button
key={langCode}
key={option.value}
id={`language-option-${index}`}
type="button"
role="option"
aria-selected={languageSelectedIndex === index}
onClick={() => {
onChange([...value, langCode]);
onChange([...selectedLanguages, option.value]);
setLanguageSearchQuery("");
setLanguageSelectedIndex(-1);
}}
Expand All @@ -195,7 +198,7 @@ export function SpokenLanguagesView({
])}
>
<span className="truncate font-medium">
{getLanguageDisplayName(langCode)}
{getSpokenLanguageDisplayName(option.value)}
</span>
</button>
))
Expand Down
Loading
Loading