DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Nine returns, eleven sentences, and the login outranks everything else one row can say

Notifio's desktop app has a tab listing each saved search and whether that search is ready to reply to a landlord on your behalf. Each row needs to answer one question: what, if anything, do I have to do here?

A row has four inputs. Whether there is a saved recording for that site, what state that recording is in ("draft" | "verified" | "broken"), whether auto-reply is switched on, and whether the browser has a live login for that site. That is sixteen reachable combinations, and every one has to produce exactly one sentence a non-technical person can act on.

The function that does it has nine return statements and produces eleven distinct sentences.

The whole thing

function statusLine(
  recipe: Recipe | null,
  on: boolean,
  loggedIn: boolean,
  loginChecked: boolean
): string {
  if (!loginChecked) return "Checking your login…";

  // The login comes before everything else it could say, because nothing else on
  // this row can happen without it - not the setup, and not a reply.
  if (!loggedIn) {
    if (!recipe) return "Log in to this site to set it up. Listings are emailed to you meanwhile.";
    if (recipe.status === "broken") return "The site changed. Log in, then redo the setup.";
    return on
      ? "Signed out, so nothing is being sent. Log in to resume."
      : "Recorded. Log in to this site to let Notifio reply as you.";
  }

  if (!recipe) return "Listings are emailed to you. Not set up to reply yet.";
  if (recipe.status === "broken") {
    return recipe.brokenReason
      ? `The site changed: ${recipe.brokenReason}`
      : "The site changed. Redo the setup.";
  }
  if (!on) return "Ready. Switch on “Reply for me” to start.";
  if (recipe.successCount > 0) return `Replying for you. ${recipe.successCount} sent so far.`;
  return "Replying for you. Nothing sent yet.";
}
Enter fullscreen mode Exit fullscreen mode

A function whose whole body is a decision tree, returning strings. There is no i18n layer, no template object, no message catalogue. It is a switch written as early returns, and the thing it is actually encoding is a priority order.

The order is the design

Read the shape rather than the strings. There are two blocks: everything inside if (!loggedIn), and everything after it. The signed-out branch comes first and it is complete, which means no sentence in the second half can ever be shown to somebody who is signed out.

That is not an obvious choice. The natural way to write this is to ask about the recording first, because the recording is the thing the tab is nominally about, and treat the login as a detail to append. That version produces rows that say "Ready. Switch on Reply for me to start." to a user whose session expired, and the switch does nothing when they flip it, and they file a bug that is really a missing sentence.

So the login question is asked first and answered in four different ways depending on what else is missing:

recipe auto-reply sentence
none either Log in to this site to set it up. Listings are emailed to you meanwhile.
broken either The site changed. Log in, then redo the setup.
fine on Signed out, so nothing is being sent. Log in to resume.
fine off Recorded. Log in to this site to let Notifio reply as you.

All four say "log in". They differ in what they promise afterwards, which is the information the user actually needs to decide whether logging in is worth doing right now. The first one in particular is there to stop a panic: no login, no setup, and yet you have not stopped receiving listings. The feature that is broken is the optional one.

The badge beside the sentence is ordered identically:

// The badge names the step the row is waiting on, and while signed out that is
// the login whatever else is or isn't done: no session, no setup and no reply.
if (loginChecked && !loggedIn) {
  return <span className={...}>Login needed</span>;
}
if (!recipe)                       return <span>Not set up</span>;
if (recipe.status === "broken")    return <span>Site changed</span>;
if (recipe.status === "verified" && loggedIn) return <span>Ready</span>;
return <span>Set up</span>;
Enter fullscreen mode Exit fullscreen mode

Five badges, same first question. A badge and a sentence that disagreed would be worse than either on its own, and the only thing preventing that is that both functions ask the same things in the same order.

The fourth parameter exists to prevent a lie

if (!loginChecked) return "Checking your login…";
Enter fullscreen mode Exit fullscreen mode

loginChecked is not "are they logged in", it is "do we know yet". The login state arrives from an async check per host, and before it lands the boolean is false, which is indistinguishable from a real signed-out answer.

Without this parameter, every row on first paint reads "Log in to this site to set it up" for a few hundred milliseconds and then corrects itself. That flash is a specific kind of bad: it tells the user something is wrong, they start reacting to it, and then it changes its mind. "Checking your login" costs one extra boolean through two function signatures and replaces a confident wrong answer with an honest one.

