~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

marketing: notice of the move to the new server on Monday, Nov 16

Open chadtmiller.com wants to merge into main from grain.social/grain:marketing/cutover-notice
marketing/update/notice.htmlnew+187−0
@@ -0,0 +1,187 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>grain update notice</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=Plus+Jakarta+Sans:wght@500;600;700;800&display=block"
rel="stylesheet"
/>
<style>
:root {
--bg-root: #080b12;
--grain: #85a1ff;
--text: #f8fafc;
--text-secondary: #a3b3cc;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
width: 100%;
height: 100%;
}
body {
font-family:
"Plus Jakarta Sans",
-apple-system,
BlinkMacSystemFont,
sans-serif;
background: var(--bg-root);
color: var(--text);
-webkit-font-smoothing: antialiased;
overflow: hidden;
position: relative;
background-image:
radial-gradient(
ellipse 1100px 900px at 90% -10%,
rgba(133, 161, 255, 0.22),
transparent 60%
),
radial-gradient(ellipse 900px 800px at 5% 110%, rgba(107, 139, 239, 0.12), transparent 60%);
}
.stage {
width: 100%;
height: 100%;
padding: 0 96px;
display: flex;
flex-direction: column;
justify-content: center;
padding-bottom: 220px;
position: relative;
}
.mark {
width: 220px;
height: 220px;
border-radius: 48px;
background:
url("../login-bg.jpg") center/cover no-repeat,
#1a2238;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
box-shadow:
0 30px 60px -16px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(255, 255, 255, 0.08);
margin-bottom: 88px;
}
.mark::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(8, 11, 18, 0) 0%, rgba(8, 11, 18, 0.35) 100%);
}
.mark span {
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: 50px;
letter-spacing: -0.035em;
color: #fff;
position: relative;
z-index: 1;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
}
.eyebrow {
font-weight: 700;
font-size: 34px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--grain);
margin-bottom: 36px;
}
h1 {
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: 100px;
line-height: 1.04;
letter-spacing: -0.03em;
margin-bottom: 56px;
}
p {
font-weight: 500;
font-size: 46px;
line-height: 1.4;
color: var(--text-secondary);
max-width: 860px;
}
.stores {
display: flex;
gap: 24px;
margin-top: 80px;
}
.store {
font-weight: 700;
font-size: 36px;
padding: 26px 40px;
border-radius: 999px;
background: rgba(133, 161, 255, 0.14);
border: 2px solid rgba(133, 161, 255, 0.45);
color: var(--text);
}
.date {
position: absolute;
left: 96px;
right: 96px;
bottom: 150px;
font-weight: 600;
font-size: 38px;
color: var(--text-secondary);
border-top: 2px solid rgba(163, 179, 204, 0.18);
padding-top: 44px;
}
.date b {
white-space: nowrap;
color: var(--text);
font-weight: 800;
}
/* The feed post is shorter, 4:5: everything a step smaller. */
body.post .stage {
padding: 0 88px 40px;
}
body.post .mark {
width: 160px;
height: 160px;
border-radius: 36px;
margin-bottom: 64px;
}
body.post .mark span {
font-size: 36px;
}
body.post h1 {
font-size: 88px;
margin-bottom: 44px;
}
body.post p {
font-size: 42px;
}
body.post .stores {
margin-top: 64px;
}
body.post .date {
display: none;
}
</style>
</head>
<body>
<div class="stage">
<div class="mark"><span>grain</span></div>
<div class="eyebrow" id="eyebrow"></div>
<h1 id="title"></h1>
<p id="body"></p>
<div class="stores">
<div class="store">App Store</div>
<div class="store">Google Play</div>
</div>
<div class="date" id="date"></div>
</div>
</body>
</html>
marketing/update/post.mjsnew+115−0
@@ -0,0 +1,115 @@
// Posts a step of the notice in posts.json as grain.social: a story on
// Grain, or a post on Bluesky with its image and reply.
//
// GRAIN_SOCIAL_PASSWORD=<app password> node marketing/update/post.mjs <step> [--dry-run]
//
// The app password is grain.social's, never kept in the repository. A step
// already posted, the same text or the same image among the account's
// latest records, is not posted again. --dry-run logs in and says what it
// would post, and posts nothing.
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const { account, images, steps } = JSON.parse(await readFile(path.join(here, "posts.json"), "utf8"));
const [name, flag] = process.argv.slice(2);
const dry = flag === "--dry-run";
const step = steps[name];
if (!step) {
console.error(`usage: node marketing/update/post.mjs <${Object.keys(steps).join("|")}> [--dry-run]`);
process.exit(2);
}
const password = process.env.GRAIN_SOCIAL_PASSWORD;
if (!password) {
console.error("GRAIN_SOCIAL_PASSWORD: grain.social's app password");
process.exit(2);
}
const doc = await (await fetch(`https://plc.directory/${account}`)).json();
const pds = doc.service.find((s) => s.id === "#atproto_pds").serviceEndpoint;
async function xrpc(method, { body, query, type = "application/json", token } = {}) {
const url = new URL(`/xrpc/${method}`, pds);
for (const [k, v] of Object.entries(query ?? {})) url.searchParams.set(k, v);
const res = await fetch(url, {
method: body === undefined ? "GET" : "POST",
headers: {
...(body === undefined ? {} : { "content-type": type }),
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: body === undefined ? undefined : type === "application/json" ? JSON.stringify(body) : body,
});
const text = await res.text();
if (!res.ok) throw new Error(`${method}: ${res.status} ${text.slice(0, 300)}`);
return text ? JSON.parse(text) : {};
}
const session = await xrpc("com.atproto.server.createSession", {
body: { identifier: account, password },
});
const token = session.accessJwt;
console.log(`logged in as ${session.handle} on ${pds}`);
const latest = async (collection) =>
(await xrpc("com.atproto.repo.listRecords", { query: { repo: account, collection, limit: "50" }, token })).records;
async function upload(file) {
const bytes = await readFile(path.join(here, file));
return (await xrpc("com.atproto.repo.uploadBlob", { body: bytes, type: "image/png", token })).blob;
}
async function create(collection, record) {
if (dry) {
console.log(`would create ${collection}:`, JSON.stringify(record, null, 2));
return { uri: "(dry run)", cid: "(dry run)" };
}
const out = await xrpc("com.atproto.repo.createRecord", {
body: { repo: account, collection, record },
token,
});
console.log(`created ${out.uri}`);
return out;
}
const now = () => new Date().toISOString();
if (step.story) {
const { width, height } = images[step.story];
const stories = await latest("social.grain.story");
if (dry) {
console.log(`${stories.length} recent stories; would upload ${step.story} and post it as a story`);
await create("social.grain.story", { $type: "social.grain.story", media: "(blob)", aspectRatio: { width, height }, createdAt: now() });
} else {
const media = await upload(step.story);
const posted = stories.find((r) => r.value.media?.ref?.$link === media.ref.$link);
if (posted) console.log(`already posted: ${posted.uri}`);
else await create("social.grain.story", { $type: "social.grain.story", media, aspectRatio: { width, height }, createdAt: now() });
}
} else {
const posted = (await latest("app.bsky.feed.post")).find((r) => r.value.text === step.text);
if (posted) {
console.log(`already posted: ${posted.uri}`);
process.exit(0);
}
const record = { $type: "app.bsky.feed.post", text: step.text, langs: ["en"], createdAt: now() };
if (step.image) {
const { width, height, alt } = images[step.image];
record.embed = {
$type: "app.bsky.embed.images",
images: [{ image: dry ? "(blob)" : await upload(step.image), alt, aspectRatio: { width, height } }],
};
}
const root = await create("app.bsky.feed.post", record);
if (step.reply) {
const ref = { uri: root.uri, cid: root.cid };
await create("app.bsky.feed.post", {
$type: "app.bsky.feed.post",
text: step.reply,
langs: ["en"],
reply: { root: ref, parent: ref },
createdAt: now(),
});
}
}
marketing/update/post.pngnew+0−0
A binary file, not shown.
marketing/update/posts.jsonnew+45−0
@@ -0,0 +1,45 @@
{
"account": "did:plc:e7rftrdyz5e2rw4y6ocszew2",
"images": {
"story-1.png": {
"width": 1080,
"height": 1920,
"alt": "Grain's app icon above the words \"Heads up. A new Grain is on the way. We’ve made big changes under the hood. Update the app to keep your feed, stories and galleries working. Older versions of the app stop working on Monday, Nov 16.\""
},
"story-2.png": {
"width": 1080,
"height": 1920,
"alt": "Grain's app icon above the words \"Update today. Get the latest Grain. Open the App Store or Google Play and update Grain. It takes a minute, and nothing you posted changes. Older versions of the app stop working on Monday, Nov 16.\""
},
"story-3.png": {
"width": 1080,
"height": 1920,
"alt": "Grain's app icon above the words \"Today. Grain updates today. Grain is down for about 15 minutes while the update goes out. Update the app if you haven’t yet. Older versions of the app stop working today.\""
},
"post.png": {
"width": 1080,
"height": 1350,
"alt": "Grain's app icon above the words \"Update Grain. This version has stopped working. Update Grain from the App Store or Google Play to see your feed again. Your photos and galleries are all still there.\""
}
},
"steps": {
"story-1": { "story": "story-1.png" },
"story-2": { "story": "story-2.png" },
"story-3": { "story": "story-3.png" },
"announce": {
"text": "A big update to Grain arrives on Monday, Nov 16.\n\nPlease update the app before then: version 11 on the App Store, 0.8.0 on Google Play. Older versions stop working on the 16th. You’ll need to sign in again after updating.\n\nYour photos, galleries and stories all come with you.",
"image": "story-1.png",
"reply": "On the web, there’s nothing to do: grain.social switches over by itself, and you stay signed in."
},
"reminder": {
"text": "Grain’s big update arrives tomorrow, Monday, Nov 16.\n\nIf you haven’t updated the app yet, now’s the time: version 11 on the App Store, 0.8.0 on Google Play. Older versions stop working tomorrow.",
"image": "story-2.png"
},
"start": {
"text": "Grain’s update is going out now. The site and apps are down for about 15 minutes. We’ll post here when it’s back."
},
"done": {
"text": "Grain is back, and up to date. Thanks for waiting.\n\nIf the app asks you to update, get the latest from the App Store or Google Play. If anything looks off, reply here or send a report from the app."
}
}
}
marketing/update/README.mdnew+39−0
@@ -0,0 +1,39 @@
# The move to the new server
What grain says, and when, as it moves from the old server to grain-serve on
**Monday, Nov 16**.
- `posts.json`: every post's text, each image's size and alt text, and the
steps below.
- `notice.html` and `render.mjs`: the images, rendered given the date.
- `post.mjs`: posts a step as grain.social.
```sh
node marketing/update/render.mjs "Monday, Nov 16"
GRAIN_SOCIAL_PASSWORD=<app password> node marketing/update/post.mjs <step> [--dry-run]
```
The app password is grain.social's, the one the support agent posts with
(`BSKY_APP_PASSWORD` in `/etc/grain/secrets/support-agent.env` on the host).
It is never kept here. A step already posted is not posted again.
## When each goes out
| When | Step | What |
|---|---|---|
| Both store versions live (iOS 11, Android 0.8.0) | `story-1`, then `story-2` | grain.social's stories |
| Same day | `announce` | Bluesky post with `story-1.png`, and the web reply under it |
| Sunday, Nov 15 | `reminder` | Bluesky post with `story-2.png` |
| Monday, Nov 16, the morning | `story-3` | grain.social's story |
| Nov 16, as the cutover starts | `start` | Bluesky post |
| Nov 16, once it's live | `done` | Bluesky post |
| After the cutover | | `post.png`, the one item the old apps' feed shows |
## The old apps' feed item
The app versions before iOS 11 and Android 0.8.0 ask for a feed grain-serve
does not serve. In front of it, Caddy answers with one item showing
`post.png`, which grain-serve's pages serve as `/update-notice.png`
(hetzner-infra's `stacks/grain/files/Caddyfile.tftpl`). iOS 10 and Android
0.7.0 are asked to update by the minimums in `/app-version.json` before
that.
marketing/update/render.mjsnew+68−0
@@ -0,0 +1,68 @@
// The update notice: stories on grain.social's account before and on the
// day grain moves to grain-serve, and the one feed item the
// old apps get after. Renders notice.html for each into
// marketing/update/*.png. The copy, and the posts that go with them, are in
// README.md beside this.
//
// Usage: node marketing/update/render.mjs ["Monday, Nov 16"]
import { chromium } from "playwright";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const date = process.argv[2] ?? "Monday, Nov 16";
const STORY = { width: 1080, height: 1920 };
const NOTICES = [
{
file: "story-1.png",
size: STORY,
eyebrow: "Heads up",
title: "A new Grain is on the way",
body: "We’ve made big changes under the hood. Update the app to keep your feed, stories and galleries working.",
date: `Older versions of the app stop working on <b>${date}</b>.`,
},
{
file: "story-2.png",
size: STORY,
eyebrow: "Update today",
title: "Get the latest Grain",
body: "Open the App Store or Google Play and update Grain. It takes a minute, and nothing you posted changes.",
date: `Older versions of the app stop working on <b>${date}</b>.`,
},
{
file: "story-3.png",
size: STORY,
eyebrow: "Today",
title: "Grain updates today",
date: `Older versions of the app stop working <b>today</b>.`,
body: "Grain is down for about 15 minutes while the update goes out. Update the app if you haven’t yet.",
},
{
file: "post.png",
size: { width: 1080, height: 1350 },
post: true,
eyebrow: "Update Grain",
title: "This version has stopped working",
body: "Update Grain from the App Store or Google Play to see your feed again. Your photos and galleries are all still there.",
},
];
const browser = await chromium.launch();
for (const n of NOTICES) {
const page = await browser.newPage({ viewport: n.size, deviceScaleFactor: 1 });
await page.goto(`file://${path.join(here, "notice.html")}`, { waitUntil: "networkidle" });
await page.evaluate((n) => {
document.body.classList.toggle("post", !!n.post);
document.getElementById("eyebrow").textContent = n.eyebrow;
document.getElementById("title").textContent = n.title;
document.getElementById("body").textContent = n.body;
document.getElementById("date").innerHTML = n.date ?? "";
return document.fonts.ready;
}, n);
await page.screenshot({ path: path.join(here, n.file) });
await page.close();
console.log("wrote", n.file);
}
await browser.close();
marketing/update/story-1.pngnew+0−0
A binary file, not shown.
marketing/update/story-2.pngnew+0−0
A binary file, not shown.
marketing/update/story-3.pngnew+0−0
A binary file, not shown.