Skip to content
Merged
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
3 changes: 2 additions & 1 deletion apps/web/src/api/queries/get-account-posts-feed-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { appAxios } from "@/api/axios";
import { apiBase } from "@/api/helper";
import { DEFAULT_OBSERVER } from "@/consts/observer";
import { slimEntryPage, withSlimEntries } from "@/core/entries/slim-entry";
import { annotateLanguageHints } from "@/core/entries/language-hint";

// Unify all branches on a single page type
type Page = Entry[] | SearchResponse;
Expand All @@ -27,7 +28,7 @@ function getPromotedEntriesInfiniteQuery() {
const response = await appAxios.get<Entry[]>(
apiBase(`/private-api/promoted-entries`)
);
return slimEntryPage(response.data);
return annotateLanguageHints(slimEntryPage(response.data));
},
getNextPageParam: (
_lastPage: PromotedPage,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { UserAvatar } from "@/features/shared/user-avatar";
import { getPostsRankedQueryOptions, QueryKeys } from "@ecency/sdk";
import { getQueryClient } from "@/core/react-query";
import { withSlimPageEntries } from "@/core/entries/slim-entry";
import { mergePreservingHint } from "@/core/entries/language-hint";
import type { InfiniteData } from "@tanstack/react-query";

const MAX_PENDING = 20;
Expand Down Expand Up @@ -109,7 +110,7 @@ export function FeedLayout(props: PropsWithChildren<Props>) {
if (Array.isArray(page)) {
return (page as Entry[]).map((item) => {
const updated = map.get(`${item.author}-${item.permlink}`);
return updated ? { ...item, ...updated } : item;
return updated ? mergePreservingHint(item, updated) : item;
});
}
return page; // SearchResponse: leave as-is
Expand All @@ -123,7 +124,7 @@ export function FeedLayout(props: PropsWithChildren<Props>) {
const updated = resp.find(
(e) => e.author === item.author && e.permlink === item.permlink
);
return updated ? { ...item, ...updated } : item;
return updated ? mergePreservingHint(item, updated) : item;
})
);

Expand Down
116 changes: 116 additions & 0 deletions apps/web/src/core/entries/language-hint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { postBodySummary } from "@ecency/render-helper";
import type { Entry } from "@/entities";
import { MIN_DETECT_CHARS, francToIso1 } from "@/features/shared/entry-translate/iso639";

/**
* Server-side content-language hint for slim feed rows (#1597).
*
* The Translate chip on a card needs the content language. Until now every
* visitor's browser detected it: one markdown render per card plus the
* franc-min detector chunk (~47 KB gzipped), all on idle after every feed and
* post view, for the majority of readers whose language matches the content.
*
* The slim step already derives each card's summary on the server, so the
* server detects the language from that same summary once per fetch and ships
* the answer as `slim.lang`: an ISO-639-1 code, or `null` when the text is too
* short or the detector is unsure (which the chip treats as "nothing to
* offer"). The client gate reads the hint and skips both the render and the
* chunk. Rows fetched by the browser itself (later pages of an infinite feed)
* carry no hint and keep the on-idle path; `undefined` means "not checked".
*
* Server only: the detector is imported lazily inside the server branch, so it
* never reaches a client bundle. The summary used is the one the client gate
* would have used, so the decision is the same one, made earlier and once.
*/
export type LangHint = string | null;

let detector: Promise<((text: string) => string) | null> | null = null;

function loadDetector(): Promise<((text: string) => string) | null> {
if (!detector) {
detector = import("franc-min")
.then((m) => m.franc)
.catch(() => {
// Let the next fetch try again rather than pinning "no detector" for
// the life of the process.
detector = null;
return null;
});
}
return detector;
}

// The same bounds the client gate applies before detecting: render the sample
// to plain text (an author-written description can be a bare image link or
// markup, which must count as no text, not as a language) and cap the input.
const RAW_SAMPLE_CHARS = 2000;
const SAMPLE_CHARS = 600;

export function detectionSample(text: string): string {
return postBodySummary(text.slice(0, RAW_SAMPLE_CHARS), 0).slice(0, SAMPLE_CHARS).trim();
}