The same principle shows up in the badge, where the condition is loginChecked && !loggedIn rather than !loggedIn, so the "Login needed" chip cannot appear before anybody has asked.

Two notions of site identity in the same component

This bit is not elegant and the comments admit it. Login state is keyed by hostname:

function hostOf(url: string): string {
  try { return new URL(url).hostname; } catch { return ""; }
}
Enter fullscreen mode Exit fullscreen mode

Recordings are keyed by registrable-ish domain:

function domainOf(url: string): string | null {
  try {
    const host = new URL(url).hostname.replace(/^www\./, "");
    const parts = host.split(".");
    // Good enough for grouping in the list; the main process does the strict check.
    return parts.length > 2 ? parts.slice(-2).join(".") : host;
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Two different answers to "is this the same site", in one file, both used by the same row. The parts.slice(-2) heuristic is wrong for anything under a multi-part public suffix, and the comment says so and says where the real check lives instead. A cookie belongs to a host; a recorded form belongs to a site. Collapsing them would require getting the public suffix list into the renderer bundle in order to make a list look tidy, and the renderer is not where that decision gets made.

The login lookup does at least avoid asking the same question repeatedly:

const hosts = [...new Set(list.map((s) => hostOf(s.url)).filter(Boolean))];
const results = await Promise.all(hosts.map(async (host) => { ... }));
const byHost = new Map(results);
return list.map((s) => ({ ...s, loggedIn: byHost.get(hostOf(s.url)) ?? false }));
Enter fullscreen mode Exit fullscreen mode

Fifteen searches across four sites is four checks, not fifteen. ?? false on the lookup, because a host that somehow is not in the map is a signed-out row, not an exception.

Three numbers out, for a header that is not this component's job

export interface SetupCounts {
  /** Recorded and logged in - able to reply. */
  ready: number;
  total: number;
  /** Signed out, so they can be neither set up nor replied from. */
  needLogin: number;
}
Enter fullscreen mode Exit fullscreen mode

ready requires both halves: s.loggedIn === true && recipeFor(recipeList, s) !== null. Not one or the other, because a count of things that are "set up" but cannot act is a number that makes the tab look finished when it is not.

needLogin is reported separately rather than being implied by total - ready, because those are different questions. total - ready includes rows that need a recording, rows whose recording broke, and rows that are signed out, and the only one of those the header can usefully nag about is the last.

The one refusal

Two different actions in this component can be refused by the local server with a 409:

const { ok, data } = await startRecording(site.url, listingUrl.trim());
if (!ok) {
  showToast(data.error || "Could not start");
  // Recording happens in the site's own signed-in session, so the server
  // refuses it while signed out. Offer the login rather than the error alone.
  if (data.needsLogin) setLoginFor(site);
  return;
}
Enter fullscreen mode Exit fullscreen mode
const { ok, data } = await patchSite(index, { replyMode: mode });
if (!ok) {
  showToast(data.error || "Could not update");
  // The server refuses "auto" without a saved login. Take them straight to
  // the one thing that fixes it instead of leaving them to work it out.
  if (data.needsLogin) setLoginFor(sites[index] ?? null);
  return;
}
Enter fullscreen mode Exit fullscreen mode

Same shape twice: show the server's sentence, and if the server said needsLogin, open the login window. The error is not swallowed and replaced by the modal, which would hide why the modal appeared. Both happen.

On the server side the refusal is one function returning either null or the 409 body, with the comment that explains the whole policy:

The reply engine refuses without one, so the requests that set it up are refused here too rather than being left in a state that could never work.

and a catch that is the only interesting line in it:

try {
  loggedIn = await checkLoginStatus(siteUrl);
} catch {
  // A check that failed is not permission to carry on.
}
Enter fullscreen mode Exit fullscreen mode

Default false, and a failed check stays false. The permissive reading, treating "could not determine" as "probably fine", would let a user switch on a feature that cannot work and discover that by it silently not working.

What all of this is in service of is written on notifio.app/help, and the honest per-site status for auto-reply, which differs by portal, is on each site's own page: notifio.app/alerts/kamernet and notifio.app/alerts/roommatch are the two most people are here for.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.