1
Fork 0

Compare commits

..

No commits in common. "866d8238200b7276e228ecd579ca3770ce3849bf" and "48742a98641e75b1d14858cb474c8ea14a80696b" have entirely different histories.

15 changed files with 202 additions and 342 deletions

View File

@ -1,17 +1,14 @@
import Shepherd from "shepherd.js"; import Shepherd from "shepherd.js";
import {fromStorage, StorageKey} from "../storage/common.js";
import { import {
allTours, addCompletedTour,
introductionTour, createIntroductionUnderstood,
showTourError, } from "../storage/common.js";
type TourData, import {introductionSteps} from "../tours/introduction.js";
} from "../tours/exports.js"; import {TourId, tourIdsAndSteps} from "../tours/exports.js";
/** The main entry point for the content script. */ /** The main entry point for the content script. */
async function main(): Promise<void> { async function main(): Promise<void> {
const introductionUnderstood = await fromStorage( const introductionUnderstood = await createIntroductionUnderstood();
StorageKey.IntroductionUnderstood,
);
// Get the anchor without the leading #. // Get the anchor without the leading #.
const anchor = window.location.hash.slice(1); const anchor = window.location.hash.slice(1);
@ -29,7 +26,9 @@ async function main(): Promise<void> {
// If a different tour is selected but the introduction hasn't happened yet, // If a different tour is selected but the introduction hasn't happened yet,
// then the main function will be rerun once this tour finishes. // then the main function will be rerun once this tour finishes.
startTour( startTour(
introductionTour, TourId.Introduction,
introductionSteps,
[],
startsWithPrefix && anchorTourId !== "introduction", startsWithPrefix && anchorTourId !== "introduction",
); );
return; return;
@ -39,29 +38,11 @@ async function main(): Promise<void> {
return; return;
} }
const userIsLoggedIn =
document.querySelector(".logged-in-user-username") !== null;
// Then run through all of the tours we have and start the first match for the // Then run through all of the tours we have and start the first match for the
// ID. // ID.
for (const tour of allTours) { for (const [id, steps, eventHandlers] of tourIdsAndSteps) {
if (anchorTourId === tour.id) { if (anchorTourId === id) {
if (tour.requirements.mustBeLoggedIn && !userIsLoggedIn) { startTour(id, steps, eventHandlers, false);
showTourError(
`The ${tour.id} tour can only be shown with a logged in account.`,
);
return;
}
if (tour.requirements.path !== window.location.pathname) {
// This tour's path requirement does not match.
showTourError(
`The ${tour.id} tour can only be start on the ${tour.requirements.path} page.`,
);
return;
}
startTour(tour, false);
return; return;
} }
} }
@ -77,7 +58,12 @@ async function main(): Promise<void> {
* @param runMainAgainAfterComplete Should the `main` function be run after this * @param runMainAgainAfterComplete Should the `main` function be run after this
* tour is completed? * tour is completed?
*/ */
function startTour(data: TourData, runMainAgainAfterComplete: boolean): void { function startTour(
tourId: TourId,
steps: TourStepOptions[],
eventHandlers: TourStepEventHandler[],
runMainAgainAfterComplete: boolean,
): void {
const defaultButtons: Shepherd.Step.StepOptionsButton[] = [ const defaultButtons: Shepherd.Step.StepOptionsButton[] = [
{ {
classes: "btn", classes: "btn",
@ -120,9 +106,7 @@ function startTour(data: TourData, runMainAgainAfterComplete: boolean): void {
} }
// Mark the tour as completed. // Mark the tour as completed.
const completedTours = await fromStorage(StorageKey.ToursCompleted); await addCompletedTour(tourId);
completedTours.value.add(data.id);
await completedTours.save();
if (runMainAgainAfterComplete) { if (runMainAgainAfterComplete) {
await main(); await main();
@ -131,24 +115,19 @@ function startTour(data: TourData, runMainAgainAfterComplete: boolean): void {
// For every step we have, add it to the tour and subsequently add all the // For every step we have, add it to the tour and subsequently add all the
// event handlers to that step. // event handlers to that step.
for (const [stepNumber, stepOptions] of data.steps.entries()) { for (const [stepNumber, stepOptions] of steps.entries()) {
// If the final step doesn't have buttons defined, set the "Continue" button // If the final step doesn't have buttons defined, set the "Continue" button
// text to "Finish". // text to "Finish".
if ( if (stepOptions.buttons === undefined && stepNumber + 1 === steps.length) {
stepOptions.buttons === undefined &&
stepNumber + 1 === data.steps.length
) {
stepOptions.buttons = [...defaultButtons]; stepOptions.buttons = [...defaultButtons];
stepOptions.buttons[0].text = "Finish"; stepOptions.buttons[0].text = "Finish";
} }
const step = tour.addStep(stepOptions); const step = tour.addStep(stepOptions);
for (const {stepId, eventHandlers} of data.eventHandlers) { for (const [targetStepId, [eventName, eventHandler]] of eventHandlers) {
if (stepId === step.id) { if (targetStepId === step.id) {
for (const {event, handler} of eventHandlers) { step.on(eventName, eventHandler);
step.on(event, handler);
}
} }
} }
} }

View File

@ -1,22 +1,59 @@
import {Component} from "preact"; import {Component, type JSX} from "preact";
import {type TourData} from "../../tours/exports.js"; import {TourId} from "../../tours/exports.js";
type Props = { type Props = {
hasBeenCompleted: boolean; hasBeenCompleted: boolean;
tour: TourData; name: string;
tourId: TourId;
}; };
function tourLink(tour: TourData): string { function tourDescription(tourId: Props["tourId"]): JSX.Element {
const anchor = `#tildes-shepherd-tour=${tour.id}`; if (tourId === TourId.Introduction) {
return (
<p class="tour-description">
A short introduction to Tildes Shepherd and how the tours work. Normally
this is automatically shown when you first installed the extension.
</p>
);
}
if (tourId === TourId.InterfaceHomepage) {
return (
<p class="tour-description">
Let's take a look at the home page and all we can do there.
</p>
);
}
return (
<p class="tour-description">
Tour ID "{tourId}" does not have a description, this should probably be
fixed!
</p>
);
}
function tourLink(tourId: Props["tourId"]): string {
const anchor = `#tildes-shepherd-tour=${tourId}`;
const baseUrl = "https://tildes.net"; const baseUrl = "https://tildes.net";
const path = tour.requirements.path; let path = "";
switch (tourId) {
case TourId.InterfaceHomepage:
case TourId.Introduction: {
path = "/";
break;
}
default:
}
return `${baseUrl}${path}${anchor}`; return `${baseUrl}${path}${anchor}`;
} }
export class Tour extends Component<Props> { export class Tour extends Component<Props> {
render() { render() {
const {hasBeenCompleted, tour} = this.props; const {hasBeenCompleted, name, tourId} = this.props;
const classes = ["tour", hasBeenCompleted ? "completed" : ""].join(" "); const classes = ["tour", hasBeenCompleted ? "completed" : ""].join(" ");
const completed = hasBeenCompleted ? ( const completed = hasBeenCompleted ? (
<p class="tour-completed" title="You've completed this tour before!"> <p class="tour-completed" title="You've completed this tour before!">
@ -26,11 +63,11 @@ export class Tour extends Component<Props> {
return ( return (
<div class={classes.trim()}> <div class={classes.trim()}>
<h3>{tour.title}</h3> <h3>{name}</h3>
{completed} {completed}
{tour.description} {tourDescription(tourId)}
<p class="tour-link"> <p class="tour-link">
<a href={tourLink(tour)}>Take this tour</a> <a href={tourLink(tourId)}>Take this tour</a>
</p> </p>
</div> </div>
); );

View File

@ -1,16 +1,12 @@
import {Component, type JSX} from "preact"; import {Component, type JSX} from "preact";
import { import {createToursCompleted} from "../../storage/common.js";
fromStorage, import {TourId} from "../../tours/exports.js";
StorageKey,
type StorageValues,
} from "../../storage/common.js";
import {allTours} from "../../tours/exports.js";
import {Tour} from "./tour.js"; import {Tour} from "./tour.js";
type Props = Record<string, unknown>; type Props = Record<string, unknown>;
type State = { type State = {
toursCompleted: Awaited<StorageValues[StorageKey.ToursCompleted]>; toursCompleted: Awaited<ReturnType<typeof createToursCompleted>>["value"];
}; };
export class Tours extends Component<Props, State> { export class Tours extends Component<Props, State> {
@ -18,29 +14,39 @@ export class Tours extends Component<Props, State> {
super(props); super(props);
this.state = { this.state = {
toursCompleted: undefined!, toursCompleted: new Set(),
}; };
} }
async componentDidMount(): Promise<void> { async componentDidMount(): Promise<void> {
const toursCompleted = await fromStorage(StorageKey.ToursCompleted); const toursCompleted = await createToursCompleted();
this.setState({toursCompleted}); this.setState({toursCompleted: toursCompleted.value});
} }
render(): JSX.Element { render(): JSX.Element {
const {toursCompleted} = this.state; const {toursCompleted} = this.state;
if (toursCompleted === undefined) {
return <></>;
}
const tours = allTours.map((tour) => ( const createTour = (tourId: TourId, name: string): Tour["props"] => {
<Tour hasBeenCompleted={toursCompleted.value.has(tour.id)} tour={tour} /> return {
)); hasBeenCompleted: toursCompleted.has(tourId),
name,
tourId,
};
};
const tourProps: Array<Tour["props"]> = [
createTour(TourId.Introduction, "Introduction"),
createTour(TourId.InterfaceHomepage, "The Homepage"),
];
return ( return (
<main> <main>
<h2>Tours</h2> <h2>Tours</h2>
<div class="tours">{tours}</div> <div class="tours">
{tourProps.map((props) => (
<Tour {...props} />
))}
</div>
</main> </main>
); );
} }

View File

@ -2,39 +2,33 @@ import browser from "webextension-polyfill";
import {createValue} from "@holllo/webextension-storage"; import {createValue} from "@holllo/webextension-storage";
import {type TourId} from "../tours/exports.js"; import {type TourId} from "../tours/exports.js";
/** All available storage keys. */
export enum StorageKey { export enum StorageKey {
IntroductionUnderstood = "introduction-understood", IntroductionUnderstood = "introduction-understood",
ToursCompleted = "tours-completed", ToursCompleted = "tours-completed",
} }
/** All values we want to save in storage. */ export async function createIntroductionUnderstood() {
const storageValues = { return createValue<boolean>({
[StorageKey.IntroductionUnderstood]: createValue<boolean>({
deserialize: (input) => input === "true", deserialize: (input) => input === "true",
serialize: (input) => JSON.stringify(input), serialize: (input) => JSON.stringify(input),
key: StorageKey.IntroductionUnderstood, key: StorageKey.IntroductionUnderstood,
storage: browser.storage.local, storage: browser.storage.local,
value: false, value: false,
}), });
[StorageKey.ToursCompleted]: createValue<Set<TourId>>({ }
export async function createToursCompleted() {
return createValue<Set<TourId>>({
deserialize: (input) => new Set(JSON.parse(input) as TourId[]), deserialize: (input) => new Set(JSON.parse(input) as TourId[]),
serialize: (input) => JSON.stringify(Array.from(input)), serialize: (input) => JSON.stringify(Array.from(input)),
key: StorageKey.ToursCompleted, key: StorageKey.ToursCompleted,
storage: browser.storage.local, storage: browser.storage.local,
value: new Set([]), value: new Set([]),
}), });
}; }
/** Alias to get the inferred type shape of {@link storageValues}. */ export async function addCompletedTour(tourId: TourId): Promise<void> {
export type StorageValues = typeof storageValues; const toursCompleted = await createToursCompleted();
toursCompleted.value.add(tourId);
/** await toursCompleted.save();
* Get the stored value for a given key.
* @param key The key to get from storage.
*/
export async function fromStorage<K extends StorageKey>(
key: K,
): Promise<StorageValues[K]> {
return storageValues[key];
} }

View File

@ -1,14 +1,14 @@
import {accountSettingsTour, homepageTour} from "./interface/exports.js"; import {homepageEventHandlers, homepageSteps} from "./interface/exports.js";
import {introductionTour} from "./introduction.js"; import {introductionSteps} from "./introduction.js";
import {type TourData} from "./types.js";
export * from "./introduction.js"; export enum TourId {
export * from "./shared/exports.js"; InterfaceHomepage = "interface-homepage",
export * from "./types.js"; Introduction = "introduction",
}
/** All tours available in a single array. */ export const tourIdsAndSteps: Array<
export const allTours: TourData[] = [ [TourId, TourStepOptions[], TourStepEventHandler[]]
introductionTour, > = [
accountSettingsTour, [TourId.Introduction, introductionSteps, []],
homepageTour, [TourId.InterfaceHomepage, homepageSteps, homepageEventHandlers],
]; ];

View File

@ -1,32 +0,0 @@
import {type TourData, TourId} from "../types.js";
import {renderInContainer} from "../utilities.js";
const step01 = renderInContainer(
<>
<h1>Your Account Settings</h1>
</>,
);
const steps: TourData["steps"] = [
{
id: "account-settings-01",
text: step01,
},
];
const eventHandlers: TourData["eventHandlers"] = [];
const requirements: TourData["requirements"] = {
mustBeLoggedIn: true,
path: "/settings",
};
export const accountSettingsTour: TourData = {
id: TourId.InterfaceAccountSettings,
title: "Your Account Settings",
description: "View your account settings and all that you can customize.",
displayInOptionsPage: true,
eventHandlers,
requirements,
steps,
};

View File

@ -1,2 +1,4 @@
export * from "./account-settings.js"; export {
export * from "./homepage.js"; eventHandlers as homepageEventHandlers,
steps as homepageSteps,
} from "./homepage.js";

View File

@ -1,11 +1,11 @@
import {LoggedOutWarning} from "../shared/logged-out-warning.js"; import type Shepherd from "shepherd.js";
import {type TourData, TourId} from "../types.js";
import { import {
addDatasetCounter, addDatasetCounter,
encapsulateElements, encapsulateElements,
removeAllDatasetCounters, removeAllDatasetCounters,
renderInContainer, renderInContainer,
} from "../utilities.js"; } from "../utilities.js";
import {LoggedOutWarning} from "../shared/logged-out-warning.js";
const step01 = renderInContainer( const step01 = renderInContainer(
<> <>
@ -345,7 +345,7 @@ const step10 = renderInContainer(
</>, </>,
); );
const steps: TourData["steps"] = [ export const steps: Shepherd.Step.StepOptions[] = [
{ {
id: "homepage-01", id: "homepage-01",
text: step01, text: step01,
@ -433,13 +433,12 @@ const steps: TourData["steps"] = [
}, },
]; ];
const eventHandlers: TourData["eventHandlers"] = [ export const eventHandlers: TourStepEventHandler[] = [
{ [
stepId: "homepage-04", "homepage-04",
eventHandlers: [ [
{ "show",
event: "show", () => {
handler() {
const topic = ".topic-listing > li:first-child"; const topic = ".topic-listing > li:first-child";
const counters = [ const counters = [
".topic-title", ".topic-title",
@ -455,21 +454,22 @@ const eventHandlers: TourData["eventHandlers"] = [
addDatasetCounter(`${topic} ${selector}`, count + 1); addDatasetCounter(`${topic} ${selector}`, count + 1);
} }
}, },
},
], ],
}, ],
{ [
stepId: "homepage-05", "homepage-05",
eventHandlers: [ [
{ "destroy",
event: "destroy", () => {
handler() {
removeAllDatasetCounters(); removeAllDatasetCounters();
}, },
}, ],
{ ],
event: "show", [
handler() { "homepage-05",
[
"show",
() => {
encapsulateElements( encapsulateElements(
"homepage-06", "homepage-06",
"#sidebar .sidebar-controls", "#sidebar .sidebar-controls",
@ -477,31 +477,25 @@ const eventHandlers: TourData["eventHandlers"] = [
["#sidebar .form-search", "#sidebar h2", "#sidebar p"], ["#sidebar .form-search", "#sidebar h2", "#sidebar p"],
); );
}, },
],
],
[
"homepage-06",
[
"show",
() => {
encapsulateElements("homepage-07", "#sidebar .divider", "beforebegin", [
"#sidebar .nav",
'#sidebar [href="/groups"',
]);
}, },
], ],
},
{
stepId: "homepage-06",
eventHandlers: [
{
event: "show",
handler() {
encapsulateElements(
"homepage-07",
"#sidebar .divider",
"beforebegin",
["#sidebar .nav", '#sidebar [href="/groups"'],
);
},
},
], ],
}, [
{ "homepage-08",
stepId: "homepage-08", [
eventHandlers: [ "show",
{ () => {
event: "show",
handler() {
const filteredTags = const filteredTags =
document.querySelector<HTMLDetailsElement>("#sidebar details") ?? document.querySelector<HTMLDetailsElement>("#sidebar details") ??
undefined; undefined;
@ -512,22 +506,6 @@ const eventHandlers: TourData["eventHandlers"] = [
filteredTags.open = true; filteredTags.open = true;
}, },
},
], ],
}, ],
]; ];
const requirements: TourData["requirements"] = {
mustBeLoggedIn: false,
path: "/",
};
export const homepageTour: TourData = {
id: TourId.InterfaceHomepage,
title: "The Tildes Homepage",
description: "Let's take a look at the home page and all we can do there.",
displayInOptionsPage: true,
eventHandlers,
requirements,
steps,
};

View File

@ -1,5 +1,5 @@
import {fromStorage, StorageKey} from "../storage/common.js"; import type Shepherd from "shepherd.js";
import {TourId, type TourData} from "./types.js"; import {createIntroductionUnderstood} from "../storage/common.js";
import {openOptionsPageFromBackground, renderInContainer} from "./utilities.js"; import {openOptionsPageFromBackground, renderInContainer} from "./utilities.js";
const step01 = renderInContainer( const step01 = renderInContainer(
@ -82,7 +82,7 @@ const step03 = renderInContainer(
</>, </>,
); );
const steps: TourData["steps"] = [ export const introductionSteps: Shepherd.Step.StepOptions[] = [
{ {
canClickTarget: false, canClickTarget: false,
id: "introduction-01", id: "introduction-01",
@ -103,9 +103,7 @@ const steps: TourData["steps"] = [
classes: "btn", classes: "btn",
text: "I understand", text: "I understand",
async action() { async action() {
const introductionUnderstood = await fromStorage( const introductionUnderstood = await createIntroductionUnderstood();
StorageKey.IntroductionUnderstood,
);
introductionUnderstood.value = true; introductionUnderstood.value = true;
await introductionUnderstood.save(); await introductionUnderstood.save();
this.complete(); this.complete();
@ -124,21 +122,3 @@ const steps: TourData["steps"] = [
text: step03, text: step03,
}, },
]; ];
const eventHandlers: TourData["eventHandlers"] = [];
const requirements: TourData["requirements"] = {
mustBeLoggedIn: false,
path: "/",
};
export const introductionTour: TourData = {
id: TourId.Introduction,
title: "Tildes Shepherd Introduction",
description:
"A short introduction to Tildes Shepherd and how the tours work.",
displayInOptionsPage: true,
eventHandlers,
requirements,
steps,
};

View File

@ -1,2 +1 @@
export * from "./logged-out-warning.js"; export * from "./logged-out-warning.js";
export * from "./tour-error.js";

View File

@ -1,6 +1,5 @@
import {type JSX} from "preact"; import {type JSX} from "preact";
/** Check if the user is logged in and return a warning element if they aren't. */
export function LoggedOutWarning(): JSX.Element { export function LoggedOutWarning(): JSX.Element {
const userIsLoggedIn = const userIsLoggedIn =
document.querySelector(".logged-in-user-username") !== null; document.querySelector(".logged-in-user-username") !== null;

View File

@ -1,29 +0,0 @@
import Shepherd from "shepherd.js";
import {renderInContainer} from "../utilities.js";
/**
* Start an ad-hoc tour to display an error message.
* @param text The message to show.
*/
export function showTourError(text: string) {
const tour = new Shepherd.Tour({
defaultStepOptions: {
buttons: [
{
classes: "btn",
text: "Continue",
action() {
this.complete();
},
},
],
},
useModalOverlay: true,
});
tour.addStep({
text: renderInContainer(<p class="tish-warning">{text}</p>),
});
tour.start();
}

View File

@ -1,58 +0,0 @@
import type Shepherd from "shepherd.js";
/** All available tour IDs. */
export enum TourId {
InterfaceAccountSettings = "interface-account-settings",
InterfaceHomepage = "interface-homepage",
Introduction = "introduction",
}
/** Requirements of a tour to be checked before the tour is started. */
export type TourRequirement = {
/**
* This tour requires that the user must be logged in. Only set this to true
* if the tour goes to pages only accessible by logged in users.
*/
mustBeLoggedIn: boolean;
/** The {@link URL.pathname} to run the tour at. */
path: string;
};
/** An individual tour step event handler. */
export type TourStepEvent = {
/**
* - The "show" event will be called when the step is displayed.
* - The "destroy" event will be called when the step is finished.
*/
event: "show" | "destroy";
/** The handler for this step event. */
handler: Parameters<Shepherd.Step["on"]>[1];
};
/** All the tour data collected in one place. */
export type TourData = {
/** A short description of the tour for use in the options page. */
description: string;
/** Whether this tour should be shown in the options page. */
displayInOptionsPage: boolean;
/** All event handlers to be added to this tour's steps. */
eventHandlers: Array<{
eventHandlers: TourStepEvent[];
stepId: string;
}>;
/** The unique ID for this tour. */
id: TourId;
/** The requirements this tour must match before starting it. */
requirements: TourRequirement;
/** All the steps this tour will take. */
steps: Shepherd.Step.StepOptions[];
/** The title of the tour for use in the options page. */
title: string;
};

View File

@ -5,7 +5,7 @@ import browser from "webextension-polyfill";
* Adds a `[data-tildes-shepherd-counter]` attribute to a specified element. For * Adds a `[data-tildes-shepherd-counter]` attribute to a specified element. For
* the associated CSS, see `source/content-scripts/scss/main.scss`. * the associated CSS, see `source/content-scripts/scss/main.scss`.
* *
* @param selector The selector of the element to apply the counter to, if the * @param selector The selector of element to apply the counter to, if the
* target element can't be selected an error will be thrown. * target element can't be selected an error will be thrown.
* @param count The number to display in the counter. * @param count The number to display in the counter.
*/ */

7
source/types.d.ts vendored
View File

@ -1,7 +1,12 @@
export {}; import type Shepherd from "shepherd.js";
declare global { declare global {
const $browser: "chromium" | "firefox"; const $browser: "chromium" | "firefox";
const $dev: boolean; const $dev: boolean;
const $test: boolean; const $test: boolean;
type TourStepEvent = "show" | "destroy";
type TourStepEventFunction = Parameters<Shepherd.Step["on"]>[1];
type TourStepEventHandler = [string, [TourStepEvent, TourStepEventFunction]];
type TourStepOptions = Shepherd.Step.StepOptions;
} }