export function isServerRuntime(): boolean {
return typeof window === "undefined";
}

export function hintFor(
franc: (text: string) => string,
entry: Pick<Entry, "json_metadata" | "body">
): LangHint {
try {
// json_metadata is author-written: description may be missing or not a string.
const description = entry.json_metadata?.description;
const raw = entry.body || typeof description !== "string" ? "" : description.trim();
if (raw.length < MIN_DETECT_CHARS) return null;
const text = detectionSample(raw);
if (text.length < MIN_DETECT_CHARS) return null;
return francToIso1(franc(text));
} catch {
return null;
}
}

/**
* Merge a freshly polled row over a cached one without losing the hint: rows
* the browser polls itself carry no `slim.lang`, and a plain spread would
* replace the server's answer with "not checked".
*/
export function mergePreservingHint<T extends Entry>(item: T, updated: T): T {
const merged: T = { ...item, ...updated };
const lang = item.slim?.lang;
if (lang !== undefined && updated.slim && updated.slim.lang === undefined) {
merged.slim = { ...updated.slim, lang };
}
// A cross-post card reads from its nested original, which the poll replaces
// wholesale; give it the same treatment.
if (item.original_entry && updated.original_entry) {
merged.original_entry = mergePreservingHint(item.original_entry, updated.original_entry);
}
return merged;
}

/**
* Annotate a slim page in place with `slim.lang`. Never throws: a missing or
* failing detector leaves the rows without a hint and the client detects as
* before. Not applied on the client (see module comment).
*/
export async function annotateLanguageHints<T>(page: T): Promise<T> {
if (!isServerRuntime() || !Array.isArray(page)) return page;
const franc = await loadDetector();
if (!franc) return page;
for (const item of page as Entry[]) {
annotate(franc, item);
}
return page;
}

function annotate(franc: (text: string) => string, entry: Entry | null | undefined): void {
try {
if (!entry || typeof entry !== "object" || !entry.slim || entry.slim.lang !== undefined) return;
entry.slim = { ...entry.slim, lang: hintFor(franc, entry) };
if (entry.original_entry) annotate(franc, entry.original_entry);
} catch {
// One malformed row must not cost the page; it simply carries no hint.
}
}
7 changes: 6 additions & 1 deletion apps/web/src/core/entries/slim-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { catchPostImage, getEntryImageRawUrl, postBodySummary } from "@ecency/re
import { hasExternalLink } from "@ecency/sdk";
import { Entry } from "@/entities";
import { parseEntryLocationFromBody } from "./entry-location";
import { annotateLanguageHints } from "./language-hint";

/**
* Feed cards render a ~200 character summary and a thumbnail, but the bridge
Expand Down Expand Up @@ -177,8 +178,12 @@ function wrapQueryFn<T extends WithQueryFn>(
return {
...options,
queryKey,
// The language hint rides on the slim rows the server produces; on the
// client it is a no-op (see core/entries/language-hint.ts).
queryFn: async (...args: unknown[]) =>
transform(await (queryFn as (...a: unknown[]) => Promise<unknown>)(...args))
annotateLanguageHints(
transform(await (queryFn as (...a: unknown[]) => Promise<unknown>)(...args))
)
} as T;
}

Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/entities/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,12 @@ export interface Entry {
* Present only on feed rows that went through the slim step
* (`core/entries/slim-entry.ts`): the body is `""` and everything a card needs
* has been derived into `json_metadata`. `ext_link` carries the one body fact
* the SDK's moderation rules still need. Absent on full entries.
* the SDK's moderation rules still need. Absent on full entries. `lang` is
* the server-detected content language of the card summary (ISO-639-1, or
* null when too short / undetermined); absent when the row was fetched by
* the browser, which then detects on its own (core/entries/language-hint.ts).
*/
slim?: { ext_link: boolean };
slim?: { ext_link: boolean; lang?: string | null };
}

