Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/self-hosted/src/core/i18n-strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ export type TranslationKey =
| 'newsletterCadence'
| 'newsletterSubscribe'
| 'newsletterCheckInbox'
| 'newsletterUseAnotherAddress'
| 'newsletterError'
| 'panel_configuration_instance_configuration_features_newsletter_label'
| 'panel_configuration_instance_configuration_features_newsletter_enabled_label'
Expand Down Expand Up @@ -348,6 +349,7 @@ export const translations: { en: Translations } & Record<
newsletterCadence: 'How often',
newsletterSubscribe: 'Subscribe',
newsletterCheckInbox: 'Almost there: confirm from the email we just sent.',
newsletterUseAnotherAddress: 'Use a different address',
newsletterError: 'Could not subscribe right now. Please try again.',
loading: "Loading...",
hivesigner_login_failed: 'Sign in could not be completed. Please try again.',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import ts from 'typescript';
import { describe, expect, it } from 'vitest';

/**
* Source guards for the shape of the About page: it carries the signup, and it
* has a heading in every outcome.
*
* The page sits under a newsletter section whose own heading is an h2
* (vision-web#1551). Three of the four shells carry a masthead h1 of their own,
* DefaultShell through BlogNavigation and journal and terminal directly, but
* the reader shell renders its title in a span. So on that template, any About
* outcome that skipped the page heading left the document starting at h2, which
* is exactly what the loading and failure branches used to do: they returned a
* bare line of text.
*
* Both variants now return through `AboutFrame`, which owns the h1, and the
* identity it needs comes from config rather than from the request. Checked in
* the source because the branch that regresses is a loading state behind a
* query, and the same approach as `failure-states.test.ts` next door.
*/

const FILE = join(__dirname, 'about-page.tsx');
/** Every one of these must return through the frame, in every branch. */
const VARIANTS = ['BlogAbout', 'CommunityAbout'];
const FRAME = 'AboutFrame';

function parse(): ts.SourceFile {
return ts.createSourceFile(
FILE,
readFileSync(FILE, 'utf8'),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
}

function each(node: ts.Node, visit: (n: ts.Node) => void): void {
visit(node);
ts.forEachChild(node, (child) => each(child, visit));
}

/**
* The return statements belonging to this function itself, not to callbacks
* nested inside it: a `useMemo(() => ...)` return is not what the component
* renders.
*/
function ownReturns(fn: ts.Node): ts.ReturnStatement[] {
const out: ts.ReturnStatement[] = [];
const walk = (node: ts.Node) => {
ts.forEachChild(node, (child) => {
if (
ts.isArrowFunction(child) ||
ts.isFunctionExpression(child) ||
ts.isFunctionDeclaration(child)
) {
return;
}
if (ts.isReturnStatement(child)) out.push(child);
walk(child);
});
};
walk(fn);
return out;
}

function functionNamed(source: ts.SourceFile, name: string): ts.Node {
let found: ts.Node | undefined;
each(source, (n) => {
if (ts.isFunctionDeclaration(n) && n.name?.text === name) found = n;
});
if (!found) throw new Error(`${name} is no longer a function declaration`);
return found;
}

/** The tag name of a returned JSX element, or null for anything else. */
function returnedTag(statement: ts.ReturnStatement): string | null {
const expr = statement.expression;
if (!expr) return null;
const jsx = ts.isParenthesizedExpression(expr) ? expr.expression : expr;
if (ts.isJsxElement(jsx)) return jsx.openingElement.tagName.getText();
if (ts.isJsxSelfClosingElement(jsx)) return jsx.tagName.getText();
return null;
}

describe('the About page carries the signup', () => {
const source = parse();

/**
* Checked here rather than by rendering: AboutPage sits behind the router,
* the config store and a react-query provider, and what can regress is the
* mount itself. Rendering NewsletterSignup directly, which the component
* suite does, cannot see whether the page still mounts it.
*/
it('mounts it once, in the page frame, outside both variants', () => {
const body = functionNamed(source, 'AboutPage').getText();
expect(body).toContain('<NewsletterSignup placement="page" />');

// Outside the variant switch on purpose: both variants return early while
// their query is loading or failed, and the signup depends on neither.
for (const variant of VARIANTS) {
expect(functionNamed(source, variant).getText()).not.toContain(
'NewsletterSignup',
);
}

let mounts = 0;
each(source, (n) => {
if (
ts.isJsxSelfClosingElement(n) &&
n.tagName.getText() === 'NewsletterSignup'
) {
mounts += 1;
}
});
expect(mounts).toBe(1);
});
});

describe('the About page always has a heading', () => {
const source = parse();

it.each(VARIANTS)('%s returns through the frame in every branch', (name) => {
// The component's OWN returns. Descending into nested functions would
// collect the `useMemo` callbacks' returns, which are not what renders.
const returns = ownReturns(functionNamed(source, name));

// Loading, failed and success. If a branch is ever added, it is covered by
// the same assertion rather than needing a new case here.
expect(returns.length).toBeGreaterThanOrEqual(3);
expect(returns.map(returnedTag)).toEqual(returns.map(() => FRAME));
});

it('the frame is the one place the page heading lives', () => {
const text = readFileSync(FILE, 'utf8');
// Exactly one, and it is inside the frame: two would give the page two
// titles in the success state, none would put us back where we started.
expect(text.match(/<h1[\s>]/g)?.length).toBe(1);

let headings = 0;
each(functionNamed(source, FRAME), (n) => {
if (ts.isJsxElement(n) && n.openingElement.tagName.getText() === 'h1') {
headings += 1;
}
});
expect(headings).toBe(1);
});
});
141 changes: 102 additions & 39 deletions apps/self-hosted/src/features/blog/components/about-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { getAccountFullQueryOptions } from '@ecency/sdk';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { type ReactNode, useMemo } from 'react';
import { formatMonthYear, InstanceConfigManager, t } from '@/core';
import { UserAvatar } from '@/features/shared';
import { ErrorMessage } from '@/features/shared/error-message';
Expand All @@ -16,6 +16,7 @@ import {
useInstanceConfig,
} from '../hooks/use-instance-config';
import { safeWebsiteUrl } from '../utils/safe-website';
import { NewsletterSignup } from './newsletter-signup';

/**
* The About surface, generated from what already exists on chain: a blog
Expand All @@ -28,7 +29,59 @@ import { safeWebsiteUrl } from '../utils/safe-website';
export function AboutPage() {
useDocumentMeta({ title: t('about_title') });
const { isCommunityMode } = useInstanceConfig();
return isCommunityMode ? <CommunityAbout /> : <BlogAbout />;
return (
<>
{isCommunityMode ? <CommunityAbout /> : <BlogAbout />}
{/* Outside the variant on purpose (vision-web#1551): both of them return
early while their account or community query is loading or has failed,
and the signup depends on neither. This is the one surface every
template has, so it is where the four sidebar-less templates offer the
digest at all. */}
<NewsletterSignup placement="page" />
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
</>
);
}

