Use team name and favicon (when public branding enabled) on shared links
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
11
app/components/TeamContext.ts
Normal file
11
app/components/TeamContext.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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, & more…",
|
description = "A modern team knowledge base for your internal documentation, product specs, support answers, meeting notes, onboarding, & 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 : ""}`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user