export interface EntryHeader {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ interface GateEntry {
permlink: string;
body?: string;
json_metadata?: { description?: string | null } | null;
// Server-detected language of a slim row's summary (core/entries/language-hint.ts).
slim?: { lang?: string | null } | null;
}

interface GateOptions {
Expand Down Expand Up @@ -124,6 +126,9 @@ export function useContentLanguageGate(
const body = entry?.body || "";
const summary = body ? "" : entry?.json_metadata?.description || "";
const sample = body || summary;
// A slim row the server already detected: the same summary, decided once
// there, so the browser neither renders markdown nor loads the detector.
const hint = summary ? entry?.slim?.lang : undefined;

useEffect(() => {
setDecision(null);
Expand Down Expand Up @@ -155,6 +160,12 @@ export function useContentLanguageGate(
return;
}

if (hint !== undefined) {
cacheDetection(key, { lang: hint, confirmed: false });
setDecision(resolveTranslateCta({ detected: hint, reader, textLength: MIN_DETECT_CHARS }));
return;
}

scheduleIdle(async () => {
if (cancelled) {
return;
Expand Down Expand Up @@ -221,7 +232,7 @@ export function useContentLanguageGate(
return () => {
cancelled = true;
};
}, [author, permlink, sample, summary, canServerConfirm, disabled]);
}, [author, permlink, sample, summary, hint, canServerConfirm, disabled]);

return decision;
}
22 changes: 22 additions & 0 deletions apps/web/src/specs/core/entries/language-hint-client.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { annotateLanguageHints, isServerRuntime } from "@/core/entries/language-hint";
import { mockEntry } from "@/specs/test-utils";
import type { Entry } from "@/entities";

// jsdom: the browser path. Rows the browser fetches itself must stay without a
// hint so the gate keeps detecting on idle, and the detector must not load.
describe("language hint in the browser (#1597)", () => {
it("is a no-op on the client", async () => {
expect(isServerRuntime()).toBe(false);
const slim = {
...mockEntry({ permlink: "client-row", body: "" }),
slim: { ext_link: false }
} as Entry;
slim.json_metadata = {
description:
"Today I want to share with you a story about the community and the work we do together every week."
};
const [out] = await annotateLanguageHints([slim]);
expect(out.slim?.lang).toBeUndefined();
});
});
124 changes: 124 additions & 0 deletions apps/web/src/specs/core/entries/language-hint.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { annotateLanguageHints, detectionSample, hintFor, isServerRuntime, mergePreservingHint } from "@/core/entries/language-hint";
import { withSlimEntries } from "@/core/entries/slim-entry";
import { mockEntry } from "@/specs/test-utils";
import type { Entry } from "@/entities";

const SPANISH =
"Hoy quiero compartir con ustedes una historia sobre la comunidad y el trabajo que hacemos juntos cada semana en la ciudad.";
const ENGLISH =
"Today I want to share with you a story about the community and the work we do together every week in the city.";

function row(body: string, overrides: Partial<Entry> = {}): Entry {
return mockEntry({ permlink: `hint-${Math.random().toString(36).slice(2)}`, body, ...overrides });
}

describe("server-side language hint (#1597)", () => {
it("runs on the server in this environment", () => {
expect(isServerRuntime()).toBe(true);
});

it("detects the summary language of slim rows and ships it as slim.lang", async () => {
const options = withSlimEntries({
queryKey: ["hint"],
queryFn: async () => [row(SPANISH), row(ENGLISH)]
});
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
const page = (await (options.queryFn as () => Promise<Entry[]>)()) as Entry[];
expect(page[0].body).toBe("");
expect(page[0].slim?.lang).toBe("es");
expect(page[1].slim?.lang).toBe("en");
});

it("marks a row whose summary is too short as checked with nothing to offer (null)", async () => {
const options = withSlimEntries({ queryKey: ["hint"], queryFn: async () => [row("Hola.")] });
const page = (await (options.queryFn as () => Promise<Entry[]>)()) as Entry[];
expect(page[0].slim?.lang).toBeNull();
});

it("reaches the nested original of a cross-post", async () => {
const original = row(SPANISH);
const options = withSlimEntries({
queryKey: ["hint"],
queryFn: async () => [row(ENGLISH, { original_entry: original } as Partial<Entry>)]
});
const page = (await (options.queryFn as () => Promise<Entry[]>)()) as Entry[];
expect(page[0].original_entry?.slim?.lang).toBe("es");
});

it("leaves rows without the slim marker and non-array pages alone", async () => {
const full = row(ENGLISH);
full.body = ""; // looks slim but never went through slimEntry
expect((await annotateLanguageHints([full]))[0].slim).toBeUndefined();
expect(await annotateLanguageHints({ items: [] })).toEqual({ items: [] });
expect(await annotateLanguageHints(undefined)).toBeUndefined();
});

it("treats a missing or non-string description as no text", () => {
const franc = vi.fn(() => "eng");
for (const description of [undefined, null, 42, { text: ENGLISH }, [ENGLISH]]) {
expect(hintFor(franc, { body: "", json_metadata: { description } as never })).toBeNull();
}
expect(franc).not.toHaveBeenCalled();
});

it("never throws out of the queryFn when the detector fails", () => {
const boom = () => {
throw new Error("boom");
};
expect(hintFor(boom, { body: "", json_metadata: { description: ENGLISH } })).toBeNull();
});

it("does not second-guess a row that already carries a hint", async () => {
const slim = { ...row(ENGLISH), body: "", slim: { ext_link: false, lang: "de" as string | null } };
slim.json_metadata = { description: ENGLISH };
const [out] = await annotateLanguageHints([slim]);
expect(out.slim?.lang).toBe("de");
});

it("treats an author description that is only a link or markup as no text (#1597 review)", () => {
const franc = vi.fn(() => "por");
for (const description of [
"![photo](https://images.ecency.com/DQmabcdefghijklmnopqrstuvwxyz/photo_2026_08_21.jpg)",
"https://3speak.tv/watch?v=someone/abcdefgh https://images.ecency.com/p/abcdefghijklmnop.png",
"<center><img src='https://images.ecency.com/DQmabcdefghijklmnopqrstuvwxyz/x.png'></center>",
"[Source](https://www.example.com/some/long/path/that/keeps/going/and/going/x)"
]) {
expect(hintFor(franc, { body: "", json_metadata: { description } })).toBeNull();
}
expect(franc).not.toHaveBeenCalled();
// Plain summaries are passed through unchanged, so the server and the
// client decide on the same text.
expect(detectionSample(ENGLISH)).toBe(ENGLISH);
});

it("keeps the server hint when a browser-polled row is merged over a cached one", () => {
const cached = { ...row(ENGLISH), body: "", slim: { ext_link: false, lang: "en" as string | null } };
const polled = { ...cached, slim: { ext_link: true }, stats: { total_votes: 9 } } as Entry;
const merged = mergePreservingHint(cached, polled);
expect(merged.slim).toEqual({ ext_link: true, lang: "en" });
expect(merged.stats?.total_votes).toBe(9);
// A row the server did check wins over the cached answer.
const rechecked = { ...polled, slim: { ext_link: true, lang: "de" as string | null } };
expect(mergePreservingHint(cached, rechecked).slim?.lang).toBe("de");
// Nothing to preserve: plain merge.
const unhinted = { ...cached, slim: { ext_link: false } } as Entry;
expect(mergePreservingHint(unhinted, polled).slim).toEqual({ ext_link: true });
// A cross-post's nested original keeps its hint too.
const original = { ...row(SPANISH), body: "", slim: { ext_link: false, lang: "es" as string | null } };
const crossCached = { ...cached, original_entry: original } as Entry;
const crossPolled = {
...polled,
original_entry: { ...original, slim: { ext_link: false }, stats: { total_votes: 3 } }
} as Entry;
const crossMerged = mergePreservingHint(crossCached, crossPolled);
expect(crossMerged.original_entry?.slim).toEqual({ ext_link: false, lang: "es" });
expect(crossMerged.original_entry?.stats?.total_votes).toBe(3);
});

it("leaves the post page's full bodies to the client (hints are for slim rows)", () => {
const franc = vi.fn(() => "spa");
expect(hintFor(franc, { body: SPANISH, json_metadata: { description: SPANISH } })).toBeNull();
expect(franc).not.toHaveBeenCalled();
});
});
Loading
Loading