/**
* The page frame, and the heading it always has. The identity is known from
* config before any query resolves, so a loading or failed About page is still
* a titled page rather than a bare line of text.
*
* That matters beyond tidiness: without it the first heading on the page is
* whatever renders next, which for the newsletter section below is an h2. Three
* of the four shells carry a masthead h1 of their own (DefaultShell through
* BlogNavigation, journal and terminal directly), but the reader shell renders
* its title in a span, so on that template the document would have started at
* h2 for as long as the query was loading or failed.
*/
function AboutFrame({
title,
handle,
avatar,
cover,
children,
}: {
title: string;
handle?: string;
avatar?: ReactNode;
cover?: ReactNode;
children?: ReactNode;
}) {
return (
<article className="max-w-3xl mx-auto">
{cover}
<div className="flex items-center gap-4 mb-6">
{avatar}
<div>
<h1 className="heading-theme text-2xl sm:text-3xl">{title}</h1>
{handle && (
<p className="text-sm text-theme-muted font-theme-ui">{handle}</p>
)}
</div>
</div>
{children}
</article>
);
}

function BlogAbout() {
Expand Down Expand Up @@ -76,34 +129,43 @@ function BlogAbout() {
hasContent: !!data,
});

// The handle and the avatar come from config, not from the request, so the
// page is recognisable in every outcome.
const identity = {
handle: `@${username}`,
avatar: <UserAvatar username={username} size="sLarge" />,
};

if (outcome === 'failed') {
return <ErrorMessage onRetry={() => refetch()} />;
return (
<AboutFrame title={username} {...identity}>
<ErrorMessage onRetry={() => refetch()} />
</AboutFrame>
);
}
if (nothingToShow(outcome) || !data) {
return (
<div className="text-center py-12 text-theme-muted">{t('loading')}</div>
<AboutFrame title={username} {...identity}>
<div className="text-center py-12 text-theme-muted">{t('loading')}</div>
</AboutFrame>
);
}

return (
<article className="max-w-3xl mx-auto">
{coverUrl && (
<img
src={coverUrl}
alt=""
aria-hidden="true"
className="w-full h-40 sm:h-56 object-cover rounded-lg mb-6"
/>
)}
<div className="flex items-center gap-4 mb-6">
<UserAvatar username={username} size="sLarge" />
<div>
<h1 className="heading-theme text-2xl sm:text-3xl">
{data.name || username}
</h1>
<p className="text-sm text-theme-muted font-theme-ui">@{username}</p>
</div>
</div>
<AboutFrame
title={data.name || username}
{...identity}
cover={
coverUrl && (
<img
src={coverUrl}
alt=""
aria-hidden="true"
className="w-full h-40 sm:h-56 object-cover rounded-lg mb-6"
/>
)
}
>

{profile?.about && (
<p className="text-theme-secondary leading-relaxed mb-6">
Expand Down Expand Up @@ -140,7 +202,7 @@ function BlogAbout() {
</div>
)}
</dl>
</article>
</AboutFrame>
);
}

Expand All @@ -161,11 +223,17 @@ function CommunityAbout() {
});

if (outcome === 'failed') {
return <ErrorMessage onRetry={() => refetch()} />;
return (
<AboutFrame title={communityId}>
<ErrorMessage onRetry={() => refetch()} />
</AboutFrame>
);
}
if (nothingToShow(outcome) || !community) {
return (
<div className="text-center py-12 text-theme-muted">{t('loading')}</div>
<AboutFrame title={communityId}>
<div className="text-center py-12 text-theme-muted">{t('loading')}</div>
</AboutFrame>
);
}

Expand All @@ -174,25 +242,20 @@ function CommunityAbout() {
: null;

return (
<article className="max-w-3xl mx-auto">
<div className="flex items-center gap-4 mb-6">
{avatarUrl && (
<AboutFrame
title={community.title || communityId}
handle={community.name}
avatar={
avatarUrl && (
<img
src={avatarUrl}
alt=""
aria-hidden="true"
className="size-14 rounded-full object-cover"
/>
)}
<div>
<h1 className="heading-theme text-2xl sm:text-3xl">
{community.title || communityId}
</h1>
<p className="text-sm text-theme-muted font-theme-ui">
{community.name}
</p>
</div>
</div>
)
}
>

{community.about && (
<p className="text-theme-secondary leading-relaxed mb-6">
Expand All @@ -204,6 +267,6 @@ function CommunityAbout() {
{community.description}
</div>
)}
</article>
</AboutFrame>
);
}
Loading
Loading