fetchAll(); $users = rc_users(array_column($rows, "user_id")); // one call for the whole list $out = []; // A deleted person's entry is not shown: an unnamed line reads as a broken one. The row stays // (account deletes the person, not what they wrote). If account answered nobody at all, it is // more likely down than everyone gone, so then the entries show without names. $down = $rows && !$users; $gone = ["id" => 0, "username" => "", "name" => "someone", "picture" => ""]; foreach ($rows as $r) { if (!isset($users[$r["user_id"]]) && !$down) continue; $out[] = ["user" => $users[$r["user_id"]] ?? $gone, "note" => $r["note"], "at" => gmdate("c", $r["at"])]; } return $out; } return [ "n" => 4, "try" => "sign the guestbook", "wing" => "Identity", "title" => "Your name and picture follow you", "promise" => "Change your name and picture at account, and every app shows the new ones.", "block" => "rc_users(\$ids) turns stored ids into current names and pictures, one call per page. An app keeps ids, never copies of names or pictures.", "files" => ["lib/robot.php"], "show" => function (?array $me): string { $list = ""; foreach (guestbook() as $g) $list .= '' . person($g["user"]) . '
' . h($g["note"]) . " · " . ago(strtotime($g["at"])) . "
"; $form = $me ? '
' : '

Sign in to sign the guestbook.

'; return $form . '

Change your name or picture at account, then reload: your entry changes too. (The robot renames itself to the time of every check, which is how this exhibit checks itself.)

' . '
' . ($list ?: '

Nobody has signed yet.

') . "
"; }, "api" => function (string $do, ?array $me, array $in, bool $post): ?array { if ($do === "list") return ["env" => env("RC_ENV"), "instance" => meta("instance"), "guestbook" => guestbook()]; if ($do === "sign" && $post) { if (!$me) return ["error" => "sign in to sign the guestbook", "status" => 401]; $note = trim(mb_substr((string)($in["note"] ?? ""), 0, 140)); if ($note === "") return ["error" => "say something", "status" => 400]; q("INSERT INTO guestbook (user_id, note, at) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET note = excluded.note, at = excluded.at", [$me["id"], $note, time()]); return ["ok" => true]; } return null; }, // The robot renames itself at account, then reads the guestbook as a visitor would. "check" => function (): array { require_once ZOO_ROOT . "/lib/robot.php"; $b = robot_at_zoo(); $host = "https://" . env("RC_HOST"); [$code, , $j] = $b->post("$host/api/4/sign", ["note" => "The robot was here, on " . env("RC_ENV") . "."]); if ($code !== 200) return [false, "the robot could not sign the guestbook ($code)"]; $name = "Zoo Robot " . gmdate("H:i"); [$code] = account_api($b, "profile", ["name" => $name]); if ($code !== 200) return [false, "account would not rename the robot ($code)"]; [, , $j] = (new Browser())->get("$host/api/4/list"); $mine = array_values(array_filter($j["guestbook"] ?? [], fn($g) => $g["user"]["id"] === robot_user()["id"]))[0] ?? null; if (!$mine) return [false, "the robot's entry is missing from the guestbook"]; if ($mine["user"]["name"] !== $name) return [false, "renamed to \"$name\" at account, but the guestbook still says \"{$mine['user']['name']}\""]; return [true, "renamed the robot to \"$name\" at account; the guestbook showed the new name on the next load"]; }, "script" => <<<'JS' document.getElementById("gb-form")?.addEventListener("submit", async (ev) => { ev.preventDefault(); const btn = ev.target.querySelector("button"); btn.disabled = true; const j = await zoo.call("/api/4/sign", { note: ev.target.note.value }); btn.disabled = false; if (!j.error) { // Her entry, in place: the list again from the server, hers marked, and a line saying so. // No reload, so the card stays open where she is looking. const l = await zoo.call("/api/4/list"); if (!l.error) { document.getElementById("gb-list").innerHTML = l.guestbook.map(g => '" + zoo.person(g.user) + '
' + zoo.esc(g.note) + " · " + zoo.ago(g.at) + "
").join(""); } const done = document.getElementById("gb-done"); done.textContent = "Signed. Your entry is at the top of the guestbook."; done.hidden = false; ev.target.reset(); document.querySelector("#gb-list .mine")?.scrollIntoView({ block: "nearest", behavior: "smooth" }); return; } let e = ev.target.nextElementSibling; if (!e || !e.classList.contains("form-err")) { e = document.createElement("p"); e.className = "rc-note bad form-err"; ev.target.after(e); } e.textContent = j.error; }); JS, ];