Use team name and favicon (when public branding enabled) on shared links

This commit is contained in:
Tom Moor
2023-05-08 14:46:25 -04:00
parent a0df79ea5a
commit 07ae67924f
8 changed files with 69 additions and 49 deletions

View File

@@ -1,35 +1,34 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { cdnPath } from "@shared/utils/urls";
import env from "~/env"; import env from "~/env";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { useTeamContext } from "./TeamContext";
type Props = { type Props = {
title: React.ReactNode; title: React.ReactNode;
favicon?: string; favicon?: string;
}; };
const originalShortcutHref = document
.querySelector('link[rel="shortcut icon"]')
?.getAttribute("href") as string;
const PageTitle = ({ title, favicon }: Props) => { const PageTitle = ({ title, favicon }: Props) => {
const { auth } = useStores(); const { auth } = useStores();
const { team } = auth; const team = useTeamContext() ?? auth.team;
return ( return (
<Helmet> <Helmet>
<title> <title>
{team?.name ? `${title} - ${team.name}` : `${title} - ${env.APP_NAME}`} {team?.name ? `${title} - ${team.name}` : `${title} - ${env.APP_NAME}`}
</title> </title>
{favicon ? ( <link
<link rel="shortcut icon" href={favicon} key={favicon} /> rel="shortcut icon"
) : ( type="image/png"
<link href={favicon ?? originalShortcutHref}
rel="shortcut icon" key={favicon ?? originalShortcutHref}
type="image/png" />
key="favicon"
href={cdnPath("/images/favicon-32.png")}
sizes="32x32"
/>
)}
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
</Helmet> </Helmet>
); );

View File

@@ -8,7 +8,7 @@ import SearchPopover from "~/components/SearchPopover";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import history from "~/utils/history"; import history from "~/utils/history";
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers"; import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
import { IAvatar } from "../Avatar/Avatar"; import { useTeamContext } from "../TeamContext";
import TeamLogo from "../TeamLogo"; import TeamLogo from "../TeamLogo";
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
import HeaderButton from "./components/HeaderButton"; import HeaderButton from "./components/HeaderButton";
@@ -16,12 +16,12 @@ import Section from "./components/Section";
import DocumentLink from "./components/SharedDocumentLink"; import DocumentLink from "./components/SharedDocumentLink";
type Props = { type Props = {
team?: IAvatar & { name: string };
rootNode: NavigationNode; rootNode: NavigationNode;
shareId: string; shareId: string;
}; };
function SharedSidebar({ rootNode, team, shareId }: Props) { function SharedSidebar({ rootNode, shareId }: Props) {
const team = useTeamContext();
const { ui, documents, auth } = useStores(); const { ui, documents, auth } = useStores();
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -0,0 +1,11 @@
import * as React from "react";
import { PublicTeam } from "@shared/types";
import Team from "~/models/Team";
export const TeamContext = React.createContext<Team | PublicTeam | undefined>(
undefined
);
export function useTeamContext() {
return React.useContext(TeamContext);
}

View File

@@ -7,12 +7,13 @@ import { RouteComponentProps, useLocation, Redirect } from "react-router-dom";
import styled, { ThemeProvider } from "styled-components"; import styled, { ThemeProvider } from "styled-components";
import { setCookie } from "tiny-cookie"; import { setCookie } from "tiny-cookie";
import { s } from "@shared/styles"; import { s } from "@shared/styles";
import { CustomTheme, NavigationNode } from "@shared/types"; import { NavigationNode, PublicTeam } from "@shared/types";
import DocumentModel from "~/models/Document"; import DocumentModel from "~/models/Document";
import Error404 from "~/scenes/Error404"; import Error404 from "~/scenes/Error404";
import ErrorOffline from "~/scenes/ErrorOffline"; import ErrorOffline from "~/scenes/ErrorOffline";
import Layout from "~/components/Layout"; import Layout from "~/components/Layout";
import Sidebar from "~/components/Sidebar/Shared"; import Sidebar from "~/components/Sidebar/Shared";
import { TeamContext } from "~/components/TeamContext";
import Text from "~/components/Text"; import Text from "~/components/Text";
import env from "~/env"; import env from "~/env";
import useBuildTheme from "~/hooks/useBuildTheme"; import useBuildTheme from "~/hooks/useBuildTheme";
@@ -29,11 +30,7 @@ const EMPTY_OBJECT = {};
type Response = { type Response = {
document: DocumentModel; document: DocumentModel;
team?: { team?: PublicTeam;
name: string;
avatarUrl: string;
customTheme?: Partial<CustomTheme>;
};
sharedTree?: NavigationNode | undefined; sharedTree?: NavigationNode | undefined;
}; };
@@ -166,14 +163,6 @@ function SharedDocumentScene(props: Props) {
return <Redirect to={response.document.url} />; return <Redirect to={response.document.url} />;
} }
const sidebar = response.sharedTree?.children.length ? (
<Sidebar
rootNode={response.sharedTree}
team={response.team}
shareId={shareId}
/>
) : undefined;
return ( return (
<> <>
<Helmet> <Helmet>
@@ -182,17 +171,26 @@ function SharedDocumentScene(props: Props) {
href={canonicalOrigin + location.pathname.replace(/\/$/, "")} href={canonicalOrigin + location.pathname.replace(/\/$/, "")}
/> />
</Helmet> </Helmet>
<ThemeProvider theme={theme}> <TeamContext.Provider value={response.team}>
<Layout title={response.document.title} sidebar={sidebar}> <ThemeProvider theme={theme}>
<Document <Layout
abilities={EMPTY_OBJECT} title={response.document.title}
document={response.document} sidebar={
sharedTree={response.sharedTree} response.sharedTree?.children.length ? (
shareId={shareId} <Sidebar rootNode={response.sharedTree} shareId={shareId} />
readOnly ) : undefined
/> }
</Layout> >
</ThemeProvider> <Document
abilities={EMPTY_OBJECT}
document={response.document}
sharedTree={response.sharedTree}
shareId={shareId}
readOnly
/>
</Layout>
</ThemeProvider>
</TeamContext.Provider>
</> </>
); );
} }

View File

@@ -1,7 +1,7 @@
import invariant from "invariant"; import invariant from "invariant";
import { find, orderBy, filter, compact, omitBy } from "lodash"; import { find, orderBy, filter, compact, omitBy } from "lodash";
import { observable, action, computed, runInAction } from "mobx"; import { observable, action, computed, runInAction } from "mobx";
import { DateFilter, NavigationNode } from "@shared/types"; import { DateFilter, NavigationNode, PublicTeam } from "@shared/types";
import { subtractDate } from "@shared/utils/date"; import { subtractDate } from "@shared/utils/date";
import { bytesToHumanReadable } from "@shared/utils/files"; import { bytesToHumanReadable } from "@shared/utils/files";
import naturalSort from "@shared/utils/naturalSort"; import naturalSort from "@shared/utils/naturalSort";
@@ -9,7 +9,6 @@ import { DocumentValidation } from "@shared/validations";
import BaseStore from "~/stores/BaseStore"; import BaseStore from "~/stores/BaseStore";
import RootStore from "~/stores/RootStore"; import RootStore from "~/stores/RootStore";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Team from "~/models/Team";
import env from "~/env"; import env from "~/env";
import { FetchOptions, PaginationParams, SearchResult } from "~/types"; import { FetchOptions, PaginationParams, SearchResult } from "~/types";
import { client } from "~/utils/ApiClient"; import { client } from "~/utils/ApiClient";
@@ -38,7 +37,7 @@ type ImportOptions = {
export default class DocumentsStore extends BaseStore<Document> { export default class DocumentsStore extends BaseStore<Document> {
sharedCache: Map< sharedCache: Map<
string, string,
{ sharedTree: NavigationNode; team: Team } | undefined { sharedTree: NavigationNode; team: PublicTeam } | undefined
> = new Map(); > = new Map();
@observable @observable
@@ -471,7 +470,7 @@ export default class DocumentsStore extends BaseStore<Document> {
options: FetchOptions = {} options: FetchOptions = {}
): Promise<{ ): Promise<{
document: Document; document: Document;
team?: Team; team?: PublicTeam;
sharedTree?: NavigationNode; sharedTree?: NavigationNode;
}> => { }> => {
if (!options.prefetch) { if (!options.prefetch) {

View File

@@ -5,7 +5,7 @@ import { Context, Next } from "koa";
import { escape } from "lodash"; import { escape } from "lodash";
import { Sequelize } from "sequelize"; import { Sequelize } from "sequelize";
import isUUID from "validator/lib/isUUID"; import isUUID from "validator/lib/isUUID";
import { IntegrationType } from "@shared/types"; import { IntegrationType, TeamPreference } from "@shared/types";
import documentLoader from "@server/commands/documentLoader"; import documentLoader from "@server/commands/documentLoader";
import env from "@server/env"; import env from "@server/env";
import { Integration } from "@server/models"; import { Integration } from "@server/models";
@@ -49,6 +49,7 @@ export const renderApp = async (
title?: string; title?: string;
description?: string; description?: string;
canonical?: string; canonical?: string;
shortcutIcon?: string;
analytics?: Integration | null; analytics?: Integration | null;
} = {} } = {}
) => { ) => {
@@ -56,6 +57,7 @@ export const renderApp = async (
title = env.APP_NAME, title = env.APP_NAME,
description = "A modern team knowledge base for your internal documentation, product specs, support answers, meeting notes, onboarding, &amp; more…", description = "A modern team knowledge base for your internal documentation, product specs, support answers, meeting notes, onboarding, &amp; more…",
canonical = "", canonical = "",
shortcutIcon = `${env.CDN_URL || ""}/images/favicon-32.png`,
} = options; } = options;
if (ctx.request.path === "/realtime/") { if (ctx.request.path === "/realtime/") {
@@ -91,6 +93,7 @@ export const renderApp = async (
.replace(/\{title\}/g, escape(title)) .replace(/\{title\}/g, escape(title))
.replace(/\{description\}/g, escape(description)) .replace(/\{description\}/g, escape(description))
.replace(/\{canonical-url\}/g, canonical) .replace(/\{canonical-url\}/g, canonical)
.replace(/\{shortcut-icon\}/g, shortcutIcon)
.replace(/\{prefetch\}/g, shareId ? "" : prefetchTags) .replace(/\{prefetch\}/g, shareId ? "" : prefetchTags)
.replace(/\{slack-app-id\}/g, env.SLACK_APP_ID || "") .replace(/\{slack-app-id\}/g, env.SLACK_APP_ID || "")
.replace(/\{cdn-url\}/g, env.CDN_URL || "") .replace(/\{cdn-url\}/g, env.CDN_URL || "")
@@ -102,10 +105,10 @@ export const renderShare = async (ctx: Context, next: Next) => {
// Find the share record if publicly published so that the document title // Find the share record if publicly published so that the document title
// can be be returned in the server-rendered HTML. This allows it to appear in // can be be returned in the server-rendered HTML. This allows it to appear in
// unfurls with more reliablity // unfurls with more reliablity
let share, document, analytics; let share, document, team, analytics;
try { try {
const team = await getTeamFromContext(ctx); team = await getTeamFromContext(ctx);
const result = await documentLoader({ const result = await documentLoader({
id: documentSlug, id: documentSlug,
shareId, shareId,
@@ -146,6 +149,10 @@ export const renderShare = async (ctx: Context, next: Next) => {
return renderApp(ctx, next, { return renderApp(ctx, next, {
title: document?.title, title: document?.title,
description: document?.getSummary(), description: document?.getSummary(),
shortcutIcon:
team?.getPreference(TeamPreference.PublicBranding) && team.avatarUrl
? team.avatarUrl
: undefined,
analytics, analytics,
canonical: share canonical: share
? `${share.canonicalUrl}${documentSlug && document ? document.url : ""}` ? `${share.canonicalUrl}${documentSlug && document ? document.url : ""}`

View File

@@ -14,7 +14,7 @@
<link <link
rel="shortcut icon" rel="shortcut icon"
type="image/png" type="image/png"
href="{cdn-url}/static/images/favicon-32.png" href="{shortcut-icon}"
sizes="32x32" sizes="32x32"
/> />
<link <link

View File

@@ -119,6 +119,12 @@ export type CustomTheme = {
accentText: string; accentText: string;
}; };
export type PublicTeam = {
avatarUrl: string;
name: string;
customTheme: Partial<CustomTheme>;
};
export enum TeamPreference { export enum TeamPreference {
/** Whether documents have a separate edit mode instead of seamless editing. */ /** Whether documents have a separate edit mode instead of seamless editing. */
SeamlessEdit = "seamlessEdit", SeamlessEdit = "seamlessEdit",