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,
];