Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
5

Configure Feed

Select the types of activity you want to include in your feed.

Use intents for translate on android

Grace Kind (Jul 17, 2026, 5:36 PM -0500) 96ed6c9f 1e89215c

+146 -3
+19 -2
src/js/templates/postActionBar.template.js
··· 7 7 groupBy, 8 8 noop, 9 9 classnames, 10 + isAndroid, 11 + isFirefox, 10 12 } from "/js/utils.js"; 11 13 import { repostIconTemplate } from "/js/templates/icons/repostIcon.template.js"; 12 14 import { replyIconTemplate } from "/js/templates/icons/replyIcon.template.js"; ··· 27 29 28 30 function getFullPostText(post) { 29 31 return richTextToString(post.record.text, post.record.facets); 32 + } 33 + 34 + // The Google Translate Android app claims translate.google.com links but drops 35 + // their query params, so on Android the text has to be delivered via a 36 + // PROCESS_TEXT intent instead. Firefox doesn't support intents so fallback to the web url 37 + function openTranslator(text, targetLang) { 38 + const webUrl = `https://translate.google.com/?sl=auto&tl=${targetLang}&text=${encodeURIComponent(text)}`; 39 + if (isAndroid() && !isFirefox()) { 40 + window.location.href = 41 + "intent:#Intent;action=android.intent.action.PROCESS_TEXT;type=text/plain;" + 42 + `S.android.intent.extra.PROCESS_TEXT=${encodeURIComponent(text)};` + 43 + "B.android.intent.extra.PROCESS_TEXT_READONLY=true;" + 44 + `S.browser_fallback_url=${encodeURIComponent(webUrl)};end`; 45 + } else { 46 + window.open(webUrl, "_blank"); 47 + } 30 48 } 31 49 32 50 function postContextMenuTemplate({ ··· 73 91 @click=${() => { 74 92 const postText = getFullPostText(post); 75 93 const targetLang = getBrowserLanguageCodes()[0] || "en"; 76 - const url = `https://translate.google.com/?sl=auto&tl=${targetLang}&text=${encodeURIComponent(postText)}`; 77 - window.open(url, "_blank"); 94 + openTranslator(postText, targetLang); 78 95 }} 79 96 > 80 97 Translate
+2
src/js/utils.js
··· 44 44 window.matchMedia("(hover: hover) and (pointer: fine)").matches; 45 45 export const isSafari = () => 46 46 /^((?!chrome|android).)*safari/i.test(navigator.userAgent); 47 + export const isAndroid = () => /android/i.test(navigator.userAgent); 48 + export const isFirefox = () => /firefox/i.test(navigator.userAgent); 47 49 48 50 export function sortBy(array, fnOrKey, { direction = "asc" } = {}) { 49 51 let fn = fnOrKey;
+125 -1
tests/unit/specs/templates/postActionBar.template.test.js
··· 1 - import { describe, it, afterEach } from "node:test"; 1 + import { describe, it, beforeEach, afterEach, mock } from "node:test"; 2 2 import assert from "node:assert/strict"; 3 3 import { postActionBarTemplate } from "/js/templates/postActionBar.template.js"; 4 4 import { post } from "../../testData.js"; 5 5 import { render } from "/js/lib/lit-html.js"; 6 + import { mockWindowLocation, restoreWindow } from "../../testHelpers.js"; 6 7 7 8 describe("postActionBarTemplate", () => { 8 9 it("should render action bar with reply button", () => { ··· 539 540 container.remove(); 540 541 }); 541 542 }); 543 + 544 + describe("postActionBarTemplate - translate menu action", () => { 545 + const androidChromeUserAgent = 546 + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36"; 547 + const androidFirefoxUserAgent = 548 + "Mozilla/5.0 (Android 14; Mobile; rv:126.0) Gecko/126.0 Firefox/126.0"; 549 + const originalUserAgentDescriptor = Object.getOwnPropertyDescriptor( 550 + window.navigator, 551 + "userAgent", 552 + ); 553 + const originalOpen = window.open; 554 + let openMock; 555 + let capturedHrefs; 556 + 557 + beforeEach(() => { 558 + openMock = mock.fn(); 559 + window.open = openMock; 560 + capturedHrefs = mockWindowLocation(); 561 + }); 562 + 563 + afterEach(() => { 564 + restoreWindow(); 565 + window.open = originalOpen; 566 + if (originalUserAgentDescriptor) { 567 + Object.defineProperty( 568 + window.navigator, 569 + "userAgent", 570 + originalUserAgentDescriptor, 571 + ); 572 + } else { 573 + delete window.navigator.userAgent; 574 + } 575 + document.body 576 + .querySelectorAll("context-menu") 577 + .forEach((menu) => menu.remove()); 578 + }); 579 + 580 + function setUserAgent(value) { 581 + Object.defineProperty(window.navigator, "userAgent", { 582 + value, 583 + configurable: true, 584 + }); 585 + } 586 + 587 + function ensurePageVisible() { 588 + if (!document.querySelector(".page-visible")) { 589 + const pageVisible = document.createElement("div"); 590 + pageVisible.classList.add("page-visible"); 591 + document.body.appendChild(pageVisible); 592 + } 593 + } 594 + 595 + async function clickTranslateMenuItem() { 596 + const result = postActionBarTemplate({ 597 + post, 598 + isAuthenticated: true, 599 + currentUser: { did: "did:plc:test" }, 600 + pluginService: { getPostContextMenuItems: async () => [] }, 601 + }); 602 + const container = document.createElement("div"); 603 + document.body.appendChild(container); 604 + render(result, container); 605 + ensurePageVisible(); 606 + const moreButton = Array.from( 607 + container.querySelectorAll(".post-action-button.text-button"), 608 + ).find((button) => button.textContent.trim() === "..."); 609 + moreButton.click(); 610 + await new Promise((resolve) => setTimeout(resolve, 0)); 611 + await new Promise((resolve) => setTimeout(resolve, 0)); 612 + const item = document.body.querySelector( 613 + "[data-testid='menu-action-post-translate']", 614 + ); 615 + assert(item !== null); 616 + item.click(); 617 + container.remove(); 618 + } 619 + 620 + it("should open the Google Translate web URL with the post text", async () => { 621 + await clickTranslateMenuItem(); 622 + assert.deepEqual(openMock.mock.callCount(), 1); 623 + const [url, target] = openMock.mock.calls[0].arguments; 624 + assert.deepEqual(target, "_blank"); 625 + const parsedUrl = new URL(url); 626 + assert.deepEqual(parsedUrl.origin, "https://translate.google.com"); 627 + assert.deepEqual(parsedUrl.searchParams.get("sl"), "auto"); 628 + assert(parsedUrl.searchParams.get("tl")); 629 + assert.deepEqual(parsedUrl.searchParams.get("text"), post.record.text); 630 + assert.deepEqual(capturedHrefs, []); 631 + }); 632 + 633 + it("should launch a PROCESS_TEXT intent on Android", async () => { 634 + setUserAgent(androidChromeUserAgent); 635 + await clickTranslateMenuItem(); 636 + assert.deepEqual(openMock.mock.callCount(), 0); 637 + assert.deepEqual(capturedHrefs.length, 1); 638 + const intentUrl = capturedHrefs[0]; 639 + assert( 640 + intentUrl.startsWith( 641 + "intent:#Intent;action=android.intent.action.PROCESS_TEXT;type=text/plain;", 642 + ), 643 + ); 644 + assert( 645 + intentUrl.includes( 646 + `S.android.intent.extra.PROCESS_TEXT=${encodeURIComponent(post.record.text)};`, 647 + ), 648 + ); 649 + const fallbackMatch = intentUrl.match(/S\.browser_fallback_url=([^;]+);/); 650 + assert(fallbackMatch !== null); 651 + const fallbackUrl = new URL(decodeURIComponent(fallbackMatch[1])); 652 + assert.deepEqual(fallbackUrl.origin, "https://translate.google.com"); 653 + assert.deepEqual(fallbackUrl.searchParams.get("text"), post.record.text); 654 + }); 655 + 656 + it("should keep the web URL on Firefox for Android", async () => { 657 + setUserAgent(androidFirefoxUserAgent); 658 + await clickTranslateMenuItem(); 659 + assert.deepEqual(openMock.mock.callCount(), 1); 660 + const parsedUrl = new URL(openMock.mock.calls[0].arguments[0]); 661 + assert.deepEqual(parsedUrl.origin, "https://translate.google.com"); 662 + assert.deepEqual(parsedUrl.searchParams.get("text"), post.record.text); 663 + assert.deepEqual(capturedHrefs, []); 664 + }); 665 + });