diff --git a/source/tours/exports.ts b/source/tours/exports.ts new file mode 100644 index 0000000..402023b --- /dev/null +++ b/source/tours/exports.ts @@ -0,0 +1,11 @@ +import {homepageSteps, homepageEventHandlers} from "./interface/exports.js"; +import {introductionSteps} from "./introduction.js"; + +export const tourIds = ["introduction", "interface-homepage"] as const; + +export const tourIdsAndSteps: Array< + [TourId, TourStepOptions[], TourStepEventHandler[]] +> = [ + ["introduction", introductionSteps, []], + ["interface-homepage", homepageSteps, homepageEventHandlers], +]; diff --git a/source/tours/interface/exports.ts b/source/tours/interface/exports.ts new file mode 100644 index 0000000..393473b --- /dev/null +++ b/source/tours/interface/exports.ts @@ -0,0 +1,4 @@ +export { + eventHandlers as homepageEventHandlers, + steps as homepageSteps, +} from "./homepage.js"; diff --git a/source/tours/interface/homepage.tsx b/source/tours/interface/homepage.tsx new file mode 100644 index 0000000..a08ee65 --- /dev/null +++ b/source/tours/interface/homepage.tsx @@ -0,0 +1,499 @@ +import type Shepherd from "shepherd.js"; +import { + addDatasetCounter, + encapsulateElements, + removeAllDatasetCounters, + renderInContainer, +} from "../utilities.js"; + +const step01 = renderInContainer( + <> +

The Homepage

+ +

+ If you plan on staying a while, this is likely the place you'll see the + most. So let's work our way top to bottom, left to right. +

+ +

Starting with...

+ , +); + +const step02 = renderInContainer( + <> +

The Main Header

+ +

+ On the left there is the Tildes logo and title, which you can click to get + back to the homepage, or refresh it if you're already there. If you are in + a group, the group name will also be shown. +

+ +

+ On the right is the notifications section and your username. When you have + unread messages and/or are mentioned, they will show up next to your + username. If you don't have any notifications right now, you can{" "} + + click here + {" "} + to show a preview of what they look like. +

+ +

+ At the moment mentions only work in comments, so if you get mentioned in a + topic's text body and don't get a notification,{" "} + + that's why + + . +

+ , +); + +// Toggle mock notifications, based on the code in the following link. +// If the logic for this needs to be updated, also update the permalink to the +// actual Tildes HTML code. +// https://gitlab.com/tildes/tildes/-/blob/0dbb031562cd9297968fec0049af4b833ef77301/tildes/tildes/templates/macros/user.jinja2#L9-20 +function toggleMockNotifications(event: Event): void { + // Prevent the click from going through so the anchor isn't removed. + event.preventDefault(); + + const root = document.querySelector("#site-header .logged-in-user-info"); + function toggle(existingSelector: string, href: string, text: string): void { + const existing = + root?.querySelector(existingSelector) ?? undefined; + if (existing === undefined) { + // If no notifiaction exists, render our mock. + const messageNotification = document.createElement("a"); + const count = 1 + Math.ceil(Math.random() * 10); + messageNotification.classList.add("logged-in-user-alert"); + messageNotification.dataset.tildesShepherdMock = "true"; + messageNotification.href = href; + messageNotification.textContent = `${count} ${text}`; + root?.insertAdjacentElement("beforeend", messageNotification); + } else if (existing.dataset.tildesShepherdMock === "true") { + // If a notification exists and it has our mock attribute, remove it. + existing.remove(); + } else { + // A real message notification already exists, so we do nothing. + } + } + + toggle('[href="/messages/unread"]', "/messages/unread", "new messages"); + toggle( + '[href="/notifications/unread"]', + "/notifications/unread", + "new comments", + ); +} + +const step03 = renderInContainer( + <> +

The Listing Options

+ +

+ Right below the main header are the topic listing options. These determine + how the topics in the listing are sorted and{" "} + + from what period + {" "} + topics should be shown. +

+ +

+ The non-activity sorts are the easiest to explain, so let's start with + them: +

+ + + +

+ Then the first Activity will sort topics by bumping topics up as + they receive comments, however this sort makes use of comment labels. +

+ +

+ If you don't know what comment labels are yet, don't worry, there is a + tour that explains them. But in short they are a community tool to + emphasize and de-emphasize comments, similar to votes but with more + specific intention. If you'd like to read more, check out{" "} + + the documentation + + . +

+ +

+ And finally the All activity sorts topics the same as Activity, but + without taking into account any comment labels. +

+ +

+ If you only want to see topics from a certain period, click on the "from" + dropdown and select your period. Aside from a set of predefined options, + you can also set a custom one by clicking the "other period" option, after + which you'll be prompted for it. +

