Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6079d4c
fix(mobile): keep profile fields visible above keyboard
Muawiya-contact Oct 24, 2026
2df020d
fix: start new learners with opt-in followed topics
Muawiya-contact Oct 24, 2026
0723f01
fix: render lesson Markdown and code consistently
Muawiya-contact Oct 24, 2026
3f5518c
fix(mobile): normalize profile photos before upload
Muawiya-contact Oct 24, 2026
2cab6b7
fix(mobile): show safe labels for rejected Markdown links
Muawiya-contact Oct 24, 2026
d20070e
docs: record combined learner fixes and release gates
Muawiya-contact Oct 24, 2026
be9008a
fix(backend): keep fallback account setup opt-in
Muawiya-contact Oct 24, 2026
4d35102
docs: record second review of learner fixes
Muawiya-contact Oct 24, 2026
429cc96
docs: record hosted review checks
Muawiya-contact Oct 24, 2026
36cef3c
fix(db): keep Edge bootstrap topic follows opt-in
Muawiya-contact Oct 24, 2026
5f0ac3b
docs: map complete topic opt-in rollout
Muawiya-contact Oct 24, 2026
40448bc
fix(mobile): surface unsaved topic choices and keep durable follows
Muawiya-contact Oct 24, 2026
8ddc3f6
docs: record final topic persistence review
Muawiya-contact Oct 24, 2026
650af70
Merge remote-tracking branch 'refs/remotes/github/develop' into codex…
Muawiya-contact Oct 24, 2026
a046716
Bump native runtime for photo preparation release
Muawiya-contact Oct 24, 2026
60b7037
fix(mobile): preserve topic choice during queued refresh
Muawiya-contact Oct 24, 2026
4d8d9c5
fix(mobile): allow failed topic save rollback during refresh
Muawiya-contact Oct 24, 2026
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
18 changes: 18 additions & 0 deletions admin/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@
},
"dependencies": {
"@supabase/supabase-js": "2.117.2",
"prismjs": "^1.30.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-markdown": "10.1.0",
"remark-gfm": "4.0.1"
},
"devDependencies": {
"@playwright/test": "1.63.0",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "6.1.1",
Expand Down
50 changes: 50 additions & 0 deletions admin/src/MarkdownText.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { MarkdownText } from "./MarkdownText";
import { LessonView } from "./LessonView";
import { editableLesson } from "./lesson";

describe("lesson Markdown", () => {
it("renders readable blocks and syntax-highlighted fenced code", () => {
const html = renderToStaticMarkup(<MarkdownText value={"# Heading\n\nA **bold** paragraph.\n\n- one\n- two\n\n> note\n\n```js\nconst count = 2;\n```"} />);
expect(html).toContain("<h1>Heading</h1>");
expect(html).toContain("<strong>bold</strong>");
expect(html).toContain("<li>one</li>");
expect(html).toContain("<blockquote>");
expect(html).toContain("<pre><code class=\"language-js\">");
expect(html).toContain("md-token-keyword");
expect(html).toContain("const");
});

it("keeps unknown code languages readable and does not execute unsafe content", () => {
const html = renderToStaticMarkup(<MarkdownText value={"```unknown\n<script>alert(1)</script>\n```\n\n[click](javascript:alert(1))\n\n<img src=x onerror=alert(1)>"} />);
expect(html).toContain("&lt;script&gt;");
expect(html).not.toContain("href=\"javascript:");
expect(html).not.toContain("[click](javascript:");
expect(html).not.toContain("<img");
});

it("accepts empty legacy content", () => {
expect(renderToStaticMarkup(<MarkdownText value={null} />)).toContain("lesson-markdown");
});

it("keeps adjacent JSON, unknown code, Unicode, and malformed fences readable", () => {
const source = "Plain text with `first` and `second`.\n\n```json\n{\"name\":\"世界\",\"items\":[1,{\"ok\":true}]}\n```\n```odd-language\nraw <tag>\n```\n```\nunclosed";
const html = renderToStaticMarkup(<MarkdownText value={source} />);
expect(html).toContain("<code>first</code>");
expect(html).toContain("<code>second</code>");
expect(html).toContain("md-token-property");
expect(html).toContain("世界");
expect(html).toContain("raw &lt;tag&gt;");
expect(html).toContain("unclosed");
});

it("uses the same renderer in the reviewer and owner lesson view", () => {
const body = editableLesson({ title: "## Structured Logging", summary: "A **machine-readable** record.", example: "```json\n{\"user_id\":123}\n```" });
const html = renderToStaticMarkup(<LessonView body={body} links={[]} />);
expect(html).toContain("Structured Logging");
expect(html).not.toContain("## Structured Logging");
expect(html).toContain("<strong>machine-readable</strong>");
expect(html).toContain("md-token-property");
});
});
4 changes: 3 additions & 1 deletion admin/src/MarkdownText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import type { ReactNode } from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { safeUrl } from "./api";
import { highlightedCode } from "./markdownCode";

/** Lesson Markdown is untrusted content. React renders HTML as text and images are omitted. */
export function MarkdownText({ value, inline = false, links = true }: { value: unknown; inline?: boolean; links?: boolean }) {
const source = typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? "Not provided");
const source = typeof value === "string" ? value : value == null ? "" : (JSON.stringify(value, null, 2) ?? "");
const content = inline ? source.replaceAll("\n", " ") : source;
const inlineElement = ({ children }: { children?: ReactNode }) => <span>{children}</span>;
const rendered = <Markdown remarkPlugins={[remarkGfm]} components={{
Expand All @@ -16,6 +17,7 @@ export function MarkdownText({ value, inline = false, links = true }: { value: u
li: inlineElement, blockquote: inlineElement, pre: inlineElement,
} : {}),
img: ({ alt }) => <span>{alt ?? "[image]"}</span>,
code: ({ className, children }) => <code className={className}>{highlightedCode(String(children), className)}</code>,
a: ({ href, children }) => {
const url = safeUrl(href ?? "");
return url && links ? <a href={url} target="_blank" rel="noopener noreferrer">{children}</a> : <span>{children}</span>;
Expand Down
33 changes: 33 additions & 0 deletions admin/src/markdownCode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ReactNode } from "react";
import Prism from "prismjs";
import "prismjs/components/prism-javascript";
import "prismjs/components/prism-typescript";
import "prismjs/components/prism-json";
import "prismjs/components/prism-python";
import "prismjs/components/prism-bash";
import "prismjs/components/prism-sql";
import "prismjs/components/prism-css";
import "prismjs/components/prism-markup";

const aliases: Record<string, string> = {
js: "javascript", jsx: "javascript", javascript: "javascript",
ts: "typescript", tsx: "typescript", typescript: "typescript",
json: "json", py: "python", python: "python",
sh: "bash", shell: "bash", bash: "bash",
sql: "sql", css: "css", html: "markup", xml: "markup",
};

export function highlightedCode(source: string, className?: string): ReactNode {
const language = aliases[className?.match(/(?:^|\s)language-([\w-]+)/)?.[1].toLowerCase() ?? ""];
const grammar = language && Prism.languages[language];
if (!grammar || source.length > 20_000) return source;

function render(items: (string | Prism.Token)[], prefix = ""): ReactNode[] {
return items.map((item, index) => {
if (typeof item === "string") return item;
const content = typeof item.content === "string" ? item.content : render(Array.isArray(item.content) ? item.content : [item.content], `${prefix}${index}-`);
return <span key={`${prefix}${index}`} className={`md-token md-token-${item.type}`}>{content}</span>;
});
}
return render(Prism.tokenize(source, grammar));
}
5 changes: 4 additions & 1 deletion admin/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,10 @@ main {
.lesson-markdown > :last-child { margin-bottom: 0; }
.lesson-markdown pre { overflow-x: auto; padding: 14px; border-radius: 8px; background: var(--subtle); }
.lesson-markdown code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; background: var(--subtle); border-radius: 4px; padding: 1px 3px; }
.lesson-markdown pre code { padding: 0; }
.lesson-markdown pre code { padding: 0; white-space: pre; background: transparent; overflow-wrap: normal; }
.lesson-markdown .md-token-comment, .lesson-markdown .md-token-prolog { color: var(--muted); }
.lesson-markdown .md-token-keyword, .lesson-markdown .md-token-boolean { color: var(--primary); }
.lesson-markdown .md-token-string, .lesson-markdown .md-token-number { color: var(--success); }
.lesson-markdown blockquote { border-left: 3px solid var(--primary); margin-left: 0; padding-left: 14px; color: var(--muted); }
.lesson-markdown table { border-collapse: collapse; display: block; max-width: 100%; overflow-x: auto; }
.lesson-markdown th, .lesson-markdown td { border: 1px solid var(--line); padding: 6px 10px; }
Expand Down
36 changes: 12 additions & 24 deletions backend/app/services/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,26 @@
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession

# Covers the same ground as the on_auth_user_created trigger — but do NOT
# re-sync the two verbatim: the follows insert here is deliberately gated on
# profile creation, which the trigger does not need (it only ever runs once).
# The trigger covers everyone who signs up normally; this is the safety net
# for users that predate the schema, or that were created through paths the
# trigger did not see.
# Covers the same ground as the on_auth_user_created trigger. The trigger
# covers normal signups; this is the safety net for users that predate the
# schema or were created through paths the trigger did not see.
#
# Kept to one round trip: it runs on every /v1/daily call (and on
# /v1/me/state only when no profile exists yet).
# Keep the two inserts in one round trip: this runs on every /v1/daily call
# (and on /v1/me/state only when no profile exists yet).
#
# Topic follows are seeded ONLY when the profile insert actually creates a
# row. Follows are the one user-curated, deletable set here — an unconditional
# insert would resurrect every unfollow on every call, which is exactly what
# it used to do (issue #29). Profile and prefs are single rows that users
# cannot delete, so re-asserting them stays harmless.
# Topic follows are always opt-in, including for users whose signup trigger
# did not run. Profile and prefs are single rows that users cannot delete, so
# re-asserting them stays harmless.
_BOOTSTRAP = text("""
with created as (
with profile as (
insert into public.profiles (id, display_name)
values (:uid, :display_name)
on conflict (id) do nothing
returning id
), prefs as (
insert into public.notification_preferences (user_id)
values (:uid)
on conflict (user_id) do nothing
)
insert into public.user_topics (user_id, topic_id)
select p.id, t.id
from created p
cross join public.topics t
where t.is_active
on conflict do nothing
insert into public.notification_preferences (user_id)
values (:uid)
on conflict (user_id) do nothing
""")


Expand Down
26 changes: 26 additions & 0 deletions backend/migrations/0131_opt_in_followed_topics.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- New accounts choose their own interests. Existing user_topics rows are untouched.
begin;

create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
insert into public.profiles (id, display_name)
values (
new.id,
coalesce(new.raw_user_meta_data ->> 'display_name', split_part(new.email, '@', 1))
)
on conflict (id) do nothing;

insert into public.notification_preferences (user_id)
values (new.id)
on conflict (user_id) do nothing;

return new;
end;
$$;

commit;
Loading
Loading