From b0feada98a2642b7d54b001900f85b805a498eec Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Fri, 9 Oct 2026 21:54:19 -0500 Subject: [PATCH] Add post language selection --- src/css/style.css | 107 +++++++ src/js/components/post-composer.js | 129 ++++++++ src/js/components/post-language-dialog.js | 293 ++++++++++++++++++ src/js/dataLayer/derived.js | 3 + src/js/dataLayer/mutations.js | 10 + src/js/dataLayer/sessionState.js | 4 + src/js/drafts.js | 3 + src/js/languages.js | 195 ++++++++++++ src/js/postComposerService.js | 3 + src/js/postCreator.js | 7 +- src/js/utils.js | 52 +++- tests/e2e/specs/flows/postLanguage.test.js | 108 +++++++ .../specs/components/post-composer.test.js | 161 ++++++++++ .../components/post-language-dialog.test.js | 147 +++++++++ tests/unit/specs/dataLayer/mutations.test.js | 21 ++ .../unit/specs/dataLayer/sessionState.test.js | 16 + tests/unit/specs/drafts.test.js | 14 + tests/unit/specs/postCreator.test.js | 35 ++- tests/unit/specs/utils.test.js | 112 +++++-- 19 files changed, 1383 insertions(+), 37 deletions(-) create mode 100644 src/js/components/post-language-dialog.js create mode 100644 src/js/languages.js create mode 100644 tests/e2e/specs/flows/postLanguage.test.js create mode 100644 tests/unit/specs/components/post-language-dialog.test.js diff --git a/src/css/style.css b/src/css/style.css index a79a9a77..7c04a952 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -7569,6 +7569,33 @@ sidebar-search { color: var(--highlight-color); } +.post-composer-language-button { + display: flex; + align-items: center; + padding: 6px 8px; + margin-right: 4px; + border: none; + border-radius: 8px; + background: none; + color: var(--highlight-color); + cursor: pointer; +} + +@media (hover: hover) { + .post-composer-language-button:hover { + background-color: var(--background-color-secondary); + } +} + +.post-composer-language-button-label { + max-width: 100px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 14px; + font-weight: 500; +} + .post-composer .rich-text-input { width: 100%; min-height: 140px; @@ -10517,6 +10544,86 @@ label:has(toggle-switch[disabled]) { width: 20px; } +/* Post Language Dialog */ + +.post-language-dialog::before { + z-index: 1; +} + +.post-language-dialog[open] { + display: flex; + flex-direction: column; +} + +@media (min-width: 800px) { + .post-language-dialog { + max-width: 440px; + height: min(640px, 85dvh); + } +} + +.post-language-dialog-content { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + padding-top: 28px; +} + +@media (min-width: 800px) { + .post-language-dialog-content { + padding-top: 24px; + } +} + +.post-language-dialog-header { + display: flex; + flex-direction: column; + gap: 4px; + padding: 0 20px 12px; +} + +.post-language-dialog-title { + font-size: 1.3rem; + font-weight: 700; + margin: 0; +} + +.post-language-dialog-subtitle { + margin: 0 0 8px; + color: var(--text-color-secondary); + font-size: 0.95rem; +} + +.post-language-dialog-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 0 20px; +} + +.post-language-section-title { + font-size: 0.95rem; + font-weight: 600; + margin: 8px 0; +} + +.post-language-row { + margin-bottom: 8px; +} + +.post-language-row-disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.post-language-dialog-footer { + display: flex; + flex-direction: column; + padding: 12px 20px calc(16px + env(safe-area-inset-bottom)); + border-top: var(--hair) solid var(--post-border-color); +} + /* Post Interaction Settings Dialog */ .post-interaction-settings-dialog::before { diff --git a/src/js/components/post-composer.js b/src/js/components/post-composer.js index 74ef8c9f..f7af1a9e 100644 --- a/src/js/components/post-composer.js +++ b/src/js/components/post-composer.js @@ -4,11 +4,15 @@ import { avatarTemplate } from "/js/templates/avatar.template.js"; import { postHeaderTextTemplate } from "/js/templates/postHeaderText.template.js"; import { richTextTemplate } from "/js/templates/richText.template.js"; import { + arePostLanguagesEqual, classnames, + getPrimaryPostLanguage, + getNameForLanguageCode, graphemeCount, readFileAsDataUrl, sanitizeUri, } from "/js/utils.js"; +import { POST_LANGUAGE_CODES } from "/js/languages.js"; import { externalLinkTemplate } from "/js/templates/externalLink.template.js"; import { confirmModal } from "/js/modals/confirm.modal.js"; import { scrollLocks } from "/js/scrollLocks.js"; @@ -59,6 +63,10 @@ import "/js/components/image-alt-text-dialog.js"; import "/js/components/emoji-picker-dialog.js"; import "/js/components/gif-picker-dialog.js"; import "/js/components/drafts-dialog.js"; +import "/js/components/post-language-dialog.js"; +import "/js/components/context-menu.js"; +import "/js/components/context-menu-item.js"; +import "/js/components/context-menu-item-group.js"; const MAX_DRAFT_GRAPHEME_LENGTH = 1000; // Threshold for detecting pasted text, since InputEvent.inputType is unreliable @@ -130,6 +138,57 @@ function errorMessageBannerTemplate({ message, onDismiss }) { `; } +function postLanguageLabel(languages, separator) { + return languages.map(getNameForLanguageCode).join(separator); +} + +function postLanguageButtonTemplate({ currentLanguages, onClick }) { + const label = postLanguageLabel(currentLanguages, ", "); + return html``; +} + +function postLanguageMenuTemplate({ + menuEntries, + currentLanguages, + onSelect, + onMoreLanguages, +}) { + return html` + + ${menuEntries.map((entry) => { + const isSelected = arePostLanguagesEqual(entry, currentLanguages); + return html` onSelect(entry)} + > + ${postLanguageLabel(entry, " + ")} + `; + })} + + + + More languages… + + + `; +} + function isVideoUploadPending(video) { return ( !!video && (video.status === "uploading" || video.status === "processing") @@ -572,6 +631,8 @@ class PostComposer extends Component { this.state.$isDraggingFiles = new Signal.State(false); this.state.$threadgateAllow = new Signal.State(null); this.state.$postgateEmbeddingRules = new Signal.State(null); + // null uses the primary language + this.state.$selectedPostLanguages = new Signal.State(null); this._dragAndDropObserver = null; this._disposers = [ effect(() => { @@ -720,6 +781,15 @@ class PostComposer extends Component { const postgateEmbeddingRules = this.state.$postgateEmbeddingRules.get(); const isInteractionLimited = threadgateAllow !== null || postgateEmbeddingRules?.length > 0; + const currentPostLanguages = this._getCurrentPostLanguages(); + const postLanguageHistory = + this.dataLayer.derived.$postLanguageHistory.get(); + const primaryPostLanguages = [getPrimaryPostLanguage()]; + const postLanguageMenuEntries = postLanguageHistory.some((entry) => + arePostLanguagesEqual(entry, primaryPostLanguages), + ) + ? postLanguageHistory + : [...postLanguageHistory, primaryPostLanguages]; const currentCharCount = graphemeCount(activePost.text); const charCountPercentage = Math.min( @@ -959,6 +1029,20 @@ class PostComposer extends Component {
+ ${postLanguageButtonTemplate({ + currentLanguages: currentPostLanguages, + onClick: () => this.openPostLanguageMenu(), + })} + ${postLanguageMenuTemplate({ + menuEntries: postLanguageMenuEntries, + currentLanguages: currentPostLanguages, + onSelect: (entry) => this.setPostLanguages(entry), + onMoreLanguages: () => { + requestAnimationFrame(() => + this.openPostLanguageDialog(), + ); + }, + })} ${canAddPost ? html`` + : ""} +
+ +
+ ${languageSectionTemplate({ + title: "Recently used", + testid: "post-language-section-recent", + languages: visibleRecentLanguages, + checkedLanguages, + onToggle, + })} + ${languageSectionTemplate({ + title: "All languages", + testid: "post-language-section-all", + languages: visibleAllLanguages, + checkedLanguages, + onToggle, + })} + ${normalizedQuery && !hasUncheckedMatches + ? html`
+ No languages found +
` + : null} +
+
+ +
+ `; +} + +class PostLanguageDialog extends Component { + connectedCallback() { + if (this.initialized) { + return; + } + this.setAttribute("data-dialog-wrapper", ""); + this.scrollLock = null; + const languagesByCode = new Map( + POST_LANGUAGES.map((language) => [ + language.code2, + { ...language, label: getNameForLanguageCode(language.code2) }, + ]), + ); + // Fixed for the dialog's lifetime so rows don't move while toggling + const recentCodes = unique([ + ...this.currentLanguages, + ...this.postLanguageHistory.flat(), + ]) + .filter((code) => POST_LANGUAGE_CODES.has(code)) + .slice(0, MAX_RECENT_LANGUAGES); + this._recentLanguages = recentCodes.map((code) => + languagesByCode.get(code), + ); + this._allLanguages = [...languagesByCode.values()] + .filter((language) => !recentCodes.includes(language.code2)) + .sort((a, b) => a.label.localeCompare(b.label)); + this.state = new ReactiveStore("post-language-dialog"); + this.state.$checkedLanguages = new Signal.State( + this.currentLanguages.filter((code) => POST_LANGUAGE_CODES.has(code)), + ); + this.state.$query = new Signal.State(""); + this.innerHTML = ""; + this._disposeEffect = effect(() => { + this.render(); + }); + this.initialized = true; + } + + disconnectedCallback() { + this._disposeEffect?.(); + this._disposeEffect = null; + this.scrollLock?.release(); + this.scrollLock = null; + } + + _toggleLanguage(code) { + const checkedLanguages = untrack(() => this.state.$checkedLanguages.get()); + if (checkedLanguages.includes(code)) { + this.state.$checkedLanguages.set( + checkedLanguages.filter((checkedCode) => checkedCode !== code), + ); + } else if (checkedLanguages.length < MAX_POST_LANGUAGES) { + this.state.$checkedLanguages.set([...checkedLanguages, code]); + } + } + + _done() { + this.dispatchEvent( + new CustomEvent("select-languages", { + detail: { + languages: untrack(() => this.state.$checkedLanguages.get()), + }, + }), + ); + this.close(); + } + + render() { + const checkedLanguages = this.state.$checkedLanguages.get(); + const query = this.state.$query.get(); + render( + html` + { + if (event.target === event.currentTarget) { + this.close(); + } + }} + @cancel=${(event) => { + event.preventDefault(); + this.close(); + }} + @close=${() => { + this.scrollLock?.release(); + this.scrollLock = null; + this.dispatchEvent(new CustomEvent("dialog-closed")); + }} + > +
+ ${postLanguageDialogTemplate({ + query, + recentLanguages: this._recentLanguages, + allLanguages: this._allLanguages, + checkedLanguages, + onQueryChange: (value) => this.state.$query.set(value), + onToggle: (code) => this._toggleLanguage(code), + onDone: () => this._done(), + })} +
+
+ `, + this, + ); + } + + open() { + this.scrollLock ??= scrollLocks.acquire({ target: this }); + const dialog = this.querySelector(".post-language-dialog"); + if (dialog?.open) return; + dialog.showModal(); + enableDragToDismiss(dialog, { + onDismiss: () => this.close(), + allowOppositeStretch: true, + scrollContainer: this.querySelector(".post-language-dialog-list"), + ignoreTouchTarget: (element) => + element.closest("button, label, input") !== null, + }); + } + + close() { + return closeWithAnimation(this.querySelector(".post-language-dialog")); + } +} + +PostLanguageDialog.register(); diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index 2380231f..e8e5c265 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -469,6 +469,9 @@ export class Derived extends ReactiveStore { this.$trendingHidden = new Signal.Computed(() => this.sessionState.$trendingHidden.get(), ); + this.$postLanguageHistory = new Signal.Computed(() => + this.sessionState.$postLanguageHistory.get(), + ); this.$postSearchResultsTop = new Signal.Computed(() => this.hydratePostSearchResults(this.dataStore.$postSearchResultsTop), ); diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index bfac6e28..1e9e56fb 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -18,6 +18,7 @@ import { getReplyRootFromPost, } from "/js/dataHelpers.js"; import { + addPostLanguagesToHistory, batch, getCurrentTimestamp, truncateGraphemes, @@ -588,6 +589,13 @@ export class Mutations { this.sessionState.$trendingHidden.set(hidden); } + updatePostLanguageHistory(languages) { + const history = untrack(() => this.sessionState.$postLanguageHistory.get()); + this.sessionState.$postLanguageHistory.set( + addPostLanguagesToHistory(history, languages), + ); + } + async hidePost(post) { const patchId = this.patchStore.addPostPatch(post.uri, { type: "hidePost", @@ -1985,6 +1993,7 @@ export class Mutations { posts, replyTo, replyRoot, + langs, threadgateAllow, postgateEmbeddingRules, signal = null, @@ -1993,6 +2002,7 @@ export class Mutations { posts, replyTo, replyRoot, + langs, threadgateAllow, postgateEmbeddingRules, signal, diff --git a/src/js/dataLayer/sessionState.js b/src/js/dataLayer/sessionState.js index b0998244..e341e6e1 100644 --- a/src/js/dataLayer/sessionState.js +++ b/src/js/dataLayer/sessionState.js @@ -1,4 +1,5 @@ import { Signal, ReactiveStore, PersistedReactiveStore } from "/js/signals.js"; +import { getDefaultPostLanguageHistory } from "/js/utils.js"; const LEGACY_SELECTED_FEED_URI_STORAGE_KEY = "home-view-currentFeedUri"; const LEGACY_DISPLAY_PREFERENCES_STORAGE_KEY = "display-preferences"; @@ -73,5 +74,8 @@ export function createSessionState(session) { // The home view's selected feed, shared with the pinned feeds pane sessionState.$selectedFeedUri = new Signal.State(null); sessionState.$trendingHidden = new Signal.State(false); + sessionState.$postLanguageHistory = new Signal.State( + getDefaultPostLanguageHistory(), + ); return sessionState; } diff --git a/src/js/drafts.js b/src/js/drafts.js index 85a55eaa..3e283c73 100644 --- a/src/js/drafts.js +++ b/src/js/drafts.js @@ -249,5 +249,8 @@ export function buildDraftFromComposerSnapshot(snapshot) { if (snapshot.postgateEmbeddingRules?.length > 0) { draft.postgateEmbeddingRules = snapshot.postgateEmbeddingRules; } + if (snapshot.langs?.length > 0) { + draft.langs = snapshot.langs; + } return { draft, media }; } diff --git a/src/js/languages.js b/src/js/languages.js new file mode 100644 index 00000000..3e4bb1df --- /dev/null +++ b/src/js/languages.js @@ -0,0 +1,195 @@ +// Languages with an ISO 639-1 code, from social-app's src/locale/languages.ts +export const POST_LANGUAGES = [ + { code2: "aa", name: "Afar" }, + { code2: "ab", name: "Abkhazian" }, + { code2: "af", name: "Afrikaans" }, + { code2: "ak", name: "Akan" }, + { code2: "sq", name: "Albanian" }, + { code2: "am", name: "Amharic" }, + { code2: "ar", name: "Arabic" }, + { code2: "an", name: "Aragonese" }, + { code2: "hy", name: "Armenian" }, + { code2: "as", name: "Assamese" }, + { code2: "av", name: "Avaric" }, + { code2: "ae", name: "Avestan" }, + { code2: "ay", name: "Aymara" }, + { code2: "az", name: "Azerbaijani" }, + { code2: "ba", name: "Bashkir" }, + { code2: "bm", name: "Bambara" }, + { code2: "eu", name: "Basque" }, + { code2: "be", name: "Belarusian" }, + { code2: "bn", name: "Bangla" }, + { code2: "bh", name: "Bhojpuri" }, + { code2: "bi", name: "Bislama" }, + { code2: "bo", name: "Tibetan" }, + { code2: "bs", name: "Bosnian" }, + { code2: "br", name: "Breton" }, + { code2: "bg", name: "Bulgarian" }, + { code2: "my", name: "Burmese" }, + { code2: "ca", name: "Catalan" }, + { code2: "cs", name: "Czech" }, + { code2: "ch", name: "Chamorro" }, + { code2: "ce", name: "Chechen" }, + { code2: "zh", name: "Chinese" }, + { code2: "cu", name: "Church Slavic" }, + { code2: "cv", name: "Chuvash" }, + { code2: "kw", name: "Cornish" }, + { code2: "co", name: "Corsican" }, + { code2: "cr", name: "Cree" }, + { code2: "cy", name: "Welsh" }, + { code2: "da", name: "Danish" }, + { code2: "de", name: "German" }, + { code2: "dv", name: "Divehi" }, + { code2: "nl", name: "Dutch" }, + { code2: "dz", name: "Dzongkha" }, + { code2: "el", name: "Greek" }, + { code2: "en", name: "English" }, + { code2: "eo", name: "Esperanto" }, + { code2: "et", name: "Estonian" }, + { code2: "ee", name: "Ewe" }, + { code2: "fo", name: "Faroese" }, + { code2: "fa", name: "Persian" }, + { code2: "fj", name: "Fijian" }, + { code2: "fi", name: "Finnish" }, + { code2: "fr", name: "French" }, + { code2: "fy", name: "Western Frisian" }, + { code2: "ff", name: "Fulah" }, + { code2: "ka", name: "Georgian" }, + { code2: "gd", name: "Scottish Gaelic" }, + { code2: "ga", name: "Irish" }, + { code2: "gl", name: "Galician" }, + { code2: "gv", name: "Manx" }, + { code2: "gn", name: "Guarani" }, + { code2: "gu", name: "Gujarati" }, + { code2: "ht", name: "Haitian Creole" }, + { code2: "ha", name: "Hausa" }, + { code2: "he", name: "Hebrew" }, + { code2: "hz", name: "Herero" }, + { code2: "hi", name: "Hindi" }, + { code2: "ho", name: "Hiri Motu" }, + { code2: "hr", name: "Croatian" }, + { code2: "hu", name: "Hungarian" }, + { code2: "ig", name: "Igbo" }, + { code2: "is", name: "Icelandic" }, + { code2: "io", name: "Ido" }, + { code2: "ii", name: "Sichuan Yi; Nuosu" }, + { code2: "iu", name: "Inuktitut" }, + { code2: "ie", name: "Interlingue" }, + { code2: "ia", name: "Interlingua" }, + { code2: "id", name: "Indonesian" }, + { code2: "ik", name: "Inupiaq" }, + { code2: "it", name: "Italian" }, + { code2: "jv", name: "Javanese" }, + { code2: "ja", name: "Japanese" }, + { code2: "kl", name: "Kalaallisut" }, + { code2: "kn", name: "Kannada" }, + { code2: "ks", name: "Kashmiri" }, + { code2: "kr", name: "Kanuri" }, + { code2: "kk", name: "Kazakh" }, + { code2: "km", name: "Khmer" }, + { code2: "ki", name: "Kikuyu; Gikuyu" }, + { code2: "rw", name: "Kinyarwanda" }, + { code2: "ky", name: "Kyrgyz" }, + { code2: "kv", name: "Komi" }, + { code2: "kg", name: "Kongo" }, + { code2: "ko", name: "Korean" }, + { code2: "kj", name: "Kuanyama; Kwanyama" }, + { code2: "ku", name: "Kurdish" }, + { code2: "lo", name: "Lao" }, + { code2: "la", name: "Latin" }, + { code2: "lv", name: "Latvian" }, + { code2: "li", name: "Limburgish" }, + { code2: "ln", name: "Lingala" }, + { code2: "lt", name: "Lithuanian" }, + { code2: "lb", name: "Luxembourgish" }, + { code2: "lu", name: "Luba-Katanga" }, + { code2: "lg", name: "Ganda" }, + { code2: "mk", name: "Macedonian" }, + { code2: "mh", name: "Marshallese" }, + { code2: "ml", name: "Malayalam" }, + { code2: "mi", name: "Māori" }, + { code2: "mr", name: "Marathi" }, + { code2: "ms", name: "Malay" }, + { code2: "mg", name: "Malagasy" }, + { code2: "mt", name: "Maltese" }, + { code2: "mn", name: "Mongolian" }, + { code2: "na", name: "Nauru" }, + { code2: "nv", name: "Navajo" }, + { code2: "nr", name: "South Ndebele" }, + { code2: "nd", name: "North Ndebele" }, + { code2: "ng", name: "Ndonga" }, + { code2: "ne", name: "Nepali" }, + { code2: "nn", name: "Norwegian Nynorsk" }, + { code2: "nb", name: "Norwegian Bokmål" }, + { code2: "no", name: "Norwegian" }, + { code2: "ny", name: "Nyanja" }, + { code2: "oc", name: "Occitan" }, + { code2: "oj", name: "Ojibwa" }, + { code2: "or", name: "Odia" }, + { code2: "om", name: "Oromo" }, + { code2: "os", name: "Ossetic" }, + { code2: "pa", name: "Punjabi" }, + { code2: "pi", name: "Pali" }, + { code2: "pl", name: "Polish" }, + { code2: "pt", name: "Portuguese" }, + { code2: "ps", name: "Pashto" }, + { code2: "qu", name: "Quechua" }, + { code2: "rm", name: "Romansh" }, + { code2: "ro", name: "Romanian" }, + { code2: "rn", name: "Rundi" }, + { code2: "ru", name: "Russian" }, + { code2: "sg", name: "Sango" }, + { code2: "sa", name: "Sanskrit" }, + { code2: "si", name: "Sinhala" }, + { code2: "sk", name: "Slovak" }, + { code2: "sl", name: "Slovenian" }, + { code2: "se", name: "Northern Sami" }, + { code2: "sm", name: "Samoan" }, + { code2: "sn", name: "Shona" }, + { code2: "sd", name: "Sindhi" }, + { code2: "so", name: "Somali" }, + { code2: "st", name: "Southern Sotho" }, + { code2: "es", name: "Spanish" }, + { code2: "sc", name: "Sardinian" }, + { code2: "sr", name: "Serbian" }, + { code2: "ss", name: "Swati" }, + { code2: "su", name: "Sundanese" }, + { code2: "sw", name: "Swahili" }, + { code2: "sv", name: "Swedish" }, + { code2: "ty", name: "Tahitian" }, + { code2: "ta", name: "Tamil" }, + { code2: "tt", name: "Tatar" }, + { code2: "te", name: "Telugu" }, + { code2: "tg", name: "Tajik" }, + { code2: "tl", name: "Filipino" }, + { code2: "th", name: "Thai" }, + { code2: "ti", name: "Tigrinya" }, + { code2: "to", name: "Tongan" }, + { code2: "tn", name: "Tswana" }, + { code2: "ts", name: "Tsonga" }, + { code2: "tk", name: "Turkmen" }, + { code2: "tr", name: "Turkish" }, + { code2: "tw", name: "Akan" }, + { code2: "ug", name: "Uyghur" }, + { code2: "uk", name: "Ukrainian" }, + { code2: "ur", name: "Urdu" }, + { code2: "uz", name: "Uzbek" }, + { code2: "ve", name: "Venda" }, + { code2: "vi", name: "Vietnamese" }, + { code2: "vo", name: "Volapük" }, + { code2: "wa", name: "Walloon" }, + { code2: "wo", name: "Wolof" }, + { code2: "xh", name: "Xhosa" }, + { code2: "yi", name: "Yiddish" }, + { code2: "yo", name: "Yoruba" }, + { code2: "za", name: "Zhuang; Chuang" }, + { code2: "zu", name: "Zulu" }, +]; + +export const POST_LANGUAGE_CODES = new Set( + POST_LANGUAGES.map((language) => language.code2), +); + +export const POST_LANGUAGE_NAMES = new Map( + POST_LANGUAGES.map((language) => [language.code2, language.name]), +); diff --git a/src/js/postComposerService.js b/src/js/postComposerService.js index e0e00ad9..e1aa8393 100644 --- a/src/js/postComposerService.js +++ b/src/js/postComposerService.js @@ -46,6 +46,7 @@ export class PostComposerService { posts, replyTo: sendReplyTo, replyRoot: sendReplyRoot, + langs, threadgateAllow, postgateEmbeddingRules, draft, @@ -59,10 +60,12 @@ export class PostComposerService { posts, replyTo: sendReplyTo, replyRoot: sendReplyRoot, + langs, threadgateAllow, postgateEmbeddingRules, signal, }); + this.dataLayer.mutations.updatePostLanguageHistory(langs); let toastMessage = "Your post was sent"; if (posts.length > 1) { toastMessage = "Your posts were sent"; diff --git a/src/js/postCreator.js b/src/js/postCreator.js index 90efd355..03c168f7 100644 --- a/src/js/postCreator.js +++ b/src/js/postCreator.js @@ -1,4 +1,4 @@ -import { getPostLangs, isNil, wait } from "/js/utils.js"; +import { isNil, wait } from "/js/utils.js"; import { computeRecordCid, generateTid } from "/js/atproto.js"; import { ImageCompressor } from "/js/imageCompressor.js"; import { @@ -33,6 +33,7 @@ export class PostCreator { posts, replyTo, replyRoot, + langs, threadgateAllow = null, postgateEmbeddingRules = null, signal = null, @@ -52,7 +53,7 @@ export class PostCreator { } const did = this.api.session.did; - const langs = getPostLangs(); + const postLangs = langs.slice(0, 3); const writes = []; const uris = []; // Sort order for posts sharing a createdAt is undefined, so each post @@ -72,7 +73,7 @@ export class PostCreator { text, facets, createdAt, - langs, + langs: postLangs, }; if (embed) { record.embed = embed; diff --git a/src/js/utils.js b/src/js/utils.js index 94a74c34..a0974190 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -1,4 +1,5 @@ import { Capacitor } from "/js/lib/capacitor.js"; +import { POST_LANGUAGE_CODES, POST_LANGUAGE_NAMES } from "/js/languages.js"; export function noop() {} @@ -703,9 +704,54 @@ export function getBrowserLanguageCodes() { ); } -export function getPostLangs() { - const codes = getBrowserLanguageCodes(); - return codes.length ? codes.slice(0, 3) : ["en"]; +const MAX_POST_LANGUAGE_HISTORY = 6; + +export function arePostLanguagesEqual(languages, otherLanguages) { + return ( + languages.length === otherLanguages.length && + languages.every((code) => otherLanguages.includes(code)) + ); +} + +function getBrowserPostLanguageCodes() { + return getBrowserLanguageCodes().filter((code) => + POST_LANGUAGE_CODES.has(code), + ); +} + +export function getPrimaryPostLanguage() { + return getBrowserPostLanguageCodes()[0] ?? "en"; +} + +export function getDefaultPostLanguageHistory() { + return unique([...getBrowserPostLanguageCodes(), "en", "ja", "pt", "de"]) + .slice(0, MAX_POST_LANGUAGE_HISTORY) + .map((code) => [code]); +} + +export function addPostLanguagesToHistory(history, languages) { + return [ + languages, + ...history.filter((entry) => !arePostLanguagesEqual(entry, languages)), + ].slice(0, MAX_POST_LANGUAGE_HISTORY); +} + +let languageDisplayNames = null; + +export function getNameForLanguageCode(code) { + try { + languageDisplayNames ??= new Intl.DisplayNames(["en"], { + type: "language", + fallback: "none", + }); + const name = languageDisplayNames.of(code); + if (name) { + return name; + } + } catch { + // Malformed codes throw a RangeError + } + return POST_LANGUAGE_NAMES.get(code) ?? code; } export function sanitizeUri(uri) { diff --git a/tests/e2e/specs/flows/postLanguage.test.js b/tests/e2e/specs/flows/postLanguage.test.js new file mode 100644 index 00000000..f139b0d8 --- /dev/null +++ b/tests/e2e/specs/flows/postLanguage.test.js @@ -0,0 +1,108 @@ +import { test, expect } from "../../base.js"; +import { login } from "../../helpers.js"; +import { MockServer } from "../../mockServer.js"; + +test.use({ locale: "en-US" }); + +async function openComposer(page) { + await page.locator('[data-testid="sidebar-compose-button"]').click(); + const composer = page.locator("post-composer .post-composer"); + await expect(composer).toBeVisible({ timeout: 10000 }); + return composer; +} + +async function openLanguageMenu(page, composer) { + await composer.locator('[data-testid="composer-language-button"]').click(); + const menu = page.locator(".post-language-menu .context-menu"); + await expect(menu).toBeVisible(); + return menu; +} + +async function openLanguageDialog(page, composer) { + const menu = await openLanguageMenu(page, composer); + await menu.locator('[data-testid="menu-action-more-languages"]').click(); + const dialog = page.locator('[data-testid="post-language-dialog"]'); + await expect(dialog).toBeVisible(); + return dialog; +} + +async function publish(composer, text) { + await composer.locator(".rich-text-input").first().click(); + await composer.locator(".rich-text-input").first().type(text); + await composer.locator('[data-testid="composer-submit-button"]').click(); + await expect(composer).not.toBeVisible({ timeout: 10000 }); +} + +test.describe("Post language flow", () => { + let mockServer; + + test.beforeEach(async ({ page }) => { + mockServer = new MockServer(); + await mockServer.setup(page); + await login(page); + await page.goto("/"); + await expect(page.locator("#home-view")).toBeVisible({ timeout: 10000 }); + }); + + test("publishes with the primary language by default", async ({ page }) => { + const composer = await openComposer(page); + await publish(composer, "Hello"); + expect(mockServer.applyWritesCalls[0][0].value.langs).toEqual(["en"]); + }); + + test("publishes with a language picked from the menu and moves it to the top of the menu", async ({ + page, + }) => { + let composer = await openComposer(page); + let menu = await openLanguageMenu(page, composer); + await menu.locator('[data-testid="menu-action-post-language-ja"]').click(); + await expect(menu).not.toBeVisible(); + await publish(composer, "こんにちは"); + expect(mockServer.applyWritesCalls[0][0].value.langs).toEqual(["ja"]); + + composer = await openComposer(page); + menu = await openLanguageMenu(page, composer); + const items = menu.locator('[data-testid^="menu-action-post-language-"]'); + await expect(items.first()).toHaveAttribute( + "data-testid", + "menu-action-post-language-ja", + ); + await expect( + menu.locator('[data-testid="menu-action-post-language-en"]'), + ).toHaveAttribute("data-teststate", "selected"); + }); + + test("publishes with up to three languages chosen in the dialog", async ({ + page, + }) => { + const composer = await openComposer(page); + const dialog = await openLanguageDialog(page, composer); + await dialog.locator('[data-testid="post-language-option-de"]').click(); + await dialog.locator('[data-testid="post-language-option-fr"]').click(); + await expect( + dialog.locator('[data-testid="post-language-option-es"] input'), + ).toBeDisabled(); + await dialog.locator('[data-testid="post-language-done-button"]').click(); + await expect(dialog).not.toBeVisible(); + + await publish(composer, "Hallo, bonjour"); + expect(mockServer.applyWritesCalls[0][0].value.langs).toEqual([ + "en", + "de", + "fr", + ]); + }); + + test("keeps the current languages when the dialog is dismissed", async ({ + page, + }) => { + const composer = await openComposer(page); + const dialog = await openLanguageDialog(page, composer); + await dialog.locator('[data-testid="post-language-option-de"]').click(); + await page.keyboard.press("Escape"); + await expect(dialog).not.toBeVisible(); + + await publish(composer, "Hello"); + expect(mockServer.applyWritesCalls[0][0].value.langs).toEqual(["en"]); + }); +}); diff --git a/tests/unit/specs/components/post-composer.test.js b/tests/unit/specs/components/post-composer.test.js index 049b2a7e..eaf58f10 100644 --- a/tests/unit/specs/components/post-composer.test.js +++ b/tests/unit/specs/components/post-composer.test.js @@ -10,6 +10,7 @@ import { } from "../../testHelpers.js"; import { ApiError } from "/js/api.js"; import { getDraftDeviceId } from "/js/drafts.js"; +import { getPrimaryPostLanguage } from "/js/utils.js"; import { LINK_CARD_SERVICE_URL } from "/js/config.js"; import { createGif } from "../../../shared/factories.js"; import "/js/components/post-composer.js"; @@ -34,6 +35,7 @@ describe("post-composer", () => { function createPostComposer({ draftsEnabled = true } = {}) { const element = document.createElement("post-composer"); + element.dataLayer = makeTestDataLayer(); element.draftsEnabled = draftsEnabled; element.currentUser = { did: "did:plc:test", @@ -262,6 +264,164 @@ describe("post-composer", () => { }); }); + describe("PostComposer - post language", () => { + async function sendAndCaptureDetail(element) { + patchFirstPost(element, { text: "Hello world" }); + let receivedDetail = null; + element.addEventListener("send-post", (e) => { + receivedDetail = e.detail; + }); + await element.send(); + return receivedDetail; + } + + function getLanguageButton(element) { + return element.querySelector('[data-testid="composer-language-button"]'); + } + + it("sends the primary language by default", async () => { + const element = createPostComposer(); + connectElement(element); + const detail = await sendAndCaptureDetail(element); + assert.deepEqual(detail.langs, [getPrimaryPostLanguage()]); + }); + + it("lists history entries plus the primary language in the menu", async () => { + const element = createPostComposer(); + element.dataLayer.sessionState.$postLanguageHistory.set([ + ["ja"], + ["en", "ja"], + ]); + connectElement(element); + await nextFrame(); + const testids = [ + ...element.querySelectorAll( + '.post-language-menu context-menu-item[data-testid^="menu-action-post-language-"]', + ), + ].map((item) => item.dataset.testid); + assert.deepEqual(testids, [ + "menu-action-post-language-ja", + "menu-action-post-language-en-ja", + `menu-action-post-language-${getPrimaryPostLanguage()}`, + ]); + const primaryItem = element.querySelector( + `[data-testid="menu-action-post-language-${getPrimaryPostLanguage()}"]`, + ); + assert.deepEqual(primaryItem.dataset.teststate, "selected"); + }); + + it("uses a language combination picked from the menu", async () => { + const element = createPostComposer(); + element.dataLayer.sessionState.$postLanguageHistory.set([["en", "ja"]]); + connectElement(element); + await nextFrame(); + element + .querySelector('[data-testid="menu-action-post-language-en-ja"] button') + .click(); + await nextFrame(); + assert.deepEqual( + getLanguageButton(element).textContent.trim(), + "English, Japanese", + ); + assert.deepEqual( + element.querySelector('[data-testid="menu-action-post-language-en-ja"]') + .dataset.teststate, + "selected", + ); + const detail = await sendAndCaptureDetail(element); + assert.deepEqual(detail.langs, ["en", "ja"]); + }); + + it("moves the check mark when the selection changes", async () => { + const element = createPostComposer(); + element.dataLayer.sessionState.$postLanguageHistory.set([["ja"], ["de"]]); + connectElement(element); + await nextFrame(); + const getItem = (code) => + element.querySelector( + `[data-testid="menu-action-post-language-${code}"]`, + ); + const checkedTestids = () => + [ + ...element.querySelectorAll( + '.post-language-menu app-icon[icon="check"]', + ), + ].map((icon) => icon.closest("context-menu-item").dataset.testid); + + getItem("ja").querySelector("button").click(); + await nextFrame(); + assert.deepEqual(checkedTestids(), ["menu-action-post-language-ja"]); + + getItem("de").querySelector("button").click(); + await nextFrame(); + assert.deepEqual(checkedTestids(), ["menu-action-post-language-de"]); + assert.deepEqual(getItem("ja").dataset.teststate, "unselected"); + assert.deepEqual(getItem("de").dataset.teststate, "selected"); + assert.deepEqual(getLanguageButton(element).textContent.trim(), "German"); + }); + + it("applies languages chosen in the dialog", async () => { + const element = createPostComposer(); + connectElement(element); + element.openPostLanguageDialog(); + const dialog = document.querySelector("post-language-dialog"); + dialog.dispatchEvent( + new CustomEvent("select-languages", { + detail: { languages: ["de", "fr"] }, + }), + ); + const detail = await sendAndCaptureDetail(element); + assert.deepEqual(detail.langs, ["de", "fr"]); + }); + + it("falls back to the primary language when the dialog returns none", async () => { + const element = createPostComposer(); + connectElement(element); + element.setPostLanguages(["de"]); + element.openPostLanguageDialog(); + document + .querySelector("post-language-dialog") + .dispatchEvent( + new CustomEvent("select-languages", { detail: { languages: [] } }), + ); + const detail = await sendAndCaptureDetail(element); + assert.deepEqual(detail.langs, [getPrimaryPostLanguage()]); + }); + + it("resets the language when the composer is cleared", async () => { + const element = createPostComposer(); + connectElement(element); + element.setPostLanguages(["ja"]); + element.clearComposer(); + const detail = await sendAndCaptureDetail(element); + assert.deepEqual(detail.langs, [getPrimaryPostLanguage()]); + }); + + it("saves the chosen languages in the draft snapshot", () => { + const element = createPostComposer(); + connectElement(element); + assert.deepEqual(element.buildDraftSnapshot().langs, null); + element.setPostLanguages(["ja", "en"]); + assert.deepEqual(element.buildDraftSnapshot().langs, ["ja", "en"]); + }); + + it("restores known draft languages", async () => { + const element = createPostComposer(); + connectElement(element); + await element.restoreFromDraft({ + id: "draft-1", + draft: { + deviceId: getDraftDeviceId(), + deviceName: "Web", + langs: ["ja", "not-a-language"], + posts: [{ text: "draft" }], + }, + }); + const detail = await sendAndCaptureDetail(element); + assert.deepEqual(detail.langs, ["ja"]); + }); + }); + describe("PostComposer - send method", () => { it("should set isSending to true when send() is called", async () => { const element = createPostComposer(); @@ -1317,6 +1477,7 @@ describe("post-composer", () => { it("preserves quotedRecord set before connectedCallback and renders its preview", () => { const preSeeded = document.createElement("post-composer"); + preSeeded.dataLayer = element.dataLayer; preSeeded.currentUser = element.currentUser; preSeeded.quotedRecord = { $type: "app.bsky.feed.defs#generatorView", diff --git a/tests/unit/specs/components/post-language-dialog.test.js b/tests/unit/specs/components/post-language-dialog.test.js new file mode 100644 index 00000000..ceffbb6a --- /dev/null +++ b/tests/unit/specs/components/post-language-dialog.test.js @@ -0,0 +1,147 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import "/js/components/post-language-dialog.js"; + +describe("post-language-dialog", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + async function flushRender() { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + async function createDialog({ + currentLanguages = ["en"], + postLanguageHistory = [["en"], ["ja"]], + } = {}) { + const element = document.createElement("post-language-dialog"); + element.currentLanguages = currentLanguages; + element.postLanguageHistory = postLanguageHistory; + const events = { selected: [], closed: 0 }; + element.addEventListener("select-languages", (e) => { + events.selected.push(e.detail.languages); + }); + element.addEventListener("dialog-closed", () => { + events.closed++; + }); + document.body.appendChild(element); + element.open(); + await flushRender(); + return { element, events }; + } + + function getOption(element, code) { + return element.querySelector( + `[data-testid="post-language-option-${code}"]`, + ); + } + + function sectionCodes(element, section) { + return [ + ...element.querySelectorAll( + `[data-testid="post-language-section-${section}"] [data-testid^="post-language-option-"]`, + ), + ].map((row) => row.dataset.testid.replace("post-language-option-", "")); + } + + async function toggle(element, code) { + const input = getOption(element, code).querySelector("input"); + input.checked = !input.checked; + input.dispatchEvent(new window.Event("change", { bubbles: true })); + await flushRender(); + } + + async function search(element, query) { + const input = element.querySelector( + '[data-testid="post-language-search-input"]', + ); + input.value = query; + input.dispatchEvent(new window.Event("input", { bubbles: true })); + await flushRender(); + } + + function clickDone(element) { + element.querySelector('[data-testid="post-language-done-button"]').click(); + } + + it("checks the current languages on open", async () => { + const { element } = await createDialog({ currentLanguages: ["ja", "en"] }); + assert.deepEqual(getOption(element, "ja").dataset.teststate, "checked"); + assert.deepEqual(getOption(element, "en").dataset.teststate, "checked"); + assert.deepEqual(getOption(element, "de").dataset.teststate, "unchecked"); + }); + + it("splits combination history entries into recent languages", async () => { + const { element } = await createDialog({ + currentLanguages: ["pt"], + postLanguageHistory: [["en", "ja"], ["de"], ["pt"]], + }); + assert.deepEqual(sectionCodes(element, "recent"), ["pt", "en", "ja", "de"]); + assert(!sectionCodes(element, "all").includes("en")); + }); + + it("sorts the remaining languages by name", async () => { + const { element } = await createDialog(); + const allCodes = sectionCodes(element, "all"); + // French sorts before German by name, but after it by code + assert(allCodes.indexOf("fr") < allCodes.indexOf("de")); + }); + + it("dispatches the checked languages in selection order on Done", async () => { + const { element, events } = await createDialog({ + currentLanguages: ["en"], + }); + await toggle(element, "de"); + await toggle(element, "en"); + await toggle(element, "ja"); + clickDone(element); + assert.deepEqual(events.selected, [["de", "ja"]]); + }); + + it("disables unchecked languages once three are checked", async () => { + const { element } = await createDialog({ currentLanguages: ["en"] }); + await toggle(element, "ja"); + await toggle(element, "de"); + assert(getOption(element, "fr").querySelector("input").disabled); + assert(!getOption(element, "de").querySelector("input").disabled); + await toggle(element, "de"); + assert(!getOption(element, "fr").querySelector("input").disabled); + }); + + it("does not dispatch a selection when closed without Done", async () => { + const { element, events } = await createDialog(); + await toggle(element, "de"); + element + .querySelector(".post-language-dialog") + .dispatchEvent(new window.Event("cancel", { cancelable: true })); + await element.close(); + assert.deepEqual(events.selected, []); + }); + + it("filters by name, ignoring case and accents, and keeps checked rows", async () => { + const { element } = await createDialog({ currentLanguages: ["en"] }); + await search(element, " GERMAN "); + assert.deepEqual(sectionCodes(element, "recent"), ["en"]); + assert.deepEqual(sectionCodes(element, "all"), ["de"]); + await search(element, "frénch"); + assert.deepEqual(sectionCodes(element, "all"), ["fr"]); + }); + + it("matches a language code exactly", async () => { + const { element } = await createDialog({ currentLanguages: ["en"] }); + await search(element, "de"); + assert(sectionCodes(element, "all").includes("de")); + }); + + it("shows an empty state when nothing unchecked matches", async () => { + const { element } = await createDialog(); + await search(element, "zzzz"); + assert(element.querySelector('[data-testid="empty-state"]')); + await search(element, ""); + assert.deepEqual( + element.querySelector('[data-testid="empty-state"]'), + null, + ); + }); +}); diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js index d1132da8..e5e336d0 100644 --- a/tests/unit/specs/dataLayer/mutations.test.js +++ b/tests/unit/specs/dataLayer/mutations.test.js @@ -5981,6 +5981,27 @@ describe("setSelectedFeedUri", () => { }); }); +describe("updatePostLanguageHistory", () => { + it("moves the published language combination to the front", () => { + const dataStore = new DataStore(createSessionState(null)); + const mutations = makeMutations({}, dataStore, new PatchStore(), {}); + dataStore.sessionState.$postLanguageHistory.set([["en"], ["ja"], ["pt"]]); + mutations.updatePostLanguageHistory(["ja"]); + assert.deepEqual(dataStore.sessionState.$postLanguageHistory.get(), [ + ["ja"], + ["en"], + ["pt"], + ]); + mutations.updatePostLanguageHistory(["en", "ja"]); + assert.deepEqual(dataStore.sessionState.$postLanguageHistory.get(), [ + ["en", "ja"], + ["ja"], + ["en"], + ["pt"], + ]); + }); +}); + describe("updatePostInteractionSettings", () => { function makePreferenceMutations(initialPrefs) { let updatedPreferences = null; diff --git a/tests/unit/specs/dataLayer/sessionState.test.js b/tests/unit/specs/dataLayer/sessionState.test.js index d1ccab36..a1c571bb 100644 --- a/tests/unit/specs/dataLayer/sessionState.test.js +++ b/tests/unit/specs/dataLayer/sessionState.test.js @@ -88,6 +88,22 @@ describe("createSessionState", () => { }); }); + describe("postLanguageHistory", () => { + it("persists history for the account once it changes", async () => { + const sessionState = createSessionState(session); + sessionState.$postLanguageHistory.set([["ja", "en"], ["en"]]); + await flushEffects(); + assert.deepEqual(stored(), { + postLanguageHistory: [["ja", "en"], ["en"]], + }); + const restored = createSessionState(session); + assert.deepEqual(restored.$postLanguageHistory.get(), [ + ["ja", "en"], + ["en"], + ]); + }); + }); + describe("trendingHidden", () => { it("defaults to shown", () => { const sessionState = createSessionState(session); diff --git a/tests/unit/specs/drafts.test.js b/tests/unit/specs/drafts.test.js index e8968f9f..13907580 100644 --- a/tests/unit/specs/drafts.test.js +++ b/tests/unit/specs/drafts.test.js @@ -180,6 +180,7 @@ describe("buildDraftFromComposerSnapshot", () => { const { threadgateAllow = null, postgateEmbeddingRules = null, + langs = null, ...postOverrides } = overrides; return { @@ -196,6 +197,7 @@ describe("buildDraftFromComposerSnapshot", () => { ], threadgateAllow, postgateEmbeddingRules, + langs, }; } @@ -352,6 +354,18 @@ describe("buildDraftFromComposerSnapshot", () => { assert.deepEqual(draft.postgateEmbeddingRules.length, 1); }); + it("carries chosen post languages", () => { + const { draft } = buildDraftFromComposerSnapshot( + makeDraftSnapshot({ langs: ["ja", "en"] }), + ); + assert.deepEqual(draft.langs, ["ja", "en"]); + }); + + it("omits langs when no language was chosen", () => { + const { draft } = buildDraftFromComposerSnapshot(makeDraftSnapshot()); + assert.deepEqual(draft.langs, undefined); + }); + it("omits empty postgate rules", () => { const { draft } = buildDraftFromComposerSnapshot( makeDraftSnapshot({ postgateEmbeddingRules: [] }), diff --git a/tests/unit/specs/postCreator.test.js b/tests/unit/specs/postCreator.test.js index af9254e7..a18ef5f4 100644 --- a/tests/unit/specs/postCreator.test.js +++ b/tests/unit/specs/postCreator.test.js @@ -45,10 +45,12 @@ function createSinglePost(pc, options = {}) { replyRoot, threadgateAllow = null, postgateEmbeddingRules = null, + langs = ["en"], ...postFields } = options; return pc.createThread({ posts: [postFields], + langs, replyTo, replyRoot, threadgateAllow, @@ -158,12 +160,21 @@ describe("video embed preparation", () => { assert(!("alt" in api.lastEmbed)); }); - it("forwards langs to api.createPost", async () => { + it("writes the given langs on the record", async () => { const api = makeApi(); const pc = new PostCreator(api, mockIdentityResolver); - await createSinglePost(pc, { postText: "hi" }); - assert(Array.isArray(api.lastLangs)); - assert(api.lastLangs.length > 0); + await createSinglePost(pc, { postText: "hi", langs: ["ja", "en"] }); + assert.deepEqual(api.lastLangs, ["ja", "en"]); + }); + + it("caps langs at 3", async () => { + const api = makeApi(); + const pc = new PostCreator(api, mockIdentityResolver); + await createSinglePost(pc, { + postText: "hi", + langs: ["en", "ja", "de", "fr"], + }); + assert.deepEqual(api.lastLangs, ["en", "ja", "de"]); }); }); @@ -757,7 +768,10 @@ describe("createThread", () => { it("publishes a single post as one applyWrites create", async () => { const api = makeThreadApi(); const pc = new PostCreator(api, mockIdentityResolver); - const res = await pc.createThread({ posts: [{ postText: "solo post" }] }); + const res = await pc.createThread({ + posts: [{ postText: "solo post" }], + langs: ["en"], + }); assert.deepEqual(api.applyWritesCalls.length, 1); const writes = api.applyWritesCalls[0]; assert.deepEqual(writes.length, 1); @@ -777,10 +791,14 @@ describe("createThread", () => { const api = makeThreadApi(); const pc = new PostCreator(api, mockIdentityResolver); const res = await pc.createThread({ + langs: ["ja", "en"], posts: [{ postText: "one" }, { postText: "two" }, { postText: "three" }], }); const writes = api.applyWritesCalls[0]; assert.deepEqual(writes.length, 3); + for (const write of writes) { + assert.deepEqual(write.value.langs, ["ja", "en"]); + } const uris = writes.map( (write) => `at://did:plc:user/app.bsky.feed.post/${write.rkey}`, ); @@ -822,6 +840,7 @@ describe("createThread", () => { cid: "leafcid", }; await pc.createThread({ + langs: ["en"], posts: [{ postText: "one" }, { postText: "two" }], replyTo, replyRoot, @@ -843,6 +862,7 @@ describe("createThread", () => { const allow = [{ $type: "app.bsky.feed.threadgate#followingRule" }]; const embeddingRules = [{ $type: "app.bsky.feed.postgate#disableRule" }]; await pc.createThread({ + langs: ["en"], posts: [{ postText: "one" }, { postText: "two" }], threadgateAllow: allow, postgateEmbeddingRules: embeddingRules, @@ -885,6 +905,7 @@ describe("createThread", () => { }; const pc = new PostCreator(api, mockIdentityResolver); const res = await pc.createThread({ + langs: ["en"], posts: [{ postText: "one" }, { postText: "two" }], }); assert.deepEqual(res.posts, null); @@ -913,6 +934,7 @@ describe("send cancellation", () => { await assert.rejects( () => pc.createThread({ + langs: ["en"], posts: [{ postText: "hi" }], signal: controller.signal, }), @@ -938,6 +960,7 @@ describe("send cancellation", () => { await assert.rejects( () => pc.createThread({ + langs: ["en"], posts: [ { postText: "hi", @@ -968,6 +991,7 @@ describe("send cancellation", () => { await assert.rejects( () => pc.createThread({ + langs: ["en"], posts: [ { postText: "hi", @@ -999,6 +1023,7 @@ describe("send cancellation", () => { }; const pc = new PostCreator(api, mockIdentityResolver); const res = await pc.createThread({ + langs: ["en"], posts: [{ postText: "hi" }], signal: controller.signal, }); diff --git a/tests/unit/specs/utils.test.js b/tests/unit/specs/utils.test.js index 68d1fc89..de05e0eb 100644 --- a/tests/unit/specs/utils.test.js +++ b/tests/unit/specs/utils.test.js @@ -19,8 +19,12 @@ import { buildQueryString, ImageLoader, compareVersions, - getPostLangs, getBrowserLanguageCodes, + arePostLanguagesEqual, + getPrimaryPostLanguage, + getDefaultPostLanguageHistory, + addPostLanguagesToHistory, + getNameForLanguageCode, withTimeout, wait, enableLongPress, @@ -829,7 +833,7 @@ describe("compareVersions", () => { }); }); -describe("getPostLangs", () => { +describe("getBrowserLanguageCodes", () => { let originalLanguages; let originalLanguage; @@ -858,33 +862,28 @@ describe("getPostLangs", () => { }); } - it("returns base language codes from navigator.languages", () => { - setLanguages(["en-US", "fr-FR"], "en-US"); - assert.deepEqual(getPostLangs(), ["en", "fr"]); - }); - - it("dedupes language codes", () => { + it("returns deduped base language codes from navigator.languages", () => { setLanguages(["en-US", "en-GB", "fr-FR"], "en-US"); - assert.deepEqual(getPostLangs(), ["en", "fr"]); + assert.deepEqual(getBrowserLanguageCodes(), ["en", "fr"]); }); - it("limits to top 3 codes", () => { + it("does not limit the number of codes", () => { setLanguages(["en", "fr", "de", "es", "ja"], "en"); - assert.deepEqual(getPostLangs(), ["en", "fr", "de"]); + assert.deepEqual(getBrowserLanguageCodes(), ["en", "fr", "de", "es", "ja"]); }); it("falls back to navigator.language when languages is empty", () => { setLanguages([], "es-MX"); - assert.deepEqual(getPostLangs(), ["es"]); + assert.deepEqual(getBrowserLanguageCodes(), ["es"]); }); - it("falls back to ['en'] when no locale info is available", () => { + it("returns an empty array when no locale info is available", () => { setLanguages([], ""); - assert.deepEqual(getPostLangs(), ["en"]); + assert.deepEqual(getBrowserLanguageCodes(), []); }); }); -describe("getBrowserLanguageCodes", () => { +describe("post language helpers", () => { let originalLanguages; let originalLanguage; @@ -913,24 +912,85 @@ describe("getBrowserLanguageCodes", () => { }); } - it("returns deduped base language codes from navigator.languages", () => { - setLanguages(["en-US", "en-GB", "fr-FR"], "en-US"); - assert.deepEqual(getBrowserLanguageCodes(), ["en", "fr"]); + it("compares language combinations regardless of order", () => { + assert(arePostLanguagesEqual(["ja", "en"], ["ja", "en"])); + assert(arePostLanguagesEqual(["ja", "en"], ["en", "ja"])); + assert(!arePostLanguagesEqual(["ja"], ["ja", "en"])); + assert(!arePostLanguagesEqual(["ja", "de"], ["ja", "en"])); }); - it("does not limit the number of codes", () => { - setLanguages(["en", "fr", "de", "es", "ja"], "en"); - assert.deepEqual(getBrowserLanguageCodes(), ["en", "fr", "de", "es", "ja"]); + it("uses the first browser language as the primary language", () => { + setLanguages(["fr-FR", "en-US"], "fr-FR"); + assert.deepEqual(getPrimaryPostLanguage(), "fr"); }); - it("falls back to navigator.language when languages is empty", () => { - setLanguages([], "es-MX"); - assert.deepEqual(getBrowserLanguageCodes(), ["es"]); + it("skips browser languages without a post language code", () => { + setLanguages(["fil-PH", "es-MX"], "fil-PH"); + assert.deepEqual(getPrimaryPostLanguage(), "es"); + assert.deepEqual(getDefaultPostLanguageHistory(), [ + ["es"], + ["en"], + ["ja"], + ["pt"], + ["de"], + ]); }); - it("returns an empty array when no locale info is available", () => { + it("falls back to English as the primary language", () => { setLanguages([], ""); - assert.deepEqual(getBrowserLanguageCodes(), []); + assert.deepEqual(getPrimaryPostLanguage(), "en"); + }); + + it("seeds history with browser languages and common languages, deduped", () => { + setLanguages(["ja-JP", "en-US"], "ja-JP"); + assert.deepEqual(getDefaultPostLanguageHistory(), [ + ["ja"], + ["en"], + ["pt"], + ["de"], + ]); + }); + + it("caps the seeded history at 6 entries", () => { + setLanguages(["fr", "es", "it", "ko", "zh"], "fr"); + assert.deepEqual(getDefaultPostLanguageHistory(), [ + ["fr"], + ["es"], + ["it"], + ["ko"], + ["zh"], + ["en"], + ]); + }); + + it("prepends to history, deduping entries and capping at 6", () => { + const history = [["en"], ["ja"], ["en", "ja"], ["pt"], ["de"], ["fr"]]; + assert.deepEqual(addPostLanguagesToHistory(history, ["pt"]), [ + ["pt"], + ["en"], + ["ja"], + ["en", "ja"], + ["de"], + ["fr"], + ]); + assert.deepEqual(addPostLanguagesToHistory(history, ["ja", "en"]), [ + ["ja", "en"], + ["en"], + ["ja"], + ["pt"], + ["de"], + ["fr"], + ]); + }); +}); + +describe("getNameForLanguageCode", () => { + it("names languages in English", () => { + assert.deepEqual(getNameForLanguageCode("ja"), "Japanese"); + }); + + it("falls back to the code when it can't be named", () => { + assert.deepEqual(getNameForLanguageCode("not a code!"), "not a code!"); }); }); -- 2.51.2