+ , +); + +const step04 = renderInContainer( + <> +

The Topic Listing

+ +

+ Next up, just below the listing options is the topic listing itself. We've + only highlighted the first topic here, but you can probably see it + continues all the way down the page. +

+ +

Here, we've marked the main elements of the topic:

+ +
    +
  1. + The topic title, when the topic is a "text topic" links to the same + place as the comments link does. Otherwise when it's a "link topic", it + will link to an external site. +
  2. + +
  3. + The topic metadata, which by default includes the group it's in. If a + topic has specific notable tags and you have the "show tags" setting + enabled in your user settings, they will be shown after the group name. + A "topic type" may also be shown to indicate whether the topic is a text + topic, a video, an "ask" topic for recommendations or a survey, etc. +
  4. + +
  5. + The amount of comments the topic has received. If any new comments have + been posted since you last viewed the topic, in orange a "(X new)" will + be added. +
  6. + +
  7. + The topic source. For text topics this is always the poster of the + topic, but for link topics in certain groups it will be the domain name + together with that website's icon. For topics posted automatically by + Tildes itself, it will be "Scheduled topic". +
  8. +
+ +

The list continues on the next page.

+ , +); + +const step05 = renderInContainer( + <> +

The Topic Listing continued

+ +
    +
  1. + The date when the topic was posted. For dates that are pretty recent it + will show something like "X days ago" while for longer dates it will be + the exact date, like "April 26th, 2021". +
  2. + +
  3. + The voting button, clicking it will add your vote to the topic. On + Tildes, once the topic is older than 30 days you can no longer vote on + it and the individual voting data for is removed, with only the total + count kept. You can read more about it in{" "} + + this announcement topic + + . +
  4. + +
  5. + And finally the topic actions. Clicking on the "Actions" button will + show a dropdown including "Bookmark", "Ignore this topic" and if you + have been granted the permission for it, "Edit title". Ignoring the + topic will remove it from the topic listing and prevent any future + notifications from happening. Both your bookmarked and ignored topics + can be found in their respective sections on your profile. +
  6. +
+ , +); + +const step06 = renderInContainer( + <> +

The Sidebar

+ +

+ Let's take a look at the sidebar, where the first thing we're greeted with + is the search bar. +

+ +

+ A quick note for mobile use, the sidebar can opened via a button that + appears in the very top-right of the page. +

+ +

+ When searching from the homepage, topics will be included from any group. + However, searching when inside a group will only include topics from that + group and any sub-groups. +

+ +

+ Below the search bar you will also find the title of the page, or name of + the group, and a small description. The sidebar has more elements when in + you're in a group page, but those are touched upon in the groups tour. +

+ , +); + +const step07 = renderInContainer( + <> +

The Sidebar Subscriptions

+ +

+ Moving on, you'll find the list of groups you are subscribed to and a + button below it to go to the groups listing, where you can find all the + that are available. +

+ +

+ To post a topic, subscribe or unsubscribe from any group, go to the group + in question and in the sidebar there will be buttons to do so. +

+ , +); + +const step08 = renderInContainer( + <> +

The Sidebar User Settings

+ +

+ At the bottom of the sidebar you will find some user settings. + Specifically the filtered topics tags you have set and a link to the + settings page. +

+ +

+ When you have filtered topics tags set, any topics with any of the tags + present will be removed from your listing. You can read more about it by + going to the dedicated page for it by clicking the "Edit filtered tags" + button. +

+ , +); + +const step09 = renderInContainer( + <> +

The Footer

+ +

+ And finally, the very last part of the homepage is the footer. Here you + will find a theme selector (more on that in a moment) and various links to + places, such as the Tildes Documentation, Contact page, the source code, + etc. We highly recommend reading through the documentation as it explains + a lot of the how and why Tildes does certain things. +

+ +

+ As for the theme selector, you can change your theme with a number of + options. When you change it here, it will only change for the current + device, if you'd like to set an account default for all devices, head to + your account settings. +

+ , +); + +const step10 = renderInContainer( + <> +

This is the end, beautiful friend

+ +

And that's the end of the Homepage tour!

+ +

+ As always, if you find any bugs, have feature requests or simply want to + ask a question. Please send us a message at{" "} + + @Community + + . +

+ +

Happy Tildying!~

+ , +); + +export const steps: Shepherd.Step.StepOptions[] = [ + { + id: "homepage-01", + text: step01, + }, + { + attachTo: { + element: "#site-header", + on: "bottom", + }, + canClickTarget: false, + id: "homepage-02", + scrollTo: true, + text: step02, + }, + { + attachTo: { + element: "main > .listing-options", + on: "bottom", + }, + canClickTarget: false, + id: "homepage-03", + text: step03, + }, + { + attachTo: { + element: ".topic-listing > li:first-child", + on: "bottom", + }, + canClickTarget: false, + id: "homepage-04", + text: step04, + }, + { + attachTo: { + element: ".topic-listing > li:first-child", + on: "bottom", + }, + canClickTarget: false, + id: "homepage-05", + text: step05, + }, + { + attachTo: { + element: '#sidebar [data-tildes-shepherd-encapsulated="homepage-06"]', + on: "left-start", + }, + canClickTarget: false, + id: "homepage-06", + text: step06, + }, + { + attachTo: { + element: '#sidebar [data-tildes-shepherd-encapsulated="homepage-07"]', + on: "left-start", + }, + canClickTarget: false, + id: "homepage-07", + scrollTo: true, + text: step07, + }, + { + attachTo: { + element: "#sidebar .divider + .nav", + on: "left-start", + }, + canClickTarget: false, + id: "homepage-08", + scrollTo: true, + text: step08, + }, + { + attachTo: { + element: "#site-footer", + on: "top", + }, + canClickTarget: false, + id: "homepage-09", + scrollTo: true, + text: step09, + }, + { + canClickTarget: false, + id: "homepage-10", + text: step10, + }, +]; + +export const eventHandlers: TourStepEventHandler[] = [ + [ + "homepage-04", + [ + "show", + () => { + const topic = ".topic-listing > li:first-child"; + const counters = [ + ".topic-title", + ".topic-metadata", + ".topic-info-comments", + ".topic-info-source", + "time", + ".topic-voting", + ".topic-actions", + ]; + + for (const [count, selector] of counters.entries()) { + addDatasetCounter(`${topic} ${selector}`, count + 1); + } + }, + ], + ], + [ + "homepage-05", + [ + "destroy", + () => { + removeAllDatasetCounters(); + }, + ], + ], + [ + "homepage-05", + [ + "show", + () => { + encapsulateElements( + "homepage-06", + "#sidebar .sidebar-controls", + "afterend", + ["#sidebar .form-search", "#sidebar h2", "#sidebar p"], + ); + }, + ], + ], + [ + "homepage-06", + [ + "show", + () => { + encapsulateElements("homepage-07", "#sidebar .divider", "beforebegin", [ + "#sidebar .nav", + '#sidebar [href="/groups"', + ]); + }, + ], + ], + [ + "homepage-08", + [ + "show", + () => { + const filteredTags = + document.querySelector("#sidebar details") ?? + undefined; + if (filteredTags === undefined) { + console.warn("Element is unexpectedly undefined"); + return; + } + + filteredTags.open = true; + }, + ], + ], +]; diff --git a/source/tours/introduction.tsx b/source/tours/introduction.tsx new file mode 100644 index 0000000..dae6fa4 --- /dev/null +++ b/source/tours/introduction.tsx @@ -0,0 +1,122 @@ +import type Shepherd from "shepherd.js"; +import {createIntroductionUnderstood} from "../storage/common.js"; +import {openOptionsPageFromBackground, renderInContainer} from "./utilities.js"; + +const stepOne = renderInContainer( + <> +

Thank you for installing Tildes Shepherd!

+ +

+ Tildes Shepherd is a set of interactive guided tours for Tildes to show + you around and introduce you to the concepts that make this website great. +

+ +

+ To see and start the tours that are available, click on the extension icon + to go to the options page or{" "} + + click here now + + . +

+ +

+ Each tour will start with a pop-up like this one and have "Continue", + "Back" and "Exit" buttons at the bottom. They will progress the tour + forward, backward or exit it. +

+ , +); + +const stepTwo = renderInContainer( + <> +

Tour Mechanics

+ +

+ During the tours, at various points we will want to highlight specific + areas. When that happens you won't be able to click anything inside them, + mainly to prevent you from accidentally going to a different page and + interrupting the tour. But also to prevent you from taking actions you + maybe don't want to take, like voting on something you don't necessarily + want to vote on. +

+ +

+ Depending on your selected theme, the highlighted areas will have a yellow + or orange border and some extra added whitespace. As you can see in this + example where the main site header has been highlighted. +

+ , +); + +const stepThree = renderInContainer( + <> +

+ If you find any bugs, have feature requests or simply want to ask a + question. Please send us a message at{" "} + + @Community + + . +

+ +

+ Also, big shoutout to the people at{" "} + + Ship Shape + {" "} + for making{" "} + + Shepherd.js + + , the software library making this entire project so much easier to + create. And the namesake of it, too. +

+ +

+ Once you click the "I understand" button below, this message won't pop up + again, so remember the extension icon is how you get to the tours. +

+ +

Happy Tildying!~

+ , +); + +export const introductionSteps: Shepherd.Step.StepOptions[] = [ + { + id: "introduction-1", + text: stepOne, + }, + { + attachTo: { + element: "#site-header", + on: "bottom", + }, + canClickTarget: false, + id: "introduction-2", + text: stepTwo, + }, + { + buttons: [ + { + classes: "btn", + text: "I understand", + async action() { + const introductionUnderstood = await createIntroductionUnderstood(); + introductionUnderstood.value = true; + await introductionUnderstood.save(); + this.complete(); + }, + }, + { + classes: "btn", + text: "Back", + action() { + this.back(); + }, + }, + ], + id: "introduction-3", + text: stepThree, + }, +]; diff --git a/source/tours/utilities.ts b/source/tours/utilities.ts new file mode 100644 index 0000000..331f009 --- /dev/null +++ b/source/tours/utilities.ts @@ -0,0 +1,106 @@ +import {type JSX, render} from "preact"; +import browser from "webextension-polyfill"; + +/** + * Adds a `[data-tildes-shepherd-counter]` attribute to a specified element. For + * the associated CSS, see `source/content-scripts/scss/main.scss`. + * + * @param selector The selector of element to apply the counter to, if the + * target element can't be selected an error will be thrown. + * @param count The number to display in the counter. + */ +export function addDatasetCounter(selector: string, count: number) { + const element = document.querySelector(selector) ?? undefined; + if (element === undefined) { + throw new Error(`Target element for "${selector}" is undefined`); + } + + element.dataset.tildesShepherdCounter = count.toString(); +} + +/** + * Create a new `div` element and put all the specified elements inside it. Used + * for highlighting groups of elements that have sibling elements we may not + * want to highlight. Like highlighting only the search bar, title and + * description in the homepage sidebar, but not all the rest of the sidebar. + * @param stepId The unique step ID to set as + * `data-tildes-shepherd-encapsulated=""`. Used for attaching the Shepherd + * step to the container. + * @param rootSelector The selector for the root element to insert the container + * near. This doesn't need to be the parent element necessarily, since + * {@link Element.insertAdjacentElement} is used to do the inserting it can also + * be inserted before or after the root element. + * @param position The insert position for {@link Element.insertAdjacentElement}. + * @param selectors The list of element selectors to include in the new + * container. Note that you should make sure any elements included don't break + * their CSS when contained by a new `div` element and ideally they should all + * be adjacent to one another and specified in the order they appear in. + */ +export function encapsulateElements( + stepId: string, + rootSelector: string, + position: InsertPosition, + selectors: string[], +) { + const container = document.createElement("div"); + container.dataset.tildesShepherdEncapsulated = stepId; + + for (const selector of selectors) { + const element = document.querySelector(selector) ?? undefined; + if (element === undefined) { + console.warn(`Unexpected undefined element: ${selector}`); + continue; + } + + // Don't encapsulate anything if the parent is already one of our elements. + if ( + element.parentElement?.dataset.tildesShepherdEncapsulated !== undefined + ) { + return; + } + + container.append(element); + } + + const root = document.querySelector(rootSelector) ?? undefined; + if (root === undefined) { + throw new Error(`Root selector returned undefined: ${rootSelector}`); + } + + root.insertAdjacentElement(position, container); +} + +/** + * Content scripts can't open the options page themselves, so send a message to + * the background script and open it there. + * @param event The mouse click event to prevent from happening. + */ +export async function openOptionsPageFromBackground( + event: MouseEvent, +): Promise { + event.preventDefault(); + await browser.runtime.sendMessage("open-options-page"); +} + +/** + * Removes all elements with a `data-tildes-shepherd-counter` set. + */ +export function removeAllDatasetCounters() { + const elements = document.querySelectorAll( + "[data-tildes-shepherd-counter]", + ); + for (const element of Array.from(elements)) { + delete element.dataset.tildesShepherdCounter; + } +} + +/** + * Render JSX inside a `div` and return the div. This is mostly used so we can + * pass JSX more easily to Shepherd, which doesn't accept JSX directly but does + * accept {@link HTMLElement}. So this bit of indirection gets us there. + */ +export function renderInContainer(root: JSX.Element): HTMLElement { + const container = document.createElement("div"); + render(root, container); + return container; +} diff --git a/source/types.d.ts b/source/types.d.ts index 7a154ec..83b052a 100644 --- a/source/types.d.ts +++ b/source/types.d.ts @@ -1,8 +1,14 @@ -// Export something so TypeScript doesn't see this file as an ambient module. -export {}; +import type Shepherd from "shepherd.js"; +import type {tourIds} from "./tours/exports.js"; declare global { const $browser: "chromium" | "firefox"; const $dev: boolean; const $test: boolean; + + type TourId = (typeof tourIds)[number]; + type TourStepEvent = "show" | "destroy"; + type TourStepEventFunction = Parameters[1]; + type TourStepEventHandler = [string, [TourStepEvent, TourStepEventFunction]]; + type TourStepOptions = Shepherd.Step.StepOptions; }