Threema QR: on-demand modal + auto-open on first add

This commit is contained in:
2026-05-17 17:13:44 +02:00
parent 395d2f43cc
commit d7fae39077
9 changed files with 210 additions and 102 deletions

140
README.md
View File

@@ -1,116 +1,66 @@
# Threema UX rework + QR code
# Threema UX v2 — QR available on demand
Replaces the previous attempt that had the QR rendering inline above
the channel help text. That placement didn't work for you in practice
because it wasn't visible when you actually wanted it.
## What this does instead
1. **"Show QR" link** next to the channel title in the threema card —
visible at all times, clickable any time you want the QR.
2. **Auto-opens the modal** the first time you focus the add-ID input
on the page (so a new user adding their first ID sees it without
needing to click "Show QR" themselves). Doesn't re-pop after
dismissal — the link covers re-opens.
3. **Modal** with QR + 3-step instructions + "AIAGENT" label. Plain
`<img>` (no next/image), closes on ESC / overlay click / × button.
The earlier `threema-setup.tsx` component file is removed — replaced by
`threema-qr-modal.tsx`.
## Files
```
src/lib/threema-gateway-config.ts # NEW — single source of truth for gateway ID + QR path
src/components/channel-users/threema-setup.tsx # NEW — QR + 3-step instructions component
src/components/channel-users/channel-users.tsx # MODIFIED — renders <ThreemaSetup /> for the threema channel
deploy/patch-i18n-threema.mjs # REPLACES earlier version — customer-friendly texts in 4 langs
public/threema/qr_code_AIAGENT.png # NEW — the QR you uploaded
src/lib/threema-gateway-config.ts # unchanged from before — central gateway constants
src/components/channel-users/threema-qr-modal.tsx # NEW — the modal
src/components/channel-users/channel-users.tsx # MODIFIED — Show QR button + focus auto-open + modal mount
deploy/patch-i18n-threema.mjs # adds threemaSetup.showQr label in 4 langs
public/threema/qr_code_AIAGENT.png # unchanged
```
## What changed (UX, customer-facing)
1. **Package description / instructions / disclaimer.** No mention of
"Gateway account", "Gateway credentials", or anything about asterisks.
The disclaimer now explicitly says messages are end-to-end encrypted
only up to PieCed's messaging service (where they get decrypted to
route to the assistant) — accurate, not overclaiming. Crucially it
also says **Threema charges per message** so customers know that
sending/receiving on this channel has a cost separate from their
PieCed subscription.
2. **Threema ID help text.** Now tells the customer to add **their
own** ID, with explicit instructions for finding it in the Threema
app (Settings → My Threema ID). Drops the asterisk / Gateway-prefix
explanation entirely.
3. **QR code component (NEW).** Shown above the help text in the
channel-users panel when the threema channel is enabled. Three-step
flow: open Threema, scan, add your own ID below.
4. **All four languages** (en/de/fr/it) updated consistently.
## What changed (technical)
- Gateway constants centralised in `src/lib/threema-gateway-config.ts`.
Today hardcoded to `*AIAGENT` + `/threema/qr_code_AIAGENT.png`. When
you need multiple gateway accounts, edit that one file (and the
inline TODO block tells the next person how).
- The component uses Next.js `<Image>` — automatic optimisation,
lazy-loaded by default (no `priority`).
- The PNG goes in `public/threema/`, served as a static asset under
`/threema/qr_code_AIAGENT.png` — no API route, no auth wrapper, the
QR encodes a Threema invitation anyone can scan anyway.
## Apply
```bash
cd /path/to/pieced-portal
# Drop in new files (overwrites prior channel-users.tsx and i18n script)
cp -r <unzipped>/* .
# Remove the old inline component if you applied the previous archive
rm -f src/components/channel-users/threema-setup.tsx
# Quick TS check (the new component uses next/image — should be fine)
# Drop new + modified files
unzip -o /path/to/threema-ux-v2.zip
# Update messages
node deploy/patch-i18n-threema.mjs
# TS check
npx tsc --noEmit
# Patch the message files
node deploy/patch-i18n-threema.mjs
# Should print 4 lines, one per language
# Commit + push
git add -A
git status # eyeball the changes
git commit -m "Threema: customer-friendly texts + QR setup component"
git commit -m "Threema QR: on-demand modal + auto-open on first add"
git push
```
## Verify after redeploy
After redeploy, the threema card under Authorized Users shows:
1. Open the portal as a customer admin, pick the test tenant.
2. Disable + re-enable Threema in the package list (or just look at
the channel-users panel — the QR shows there regardless).
3. Authorized Users → threema section should show:
- QR code on the left
- "AIAGENT" label under the QR (no asterisk)
- 3-step instruction list
- Help text below: "Enter your own Threema ID..."
- Existing user pills + Add input
Scan the QR with your phone — it should prompt to add `*AIAGENT` as a
contact in Threema with trust level "verified by the operator" (the
QR encodes the contact's public key).
## Billing log query (re-run after sending a few messages)
```bash
POD=$(kubectl -n threema-gateway get pods -l 'cnpg.io/cluster=pieced-threema-gateway-db,role=primary' -o jsonpath='{.items[0].metadata.name}')
DBPASS=$(kubectl -n threema-gateway get secret pieced-threema-gateway-db-app -o jsonpath='{.data.password}' | base64 -d)
# Per-tenant in/out counts
kubectl -n threema-gateway exec $POD -- env PGPASSWORD="$DBPASS" \
psql -U relay -d relay -c \
"SELECT tenant_name, direction, count(*), max(created_at) FROM messages GROUP BY tenant_name, direction ORDER BY tenant_name, direction;"
```
threema [Show QR] 0 users
────────────────────
<help text: 'Enter your own Threema ID...'>
────────────────────
[ input: A8K2P3X7 ] [ Add ]
^^^ focusing this opens the QR modal the first time
```
That's your billing source — count(*) × Threema's per-message rate ×
direction-specific multiplier = customer charge.
## Future: dynamic gateway accounts
Today's hardcoded `*AIAGENT` works for one shared gateway. When you
move to per-tenant gateway accounts (Threema's bigger plans, or to
isolate billing per tenant):
1. Update `src/lib/threema-gateway-config.ts`:
- Make the constants a lookup keyed by tenant
- Or fetch from `/api/tenants/<name>/threema` (new admin route)
2. Update `threema-setup.tsx` to accept gateway info as props
3. Update the parent `channel-users.tsx` to pass tenant-specific values
4. Replace the static PNG with a server route that generates per-tenant
QR codes from each gateway's `id` + `publicKey`
The single config file means this refactor is contained — every
consumer reads from one place, so once that one place returns the
right values, everything else falls in line.
Clicking "Show QR" or focusing the input → modal with QR + steps.

View File

@@ -37,6 +37,7 @@ const i18n = {
step2: "Tap the scan icon and scan this QR code to add the assistant as a contact.",
step3: "Then add your own Threema ID below.",
qrAlt: "QR code to add {gateway} as a Threema contact",
showQr: "Show QR",
},
},
de: {
@@ -56,6 +57,7 @@ const i18n = {
step2: "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
step3: "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
qrAlt: "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
showQr: "QR anzeigen",
},
},
fr: {
@@ -75,6 +77,7 @@ const i18n = {
step2: "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
step3: "Puis ajoutez votre propre identifiant Threema ci-dessous.",
qrAlt: "QR code pour ajouter {gateway} comme contact Threema",
showQr: "Afficher le QR",
},
},
it: {
@@ -94,6 +97,7 @@ const i18n = {
step2: "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
step3: "Quindi aggiungi il tuo ID Threema qui sotto.",
qrAlt: "QR code per aggiungere {gateway} come contatto Threema",
showQr: "Mostra QR",
},
},
};

View File

@@ -3,6 +3,7 @@
import { useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { ThreemaQrModal } from "./threema-qr-modal";
/** Maps channel IDs to the instructions for finding the user ID. */
const CHANNEL_ID_HELP: Record<string, string> = {
@@ -51,6 +52,14 @@ export function ChannelUsers({
const [inputValues, setInputValues] = useState<Record<string, string>>({});
const [channelUsers, setChannelUsers] =
useState<Record<string, string[]>>(initialChannelUsers);
/** Which channel's QR helper modal is open, if any. */
const [showQrFor, setShowQrFor] = useState<string | null>(null);
/**
* Tracks channels for which we've already auto-opened the helper
* modal on this page load. Prevents the modal from re-popping every
* time the user refocuses the input after dismissing it.
*/
const [autoOpened, setAutoOpened] = useState<Set<string>>(() => new Set());
const updateChannelUsers = useCallback(
async (updated: Record<string, string[]>) => {
@@ -216,9 +225,19 @@ export function ChannelUsers({
className="bg-surface-2 border border-border rounded-lg p-4"
>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-text-primary capitalize">
{channel}
</h4>
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-text-primary capitalize">
{channel}
</h4>
{channel === "threema" && (
<button
onClick={() => setShowQrFor("threema")}
className="text-xs font-medium text-accent hover:text-accent-dim cursor-pointer underline underline-offset-2"
>
{t("threemaSetup.showQr")}
</button>
)}
</div>
<span className="text-xs text-text-muted tabular-nums">
{users.length} {t("users")}
</span>
@@ -266,6 +285,17 @@ export function ChannelUsers({
[channel]: e.target.value,
}))
}
onFocus={() => {
// For threema specifically, open the QR helper the
// first time the user clicks into the input on this
// page load. We don't repeat after dismiss — the
// "Show QR" button next to the channel name covers
// re-opens on demand.
if (channel === "threema" && !autoOpened.has("threema")) {
setShowQrFor("threema");
setAutoOpened((prev) => new Set(prev).add("threema"));
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd(channel);
}}
@@ -284,6 +314,11 @@ export function ChannelUsers({
</div>
);
})}
<ThreemaQrModal
open={showQrFor === "threema"}
onClose={() => setShowQrFor(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
interface ThreemaQrModalProps {
open: boolean;
onClose: () => void;
}
/**
* On-demand modal showing the QR for adding the assistant on Threema.
* Triggered by the "Show QR" button in the threema channel card and
* closes on overlay click, ESC, or the close button.
*
* Uses a plain <img> not next/image — image optimization adds nothing
* for a 57KB static PNG and removes a potential source of rendering
* bugs in the Next.js standalone build.
*/
export function ThreemaQrModal({ open, onClose }: ThreemaQrModalProps) {
const t = useTranslations("channelUsers.threemaSetup");
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
>
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 shadow-2xl shadow-black/40 space-y-4"
>
<div className="flex items-start justify-between">
<h3 className="text-base font-semibold text-text-primary">
{t("title")}
</h3>
<button
onClick={onClose}
className="text-text-muted hover:text-text-primary text-xl leading-none"
aria-label="Close"
>
×
</button>
</div>
<div className="flex justify-center">
<div className="bg-white p-3 rounded-md">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={THREEMA_GATEWAY.qrCodePath}
alt={t("qrAlt", { gateway: THREEMA_GATEWAY.displayName })}
width={220}
height={220}
style={{ display: "block" }}
/>
</div>
</div>
<p className="text-center text-xs text-text-muted font-mono">
{THREEMA_GATEWAY.displayName}
</p>
<ol className="list-decimal list-inside text-xs text-text-secondary space-y-1.5">
<li>{t("step1")}</li>
<li>{t("step2")}</li>
<li>{t("step3")}</li>
</ol>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
/**
* Threema central gateway info shown to customers.
*
* Today PieCed runs exactly one Threema Gateway account (*AIAGENT) and
* every tenant talks to that. The constants below are hardcoded for
* that account. The values are intentionally kept here (and not split
* across i18n / env / runtime config) so when we move to multiple
* gateway accounts there's a single file to refactor.
*
* To go dynamic (future):
* 1. Replace `THREEMA_GATEWAY` constant with a runtime lookup —
* either per-tenant from the relay's admin API, or from an
* env var that lists the active account.
* 2. Move the QR PNG into a server-rendered route that takes a
* gateway ID query param.
* 3. Update consumers (today only ThreemaSetup) to accept the
* gateway info as a prop and pass it from a server component.
*
* In display contexts we strip the leading asterisk from the Threema
* ID — customers don't understand the `*X` prefix convention used for
* Gateway accounts, and the QR code carries the real value anyway. We
* keep the asterisk only for places where the technical value matters
* (server-side message routing, debug logs).
*/
export const THREEMA_GATEWAY = {
/** Technical Threema Gateway ID, with leading asterisk. */
id: "*AIAGENT",
/** Display name shown to customers (no asterisk). */
displayName: "AIAGENT",
/** Public path to the QR code PNG served from `public/`. */
qrCodePath: "/threema/qr_code_AIAGENT.png",
} as const;

View File

@@ -402,7 +402,8 @@
"step1": "Öffnen Sie Threema auf Ihrem Telefon.",
"step2": "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
"step3": "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
"qrAlt": "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen"
"qrAlt": "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
"showQr": "QR anzeigen"
}
},
"team": {

View File

@@ -402,7 +402,8 @@
"step1": "Open Threema on your phone.",
"step2": "Tap the scan icon and scan this QR code to add the assistant as a contact.",
"step3": "Then add your own Threema ID below.",
"qrAlt": "QR code to add {gateway} as a Threema contact"
"qrAlt": "QR code to add {gateway} as a Threema contact",
"showQr": "Show QR"
}
},
"team": {

View File

@@ -402,7 +402,8 @@
"step1": "Ouvrez Threema sur votre téléphone.",
"step2": "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
"step3": "Puis ajoutez votre propre identifiant Threema ci-dessous.",
"qrAlt": "QR code pour ajouter {gateway} comme contact Threema"
"qrAlt": "QR code pour ajouter {gateway} comme contact Threema",
"showQr": "Afficher le QR"
}
},
"team": {

View File

@@ -402,7 +402,8 @@
"step1": "Apri Threema sul tuo telefono.",
"step2": "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
"step3": "Quindi aggiungi il tuo ID Threema qui sotto.",
"qrAlt": "QR code per aggiungere {gateway} come contatto Threema"
"qrAlt": "QR code per aggiungere {gateway} come contatto Threema",
"showQr": "Mostra QR"
}
},
"team": {