summaryrefslogtreecommitdiffstats
path: root/browser/components/pocket/content/panels/js/components
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/pocket/content/panels/js/components')
-rw-r--r--browser/components/pocket/content/panels/js/components/ArticleList/ArticleList.jsx139
-rw-r--r--browser/components/pocket/content/panels/js/components/ArticleList/ArticleList.scss65
-rw-r--r--browser/components/pocket/content/panels/js/components/Button/Button.jsx21
-rw-r--r--browser/components/pocket/content/panels/js/components/Button/Button.scss142
-rw-r--r--browser/components/pocket/content/panels/js/components/Header/Header.jsx16
-rw-r--r--browser/components/pocket/content/panels/js/components/Header/Header.scss22
-rw-r--r--browser/components/pocket/content/panels/js/components/Home/Home.jsx168
-rw-r--r--browser/components/pocket/content/panels/js/components/PopularTopics/PopularTopics.jsx27
-rw-r--r--browser/components/pocket/content/panels/js/components/PopularTopics/PopularTopics.scss54
-rw-r--r--browser/components/pocket/content/panels/js/components/Saved/Saved.jsx203
-rw-r--r--browser/components/pocket/content/panels/js/components/Saved/Saved.scss17
-rw-r--r--browser/components/pocket/content/panels/js/components/Signup/Signup.jsx79
-rw-r--r--browser/components/pocket/content/panels/js/components/Signup/Signup.scss19
-rw-r--r--browser/components/pocket/content/panels/js/components/TagPicker/TagPicker.jsx208
-rw-r--r--browser/components/pocket/content/panels/js/components/TagPicker/TagPicker.scss141
-rw-r--r--browser/components/pocket/content/panels/js/components/TelemetryLink/TelemetryLink.jsx35
16 files changed, 1356 insertions, 0 deletions
diff --git a/browser/components/pocket/content/panels/js/components/ArticleList/ArticleList.jsx b/browser/components/pocket/content/panels/js/components/ArticleList/ArticleList.jsx
new file mode 100644
index 0000000000..25679bc638
--- /dev/null
+++ b/browser/components/pocket/content/panels/js/components/ArticleList/ArticleList.jsx
@@ -0,0 +1,139 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React, { useState } from "react";
+import TelemetryLink from "../TelemetryLink/TelemetryLink";
+
+function ArticleUrl(props) {
+ // We turn off the link if we're either a saved article, or if the url doesn't exist.
+ if (props.savedArticle || !props.url) {
+ return (
+ <div className="stp_article_list_saved_article">{props.children}</div>
+ );
+ }
+ return (
+ <TelemetryLink
+ className="stp_article_list_link"
+ href={props.url}
+ source={props.source}
+ position={props.position}
+ model={props.model}
+ >
+ {props.children}
+ </TelemetryLink>
+ );
+}
+
+function Article(props) {
+ function encodeThumbnail(rawSource) {
+ return rawSource
+ ? `https://img-getpocket.cdn.mozilla.net/80x80/filters:format(jpeg):quality(60):no_upscale():strip_exif()/${encodeURIComponent(
+ rawSource
+ )}`
+ : null;
+ }
+
+ const [thumbnailLoaded, setThumbnailLoaded] = useState(false);
+ const [thumbnailLoadFailed, setThumbnailLoadFailed] = useState(false);
+
+ const {
+ article,
+ savedArticle,
+ position,
+ source,
+ model,
+ utmParams,
+ openInPocketReader,
+ } = props;
+
+ if (!article.url && !article.resolved_url && !article.given_url) {
+ return null;
+ }
+ const url = new URL(article.url || article.resolved_url || article.given_url);
+ const urlSearchParams = new URLSearchParams(utmParams);
+
+ if (
+ openInPocketReader &&
+ article.item_id &&
+ !url.href.match(/getpocket\.com\/read/)
+ ) {
+ url.href = `https://getpocket.com/read/${article.item_id}`;
+ }
+
+ for (let [key, val] of urlSearchParams.entries()) {
+ url.searchParams.set(key, val);
+ }
+
+ // Using array notation because there is a key titled `1` (`images` is an object)
+ const thumbnail =
+ article.thumbnail ||
+ encodeThumbnail(article?.top_image_url || article?.images?.["1"]?.src);
+ const alt = article.alt || "thumbnail image";
+ const title = article.title || article.resolved_title || article.given_title;
+ // Sometimes domain_metadata is not there, depending on the source.
+ const publisher =
+ article.publisher ||
+ article.domain_metadata?.name ||
+ article.resolved_domain;
+
+ return (
+ <li className="stp_article_list_item">
+ <ArticleUrl
+ url={url.href}
+ savedArticle={savedArticle}
+ position={position}
+ source={source}
+ model={model}
+ utmParams={utmParams}
+ >
+ <>
+ {thumbnail && !thumbnailLoadFailed ? (
+ <img
+ className="stp_article_list_thumb"
+ src={thumbnail}
+ alt={alt}
+ width="40"
+ height="40"
+ onLoad={() => {
+ setThumbnailLoaded(true);
+ }}
+ onError={() => {
+ setThumbnailLoadFailed(true);
+ }}
+ style={{
+ visibility: thumbnailLoaded ? `visible` : `hidden`,
+ }}
+ />
+ ) : (
+ <div className="stp_article_list_thumb_placeholder" />
+ )}
+ <div className="stp_article_list_meta">
+ <header className="stp_article_list_header">{title}</header>
+ <p className="stp_article_list_publisher">{publisher}</p>
+ </div>
+ </>
+ </ArticleUrl>
+ </li>
+ );
+}
+
+function ArticleList(props) {
+ return (
+ <ul className="stp_article_list">
+ {props.articles?.map((article, position) => (
+ <Article
+ article={article}
+ savedArticle={props.savedArticle}
+ position={position}
+ source={props.source}
+ model={props.model}
+ utmParams={props.utmParams}
+ openInPocketReader={props.openInPocketReader}
+ />
+ ))}
+ </ul>
+ );
+}
+
+export default ArticleList;
diff --git a/browser/components/pocket/content/panels/js/components/ArticleList/ArticleList.scss b/browser/components/pocket/content/panels/js/components/ArticleList/ArticleList.scss
new file mode 100644
index 0000000000..261367433d
--- /dev/null
+++ b/browser/components/pocket/content/panels/js/components/ArticleList/ArticleList.scss
@@ -0,0 +1,65 @@
+.stp_article_list {
+ padding: 0;
+ list-style: none;
+
+ .stp_article_list_saved_article,
+ .stp_article_list_link {
+ display: flex;
+ border-radius: 4px;
+ padding: 8px;
+ margin: 0 -8px;
+ }
+
+ .stp_article_list_link {
+ &:hover, &:focus {
+ text-decoration: none;
+ background-color: #ECECEE;
+
+ @include theme_dark {
+ background-color: #2B2A33;
+ }
+ }
+ }
+
+ .stp_article_list_thumb,
+ .stp_article_list_thumb_placeholder {
+ width: 40px;
+ height: 40px;
+ border-radius: 4px;
+ margin-inline-end: 8px;
+ background-color: #ECECEE;
+ flex-shrink: 0;
+ }
+
+ .stp_article_list_header {
+ font-style: normal;
+ font-weight: 600;
+ font-size: 0.95rem;
+ line-height: 1.18rem;
+ color: #15141A;
+ margin: 0 0 4px;
+
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ display: -webkit-box;
+ overflow: hidden;
+ word-break: break-word;
+
+ @include theme_dark {
+ color: #FBFBFE;
+ }
+ }
+
+ .stp_article_list_publisher {
+ font-style: normal;
+ font-weight: normal;
+ font-size: 0.95rem;
+ line-height: 1.18rem;
+ color: #52525E;
+ margin: 4px 0 0;
+
+ @include theme_dark {
+ color: #CFCFD8;
+ }
+ }
+}
diff --git a/browser/components/pocket/content/panels/js/components/Button/Button.jsx b/browser/components/pocket/content/panels/js/components/Button/Button.jsx
new file mode 100644
index 0000000000..7f3d2ea7ce
--- /dev/null
+++ b/browser/components/pocket/content/panels/js/components/Button/Button.jsx
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+import TelemetryLink from "../TelemetryLink/TelemetryLink";
+
+function Button(props) {
+ return (
+ <TelemetryLink
+ href={props.url}
+ onClick={props.onClick}
+ className={`stp_button${props?.style && ` stp_button_${props.style}`}`}
+ source={props.source}
+ >
+ {props.children}
+ </TelemetryLink>
+ );
+}
+
+export default Button;
diff --git a/browser/components/pocket/content/panels/js/components/Button/Button.scss b/browser/components/pocket/content/panels/js/components/Button/Button.scss
new file mode 100644
index 0000000000..c6001e19da
--- /dev/null
+++ b/browser/components/pocket/content/panels/js/components/Button/Button.scss
@@ -0,0 +1,142 @@
+.stp_button {
+ cursor: pointer;
+ display: inline-block;
+ margin: 12px 0;
+
+ &:hover {
+ text-decoration: none;
+ }
+
+ &.stp_button_text {
+ color: #0060DF;
+ font-size: 0.95rem;
+ line-height: 1.2rem;
+ font-style: normal;
+ font-weight: 600;
+
+ &:focus {
+ text-decoration: underline;
+ }
+
+ &:hover {
+ color: #0250BB;
+ text-decoration: none;
+ }
+
+ &:active {
+ color: #054096;
+ }
+
+ @include theme_dark {
+ color: #00DDFF;
+ }
+ }
+
+ &.stp_button_primary {
+ align-items: center;
+ background: #0060DF;
+ border-radius: 4px;
+ color: #FBFBFE;
+ font-size: 0.85rem;
+ line-height: 1rem;
+ font-style: normal;
+ font-weight: 600;
+ justify-content: center;
+ padding: 6px 12px;
+
+ &:focus {
+ text-decoration: none;
+ background: #0060DF;
+ outline: 2px solid #0060df;
+ outline-offset: 2px;
+ }
+
+ &:hover {
+ background: #0250BB;
+ }
+
+ &:active {
+ background: #054096;
+ }
+
+ @include theme_dark {
+ background: #00DDFF;
+ color: #15141A;
+
+ &:hover {
+ background: #80ebfe;
+ }
+
+ &:focus {
+ outline: 2px solid #00DDFF;
+ }
+ }
+ }
+
+ &.stp_button_secondary {
+ align-items: center;
+ background: #F0F0F4;
+ border-radius: 4px;
+ color: #15141A;
+ font-size: 0.85rem;
+ line-height: 1rem;
+ font-style: normal;
+ font-weight: 600;
+ padding: 6px 12px;
+
+ &:focus {
+ text-decoration: none;
+ background: #F0F0F4;
+ outline: 2px solid #0060df;
+ outline-offset: 2px;
+ }
+
+ &:hover {
+ background: #E0E0E6;
+ }
+
+ &:active {
+ background: #CFCFD8;
+ }
+
+ @include theme_dark {
+ background: #2B2A33;
+ color: #FBFBFE;
+
+ &:focus {
+ outline: 2px solid #00DDFF;
+ }
+
+ &:hover {
+ background: #53535d;
+ }
+ }
+ }
+}
+
+.stp_button_wide {
+ .stp_button {
+ display: block;
+ margin: 12px 0;
+ text-align: center;
+ padding: 8px 12px;
+
+ &.stp_button_primary {
+ font-size: 1.1rem;
+ line-height: 1.35rem;
+ }
+
+ &.stp_button_secondary {
+ font-size: 0.85rem;
+ line-height: 1rem;
+ }
+ }
+}
+
+.stp_button_wide {
+ .stp_button {
+ display: block;
+ margin: 12px 0;
+ text-align: center;
+ }
+}
diff --git a/browser/components/pocket/content/panels/js/components/Header/Header.jsx b/browser/components/pocket/content/panels/js/components/Header/Header.jsx
new file mode 100644
index 0000000000..be60fe764c
--- /dev/null
+++ b/browser/components/pocket/content/panels/js/components/Header/Header.jsx
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+
+function Header(props) {
+ return (
+ <h1 className="stp_header">
+ <div className="stp_header_logo" />
+ {props.children}
+ </h1>
+ );
+}
+
+export default Header;
diff --git a/browser/components/pocket/content/panels/js/components/Header/Header.scss b/browser/components/pocket/content/panels/js/components/Header/Header.scss
new file mode 100644
index 0000000000..f6e4eca9d5
--- /dev/null
+++ b/browser/components/pocket/content/panels/js/components/Header/Header.scss
@@ -0,0 +1,22 @@
+.stp_header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin: 16px 0 12px;
+ font-weight: 600;
+
+ .stp_header_logo {
+ background: url(../img/pocketlogo.svg) bottom center no-repeat;
+ background-size: contain;
+ height: 32px;
+ width: 121px;
+
+ @include theme_dark {
+ background-image: url(../img/pocketlogo-dark.svg);
+ }
+ }
+
+ .stp_button {
+ margin: 0;
+ }
+}
diff --git a/browser/components/pocket/content/panels/js/components/Home/Home.jsx b/browser/components/pocket/content/panels/js/components/Home/Home.jsx
new file mode 100644
index 0000000000..fc936bfa56
--- /dev/null
+++ b/browser/components/pocket/content/panels/js/components/Home/Home.jsx
@@ -0,0 +1,168 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React, { useState, useEffect } from "react";
+import Header from "../Header/Header";
+import ArticleList from "../ArticleList/ArticleList";
+import PopularTopics from "../PopularTopics/PopularTopics";
+import Button from "../Button/Button";
+import panelMessaging from "../../messages";
+
+function Home(props) {
+ const {
+ locale,
+ topics,
+ pockethost,
+ hideRecentSaves,
+ utmSource,
+ utmCampaign,
+ utmContent,
+ } = props;
+ const [{ articles, status }, setArticlesState] = useState({
+ articles: [],
+ // Can be success, loading, or error.
+ status: "",
+ });
+
+ const utmParams = `utm_source=${utmSource}${
+ utmCampaign && utmContent
+ ? `&utm_campaign=${utmCampaign}&utm_content=${utmContent}`
+ : ``
+ }`;
+
+ useEffect(() => {
+ if (!hideRecentSaves) {
+ // We don't display the loading message until instructed. This is because cache
+ // loads should be fast, so using the loading message for cache just adds loading jank.
+ panelMessaging.addMessageListener(
+ "PKT_loadingRecentSaves",
+ function (resp) {
+ setArticlesState({
+ articles,
+ status: "loading",
+ });
+ }
+ );
+
+ panelMessaging.addMessageListener(
+ "PKT_renderRecentSaves",
+ function (resp) {
+ const { data } = resp;
+
+ if (data.status === "error") {
+ setArticlesState({
+ articles: [],
+ status: "error",
+ });
+ return;
+ }
+
+ setArticlesState({
+ articles: data,
+ status: "success",
+ });
+ }
+ );
+ }
+
+ // tell back end we're ready
+ panelMessaging.sendMessage("PKT_show_home");
+ }, []);
+
+ let recentSavesSection = null;
+
+ if (status === "error" || hideRecentSaves) {
+ recentSavesSection = (
+ <h3
+ className="header_medium"
+ data-l10n-id="pocket-panel-home-new-user-cta"
+ />
+ );
+ } else if (status === "loading") {
+ recentSavesSection = (
+ <span data-l10n-id="pocket-panel-home-most-recent-saves-loading" />
+ );
+ } else if (status === "success") {
+ if (articles?.length) {
+ recentSavesSection = (
+ <>
+ <h3
+ className="header_medium"
+ data-l10n-id="pocket-panel-home-most-recent-saves"
+ />
+ {articles.length > 3 ? (
+ <>
+ <ArticleList
+ articles={articles.slice(0, 3)}
+ source="home_recent_save"
+ utmParams={utmParams}
+ openInPocketReader={true}
+ />
+ <span className="stp_button_wide">
+ <Button
+ style="secondary"
+ url={`https://${pockethost}/a?${utmParams}`}
+ source="home_view_list"
+ >
+ <span data-l10n-id="pocket-panel-button-show-all" />
+ </Button>
+ </span>
+ </>
+ ) : (
+ <ArticleList
+ articles={articles}
+ source="home_recent_save"
+ utmParams={utmParams}
+ />
+ )}
+ </>
+ );
+ } else {
+ recentSavesSection = (
+ <>
+ <h3
+ className="header_medium"
+ data-l10n-id="pocket-panel-home-new-user-cta"
+ />
+ <h3
+ className="header_medium"
+ data-l10n-id="pocket-panel-home-new-user-message"
+ />
+ </>
+ );
+ }
+ }
+
+ return (
+ <div className="stp_panel_container">
+ <div className="stp_panel stp_panel_home">
+ <Header>
+ <Button
+ style="primary"
+ url={`https://${pockethost}/a?${utmParams}`}
+ source="home_view_list"
+ >
+ <span data-l10n-id="pocket-panel-header-my-saves" />
+ </Button>
+ </Header>
+ <hr />
+ {recentSavesSection}
+ <hr />
+ {pockethost && locale?.startsWith("en") && topics?.length && (
+ <>
+ <h3 className="header_medium">Explore popular topics:</h3>
+ <PopularTopics
+ topics={topics}
+ pockethost={pockethost}
+ utmParams={utmParams}
+ source="home_popular_topic"
+ />
+ </>
+ )}
+ </div>
+ </div>
+ );
+}
+
+export default Home;
diff --git a/browser/components/pocket/content/panels/js/components/PopularTopics/PopularTopics.jsx b/browser/components/pocket/content/panels/js/components/PopularTopics/PopularTopics.jsx
new file mode 100644
index 0000000000..517bd6d53b
--- /dev/null
+++ b/browser/components/pocket/content/panels/js/components/PopularTopics/PopularTopics.jsx
@@ -0,0 +1,27 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+import TelemetryLink from "../TelemetryLink/TelemetryLink";
+
+function PopularTopics(props) {
+ return (
+ <ul className="stp_popular_topics">
+ {props.topics?.map((topic, position) => (
+ <li key={`item-${topic.topic}`} className="stp_popular_topic">
+ <TelemetryLink
+ className="stp_popular_topic_link"
+ href={`https://${props.pockethost}/explore/${topic.topic}?${props.utmParams}`}
+ source={props.source}
+ position={position}
+ >
+ {topic.title}
+ </TelemetryLink>
+ </li>
+ ))}
+ </ul>
+ );
+}
+
+export default PopularTopics;
diff --git a/browser/components/pocket/content/panels/js/components/PopularTopics/PopularTopics.scss b/browser/components/pocket/content/panels/js/components/PopularTopics/PopularTopics.scss
new file mode 100644
index 0000000000..bacd55ee42
--- /dev/null
+++ b/browser/components/pocket/content/panels/js/components/PopularTopics/PopularTopics.scss
@@ -0,0 +1,54 @@
+.stp_popular_topics {
+ padding: 0;
+
+ .stp_popular_topic {
+ display: inline-block;
+
+ .stp_popular_topic_link {
+ display: inline-block;
+ background: #F0F0F4;
+ border-radius: 4px;
+ font-size: 0.85rem;
+ line-height: 1rem;
+ font-style: normal;
+ font-weight: 600;
+ margin-inline-end: 8px;
+ margin-bottom: 8px;
+ padding: 4px 8px;
+ color: #000;
+
+ &:focus {
+ text-decoration: none;
+ background: #F0F0F4;
+ outline: 2px solid #0060df;
+ outline-offset: 2px;
+ }
+
+ &:hover {
+ background: #E0E0E6;
+ text-decoration: none;
+ }
+
+ &:active {
+ background: #CFCFD8;
+ }
+
+ &::after {
+ content: " >";
+ }
+
+ @include theme_dark {
+ background: #2B2A33;
+ color: #FBFBFE;
+
+ &:focus {
+ outline: 2px solid #00DDFF;
+ }
+
+ &:hover {
+ background: #53535d;
+ }
+ }
+ }
+ }
+}
diff --git a/browser/components/pocket/content/panels/js/components/Saved/Saved.jsx b/browser/components/pocket/content/panels/js/components/Saved/Saved.jsx
new file mode 100644
index 0000000000..f81c6df198
--- /dev/null
+++ b/browser/components/pocket/content/panels/js/components/Saved/Saved.jsx
@@ -0,0 +1,203 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React, { useState, useEffect } from "react";
+import Header from "../Header/Header";
+import Button from "../Button/Button";
+import ArticleList from "../ArticleList/ArticleList";
+import TagPicker from "../TagPicker/TagPicker";
+import panelMessaging from "../../messages";
+
+function Saved(props) {
+ const { locale, pockethost, utmSource, utmCampaign, utmContent } = props;
+ // savedStatus can be success, loading, or error.
+ const [{ savedStatus, savedErrorId, itemId, itemUrl }, setSavedStatusState] =
+ useState({ savedStatus: "loading" });
+ // removedStatus can be removed, removing, or error.
+ const [{ removedStatus, removedErrorMessage }, setRemovedStatusState] =
+ useState({});
+ const [savedStory, setSavedStoryState] = useState();
+ const [articleInfoAttempted, setArticleInfoAttempted] = useState();
+ const [{ similarRecs, similarRecsModel }, setSimilarRecsState] = useState({});
+ const utmParams = `utm_source=${utmSource}${
+ utmCampaign && utmContent
+ ? `&utm_campaign=${utmCampaign}&utm_content=${utmContent}`
+ : ``
+ }`;
+
+ function removeItem(event) {
+ event.preventDefault();
+ setRemovedStatusState({ removedStatus: "removing" });
+ panelMessaging.sendMessage(
+ "PKT_deleteItem",
+ {
+ itemId,
+ },
+ function (resp) {
+ const { data } = resp;
+ if (data.status == "success") {
+ setRemovedStatusState({ removedStatus: "removed" });
+ } else if (data.status == "error") {
+ let errorMessage = "";
+ // The server returns English error messages, so in the case of
+ // non English, we do our best with a generic translated error.
+ if (data.error.message && locale?.startsWith("en")) {
+ errorMessage = data.error.message;
+ }
+ setRemovedStatusState({
+ removedStatus: "error",
+ removedErrorMessage: errorMessage,
+ });
+ }
+ }
+ );
+ }
+
+ useEffect(() => {
+ // Wait confirmation of save before flipping to final saved state
+ panelMessaging.addMessageListener("PKT_saveLink", function (resp) {
+ const { data } = resp;
+ if (data.status == "error") {
+ // Use localizedKey or fallback to a generic catch all error.
+ setSavedStatusState({
+ savedStatus: "error",
+ savedErrorId:
+ data?.error?.localizedKey || "pocket-panel-saved-error-generic",
+ });
+ return;
+ }
+
+ // Success, so no localized error id needed.
+ setSavedStatusState({
+ savedStatus: "success",
+ itemId: data.item?.item_id,
+ itemUrl: data.item?.given_url,
+ savedErrorId: "",
+ });
+ });
+
+ panelMessaging.addMessageListener(
+ "PKT_articleInfoFetched",
+ function (resp) {
+ setSavedStoryState(resp?.data?.item_preview);
+ }
+ );
+
+ panelMessaging.addMessageListener(
+ "PKT_getArticleInfoAttempted",
+ function (resp) {
+ setArticleInfoAttempted(true);
+ }
+ );
+
+ panelMessaging.addMessageListener("PKT_renderItemRecs", function (resp) {
+ const { data } = resp;
+
+ // This is the ML model used to recommend the story.
+ // Right now this value is the same for all three items returned together,
+ // so we can just use the first item's value for all.
+ const model = data?.recommendations?.[0]?.experiment || "";
+ setSimilarRecsState({
+ similarRecs: data?.recommendations?.map(rec => rec.item),
+ similarRecsModel: model,
+ });
+ });
+
+ // tell back end we're ready
+ panelMessaging.sendMessage("PKT_show_saved");
+ }, []);
+
+ if (savedStatus === "error") {
+ return (
+ <div className="stp_panel_container">
+ <div className="stp_panel stp_panel_error">
+ <div className="stp_panel_error_icon" />
+ <h3
+ className="header_large"
+ data-l10n-id="pocket-panel-saved-error-not-saved"
+ />
+ <p data-l10n-id={savedErrorId} />
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="stp_panel_container">
+ <div className="stp_panel stp_panel_saved">
+ <Header>
+ <Button
+ style="primary"
+ url={`https://${pockethost}/a?${utmParams}`}
+ source="view_list"
+ >
+ <span data-l10n-id="pocket-panel-header-my-saves"></span>
+ </Button>
+ </Header>
+ <hr />
+ {!removedStatus && savedStatus === "success" && (
+ <>
+ <h3 className="header_large header_flex">
+ <span data-l10n-id="pocket-panel-saved-page-saved-b" />
+ <Button style="text" onClick={removeItem}>
+ <span data-l10n-id="pocket-panel-button-remove"></span>
+ </Button>
+ </h3>
+ {savedStory && (
+ <ArticleList
+ articles={[savedStory]}
+ openInPocketReader={true}
+ utmParams={utmParams}
+ />
+ )}
+ {articleInfoAttempted && <TagPicker tags={[]} itemUrl={itemUrl} />}
+ {articleInfoAttempted &&
+ similarRecs?.length &&
+ locale?.startsWith("en") && (
+ <>
+ <hr />
+ <h3 className="header_medium">Similar Stories</h3>
+ <ArticleList
+ articles={similarRecs}
+ source="on_save_recs"
+ model={similarRecsModel}
+ utmParams={utmParams}
+ />
+ </>
+ )}
+ </>
+ )}
+ {savedStatus === "loading" && (
+ <h3
+ className="header_large"
+ data-l10n-id="pocket-panel-saved-saving-tags"
+ />
+ )}
+ {removedStatus === "removing" && (
+ <h3
+ className="header_large header_center"
+ data-l10n-id="pocket-panel-saved-processing-remove"
+ />
+ )}
+ {removedStatus === "removed" && (
+ <h3
+ className="header_large header_center"
+ data-l10n-id="pocket-panel-saved-removed-updated"
+ />
+ )}
+ {removedStatus === "error" && (
+ <>
+ <h3
+ className="header_large"
+ data-l10n-id="pocket-panel-saved-error-remove"
+ />
+ {removedErrorMessage && <p>{removedErrorMessage}</p>}
+ </>
+ )}
+ </div>
+ </div>
+ );
+}
+
+export default Saved;
diff --git a/browser/components/pocket/content/panels/js/components/Saved/Saved.scss b/browser/components/pocket/content/panels/js/components/Saved/Saved.scss
new file mode 100644
index 0000000000..08003f14e1
--- /dev/null
+++ b/browser/components/pocket/content/panels/js/components/Saved/Saved.scss
@@ -0,0 +1,17 @@
+body {
+ &.stp_saved_body {
+ overflow: hidden;
+ }
+}
+
+.stp_panel_error {
+ margin: 23px 0 32px;
+ .stp_panel_error_icon {
+ float: inline-start;
+ margin-block: 6px 16px;
+ margin-inline: 7px 17px;
+ background-image: url(../img/pocketerror@1x.png);
+ height: 44px;
+ width: 44px;
+ }
+}
diff --git a/browser/components/pocket/content/panels/js/components/Signup/Signup.jsx b/browser/components/pocket/content/panels/js/components/Signup/Signup.jsx
new file mode 100644
index 0000000000..7e628c0ad5
--- /dev/null
+++ b/browser/components/pocket/content/panels/js/components/Signup/Signup.jsx
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+import Header from "../Header/Header";
+import Button from "../Button/Button";
+
+function Signup(props) {
+ const { locale, pockethost, utmSource, utmCampaign, utmContent } = props;
+ const utmParams = `utm_source=${utmSource}${
+ utmCampaign && utmContent
+ ? `&utm_campaign=${utmCampaign}&utm_content=${utmContent}`
+ : ``
+ }`;
+ return (
+ <div className="stp_panel_container">
+ <div className="stp_panel stp_panel_signup">
+ <Header>
+ <Button
+ style="secondary"
+ url={`https://${pockethost}/login?${utmParams}`}
+ source="log_in"
+ >
+ <span data-l10n-id="pocket-panel-signup-login" />
+ </Button>
+ </Header>
+ <hr />
+ {locale?.startsWith("en") ? (
+ <>
+ <div className="stp_signup_content_wrapper">
+ <h3
+ className="header_medium"
+ data-l10n-id="pocket-panel-signup-cta-a-fix"
+ />
+ <p data-l10n-id="pocket-panel-signup-cta-b-updated" />
+ </div>
+ <div className="stp_signup_content_wrapper">
+ <hr />
+ </div>
+ <div className="stp_signup_content_wrapper">
+ <div className="stp_signup_img_rainbow_reader" />
+ <h3 className="header_medium">
+ Get thought-provoking article recommendations
+ </h3>
+ <p>
+ Find stories that go deep into a subject or offer a new
+ perspective.
+ </p>
+ </div>
+ </>
+ ) : (
+ <div className="stp_signup_content_wrapper">
+ <h3
+ className="header_large"
+ data-l10n-id="pocket-panel-signup-cta-a-fix"
+ />
+ <p data-l10n-id="pocket-panel-signup-cta-b-short" />
+ <strong>
+ <p data-l10n-id="pocket-panel-signup-cta-c-updated" />
+ </strong>
+ </div>
+ )}
+ <hr />
+ <span className="stp_button_wide">
+ <Button
+ style="primary"
+ url={`https://${pockethost}/ff_signup?${utmParams}`}
+ source="sign_up_1"
+ >
+ <span data-l10n-id="pocket-panel-button-activate" />
+ </Button>
+ </span>
+ </div>
+ </div>
+ );
+}
+
+export default Signup;
diff --git a/browser/components/pocket/content/panels/js/components/Signup/Signup.scss b/browser/components/pocket/content/panels/js/components/Signup/Signup.scss
new file mode 100644
index 0000000000..21b34ddcb6
--- /dev/null
+++ b/browser/components/pocket/content/panels/js/components/Signup/Signup.scss
@@ -0,0 +1,19 @@
+body {
+ &.stp_signup_body {
+ overflow: hidden;
+ }
+}
+
+.stp_panel_signup {
+ .stp_signup_content_wrapper {
+ margin: 12px 0 20px;
+ }
+ .stp_signup_img_rainbow_reader {
+ background: url(../img/rainbow-reader.svg) bottom center no-repeat;
+ background-size: contain;
+ height: 72px;
+ width: 82px;
+ float: inline-end;
+ margin-inline-start: 16px;
+ }
+}
diff --git a/browser/components/pocket/content/panels/js/components/TagPicker/TagPicker.jsx b/browser/components/pocket/content/panels/js/components/TagPicker/TagPicker.jsx
new file mode 100644
index 0000000000..9c1f658a80
--- /dev/null
+++ b/browser/components/pocket/content/panels/js/components/TagPicker/TagPicker.jsx
@@ -0,0 +1,208 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React, { useState, useEffect } from "react";
+import panelMessaging from "../../messages";
+
+function TagPicker(props) {
+ const [tags, setTags] = useState(props.tags); // New tag group to store
+ const [allTags, setAllTags] = useState([]); // All tags ever used (in no particular order)
+ const [recentTags, setRecentTags] = useState([]); // Most recently used tags
+ const [duplicateTag, setDuplicateTag] = useState(null);
+ const [inputValue, setInputValue] = useState("");
+
+ // Status can be success, waiting, or error.
+ const [{ tagInputStatus, tagInputErrorMessage }, setTagInputStatus] =
+ useState({
+ tagInputStatus: "",
+ tagInputErrorMessage: "",
+ });
+
+ let handleKeyDown = e => {
+ const enterKey = e.keyCode === 13;
+ const commaKey = e.keyCode === 188;
+ const tabKey = inputValue && e.keyCode === 9;
+
+ // Submit tags on enter with no input.
+ // Enter tag on comma, tab, or enter with input.
+ // Tab to next element with no input.
+ if (commaKey || enterKey || tabKey) {
+ e.preventDefault();
+ if (inputValue) {
+ addTag(inputValue.trim());
+ setInputValue(``); // Clear out input
+ } else if (enterKey) {
+ submitTags();
+ }
+ }
+ };
+
+ let addTag = tagToAdd => {
+ if (!tagToAdd?.length) {
+ return;
+ }
+
+ let newDuplicateTag = tags.find(item => item === tagToAdd);
+
+ if (!newDuplicateTag) {
+ setTags([...tags, tagToAdd]);
+ } else {
+ setDuplicateTag(newDuplicateTag);
+
+ setTimeout(() => {
+ setDuplicateTag(null);
+ }, 1000);
+ }
+ };
+
+ let removeTag = index => {
+ let updatedTags = tags.slice(0); // Shallow copied array
+ updatedTags.splice(index, 1);
+ setTags(updatedTags);
+ };
+
+ let submitTags = () => {
+ let tagsToSubmit = [];
+
+ if (tags?.length) {
+ tagsToSubmit = tags;
+ }
+
+ // Capture tags that have been typed in but not explicitly added to the tag collection
+ if (inputValue?.trim().length) {
+ tagsToSubmit.push(inputValue.trim());
+ }
+
+ if (!props.itemUrl || !tagsToSubmit?.length) {
+ return;
+ }
+
+ setTagInputStatus({
+ tagInputStatus: "waiting",
+ tagInputErrorMessage: "",
+ });
+ panelMessaging.sendMessage(
+ "PKT_addTags",
+ {
+ url: props.itemUrl,
+ tags: tagsToSubmit,
+ },
+ function (resp) {
+ const { data } = resp;
+
+ if (data.status === "success") {
+ setTagInputStatus({
+ tagInputStatus: "success",
+ tagInputErrorMessage: "",
+ });
+ } else if (data.status === "error") {
+ setTagInputStatus({
+ tagInputStatus: "error",
+ tagInputErrorMessage: data.error.message,
+ });
+ }
+ }
+ );
+ };
+
+ useEffect(() => {
+ panelMessaging.sendMessage("PKT_getTags", {}, resp => {
+ setAllTags(resp?.data?.tags);
+ });
+ }, []);
+
+ useEffect(() => {
+ panelMessaging.sendMessage("PKT_getRecentTags", {}, resp => {
+ setRecentTags(resp?.data?.recentTags);
+ });
+ }, []);
+
+ return (
+ <div className="stp_tag_picker">
+ {!tagInputStatus && (
+ <>
+ <h3
+ className="header_small"
+ data-l10n-id="pocket-panel-signup-add-tags"
+ />
+ <div className="stp_tag_picker_tags">
+ {tags.map((tag, i) => (
+ <div
+ className={`stp_tag_picker_tag${
+ duplicateTag === tag ? ` stp_tag_picker_tag_duplicate` : ``
+ }`}
+ >
+ <button
+ onClick={() => removeTag(i)}
+ className={`stp_tag_picker_tag_remove`}
+ >
+ X
+ </button>
+ {tag}
+ </div>
+ ))}
+ <div className="stp_tag_picker_input_wrapper">
+ <input
+ className="stp_tag_picker_input"
+ type="text"
+ list="tag-list"
+ value={inputValue}
+ onChange={e => setInputValue(e.target.value)}
+ onKeyDown={e => handleKeyDown(e)}
+ maxlength="25"
+ />
+ <datalist id="tag-list">
+ {allTags
+ .sort((a, b) => a.search(inputValue) - b.search(inputValue))
+ .map(item => (
+ <option key={item} value={item} />
+ ))}
+ </datalist>
+ <button
+ className="stp_tag_picker_button"
+ disabled={!inputValue?.length && !tags.length}
+ data-l10n-id="pocket-panel-saved-save-tags"
+ onClick={() => submitTags()}
+ />
+ </div>
+ </div>
+ <div className="recent_tags">
+ {recentTags
+ .slice(0, 3)
+ .filter(recentTag => {
+ return !tags.find(item => item === recentTag);
+ })
+ .map(tag => (
+ <div className="stp_tag_picker_tag">
+ <button
+ className="stp_tag_picker_tag_remove"
+ onClick={() => addTag(tag)}
+ >
+ + {tag}
+ </button>
+ </div>
+ ))}
+ </div>
+ </>
+ )}
+ {tagInputStatus === "waiting" && (
+ <h3
+ className="header_large"
+ data-l10n-id="pocket-panel-saved-processing-tags"
+ />
+ )}
+ {tagInputStatus === "success" && (
+ <h3
+ className="header_large"
+ data-l10n-id="pocket-panel-saved-tags-saved"
+ />
+ )}
+ {tagInputStatus === "error" && (
+ <h3 className="header_small">{tagInputErrorMessage}</h3>
+ )}
+ </div>
+ );
+}
+
+export default TagPicker;
diff --git a/browser/components/pocket/content/panels/js/components/TagPicker/TagPicker.scss b/browser/components/pocket/content/panels/js/components/TagPicker/TagPicker.scss
new file mode 100644
index 0000000000..215307d079
--- /dev/null
+++ b/browser/components/pocket/content/panels/js/components/TagPicker/TagPicker.scss
@@ -0,0 +1,141 @@
+.stp_tag_picker {
+ .stp_tag_picker_tags {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ padding: 8px;
+ border: 1px solid #8F8F9D;
+ border-radius: 4px;
+ font-style: normal;
+ font-weight: normal;
+ font-size: 1rem;
+ line-height: 1.2rem;
+ color: #15141A;
+ margin-bottom: 10px;
+ }
+
+ .stp_tag_picker_tag {
+ background: #F0F0F4;
+ border-radius: 4px;
+ color: #15141A;
+ display: inline-block;
+ font-size: 0.85rem;
+ line-height: 1rem;
+ font-style: normal;
+ font-weight: 600;
+ padding: 0 8px;
+ transition: background-color 200ms ease-in-out;
+
+ @include theme_dark {
+ background: #2B2A33;
+ color: #FBFBFB;
+ }
+ }
+
+ .recent_tags .stp_tag_picker_tag {
+ margin-inline-end: 5px;
+ }
+
+ .stp_tag_picker_tag_remove {
+ padding-top: 5px;
+ padding-bottom: 5px;
+ padding-inline-end: 5px;
+ color: #5B5B66;
+ font-weight: 400;
+
+ &:hover {
+ color: #3E3E44;
+ }
+
+ &:focus {
+ color: #3E3E44;
+ outline: 2px solid #0060df;
+ outline-offset: -4px;
+ }
+
+ @include theme_dark {
+ color: #8F8F9D;
+
+ &:hover {
+ color: #fff;
+ }
+
+ &:focus {
+ outline: 2px solid #00DDFF;
+ }
+ }
+ }
+
+ .stp_tag_picker_tag_duplicate {
+ background-color: #bbb;
+
+ @include theme_dark {
+ background-color: #666;
+ }
+ }
+
+ .stp_tag_picker_input_wrapper {
+ display: flex;
+ flex-grow: 1;
+ }
+
+ .stp_tag_picker_input {
+ flex-grow: 1;
+ border: 1px solid #8F8F9D;
+ padding: 0 6px;
+ border-start-start-radius: 4px;
+ border-end-start-radius: 4px;
+
+ &:focus {
+ border: 1px solid #0060DF;
+ outline: 1px solid #0060DF;
+ }
+
+ @include theme_dark {
+ background: none;
+ color: #FBFBFB;
+
+ &:focus {
+ border: 1px solid #00DDFF;
+ outline: 1px solid #00DDFF;
+ }
+ }
+ }
+
+ .stp_tag_picker_button {
+ font-size: 0.95rem;
+ line-height: 1.1rem;
+ padding: 4px 6px;
+ background-color: #F0F0F4;
+ border: 1px solid #8F8F9D;
+ border-inline-start: none;
+ border-start-end-radius: 4px;
+ border-end-end-radius: 4px;
+ &:disabled {
+ color: #8F8F9D;
+ }
+ &:hover:enabled {
+ background-color: #DADADF;
+ }
+ &:focus:enabled {
+ border: 1px solid #0060DF;
+ outline: 1px solid #0060DF;
+ }
+
+ @include theme_dark {
+ background-color: #2B2A33;
+ color: #FBFBFB;
+ &:disabled {
+ color: #666;
+ }
+ &:hover:enabled {
+ background-color: #53535d;
+ }
+ &:focus:enabled {
+ border: 1px solid #00DDFF;
+ outline: 1px solid #00DDFF;
+ }
+ }
+ }
+}
diff --git a/browser/components/pocket/content/panels/js/components/TelemetryLink/TelemetryLink.jsx b/browser/components/pocket/content/panels/js/components/TelemetryLink/TelemetryLink.jsx
new file mode 100644
index 0000000000..c23a24897f
--- /dev/null
+++ b/browser/components/pocket/content/panels/js/components/TelemetryLink/TelemetryLink.jsx
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+import panelMessaging from "../../messages";
+
+function TelemetryLink(props) {
+ function onClick(event) {
+ if (props.onClick) {
+ props.onClick(event);
+ } else {
+ event.preventDefault();
+ panelMessaging.sendMessage("PKT_openTabWithUrl", {
+ url: event.currentTarget.getAttribute(`href`),
+ source: props.source,
+ model: props.model,
+ position: props.position,
+ });
+ }
+ }
+
+ return (
+ <a
+ href={props.href}
+ onClick={onClick}
+ target="_blank"
+ className={props.className}
+ >
+ {props.children}
+ </a>
+ );
+}
+
+export default TelemetryLink;