From 7995f6ff15e3227043987a94955800806ab8de35 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 7 Jun 2024 18:46:06 +0200 Subject: [PATCH] Onboarding Nudges and PDF opener button more obvious --- external/@worldbrain/memex-common | 2 +- src/annotations/background/index.ts | 1 + src/content-scripts/constants.ts | 16 ++ src/content-scripts/content_script/global.ts | 61 +++++ .../content_script/in-page-ui-injections.ts | 21 ++ src/content-scripts/content_script/types.ts | 1 + .../ribbon/react/components/ribbon.tsx | 64 ++++- .../ribbon/react/components/types.ts | 1 - .../ribbon/react/containers/ribbon/index.tsx | 16 +- .../ribbon/react/containers/ribbon/logic.ts | 24 ++ .../shared-state/shared-in-page-ui-state.ts | 7 +- src/in-page-ui/shared-state/types.ts | 7 +- src/overview/help-btn/components/help-btn.tsx | 3 +- src/page-indexing/utils.ts | 2 +- src/pdf/util.ts | 3 +- .../components/youtubeActionBar.tsx | 132 +++++++++- src/search-injection/constants.ts | 2 + src/search-injection/img-action-buttons.tsx | 238 ++++++++++++++++++ src/search-injection/pdf-open-button.tsx | 170 +++++++++++++ src/search-injection/types.ts | 6 + src/search-injection/youtubeInterface.tsx | 6 +- src/util/nudges-utils.tsx | 179 +++++++++++++ 22 files changed, 947 insertions(+), 15 deletions(-) create mode 100644 src/search-injection/img-action-buttons.tsx create mode 100644 src/search-injection/pdf-open-button.tsx create mode 100644 src/util/nudges-utils.tsx diff --git a/external/@worldbrain/memex-common b/external/@worldbrain/memex-common index 81ce04034b..b30ff300df 160000 --- a/external/@worldbrain/memex-common +++ b/external/@worldbrain/memex-common @@ -1 +1 @@ -Subproject commit 81ce04034b52b51ffa07adf5839711be4ebbf62b +Subproject commit b30ff300dfc3f68d4e3e915ac0e3c6e9185569dc diff --git a/src/annotations/background/index.ts b/src/annotations/background/index.ts index 0350faec88..5803fb353c 100644 --- a/src/annotations/background/index.ts +++ b/src/annotations/background/index.ts @@ -143,6 +143,7 @@ export default class DirectLinkingBackground { comment: openToComment, bookmark: openToBookmark, list: openToCollections, + bookmarksNudge: false, } const actionPair = Object.entries(actions).findIndex((pair) => { return pair[1] diff --git a/src/content-scripts/constants.ts b/src/content-scripts/constants.ts index 12e31cc563..ddc82f4a87 100644 --- a/src/content-scripts/constants.ts +++ b/src/content-scripts/constants.ts @@ -1,3 +1,19 @@ export const SIDEBAR_SEARCH_RESULT_LIMIT = 10 export const IGNORE_CLICK_OUTSIDE_CLASS = 'ignore-react-onclickoutside' + +export const ONBOARDING_NUDGES_STORAGE = '@onboarding-nudges' + +export const ONBOARDING_NUDGES_DEFAULT = { + enabled: true, // should the onboarding nudge be shown at all + bookmarksCount: 5, // how many times a page has been scrolled down + youtubeSummaryCount: 5, // how many times a youtube video has been opened + youtubeTimestampCount: 0, // how many times a youtube video has been opened + pageSummaryCount: 0, // how many times did the user encounter a long article worth summarising +} +export const ONBOARDING_NUDGES_MAX_COUNT = { + bookmarksCount: 5, // how many times a page has been scrolled down + youtubeSummaryCount: 5, // how many times a youtube video has been opened + youtubeTimestampCount: 0, // how many times a youtube video has been opened + pageSummaryCount: 0, // how many times did the user encounter a long article worth summarising +} diff --git a/src/content-scripts/content_script/global.ts b/src/content-scripts/content_script/global.ts index 478c3f4957..39809f939b 100644 --- a/src/content-scripts/content_script/global.ts +++ b/src/content-scripts/content_script/global.ts @@ -124,6 +124,8 @@ import { import type { RemoteSearchInterface } from 'src/search/background/types' import * as anchoring from '@worldbrain/memex-common/lib/annotations' import type { TaskState } from 'ui-logic-core/lib/types' +import debounce from 'lodash/debounce' +import { updateNudgesCounter } from 'src/util/nudges-utils' // Content Scripts are separate bundles of javascript code that can be loaded // on demand by the browser, as needed. This main function manages the initialisation @@ -1533,6 +1535,38 @@ export async function main( }) } + const embedElements = document.getElementsByTagName('embed') + + if (embedElements.length > 0) { + inPageUI.loadOnDemandInPageUI({ + component: 'pdf-open-button', + options: { + embedElements, + contentScriptsBG, + }, + }) + } + const imageElements = document.getElementsByTagName('img') + + const disabledServices = [ + 'https://www.facebook.com/', + 'https://www.instagram.com/', + 'https://www.pinterest.com/', + ] + + if ( + imageElements.length > 0 && + !disabledServices.some((url) => fullPageUrl.includes(url)) + ) { + inPageUI.loadOnDemandInPageUI({ + component: 'img-action-buttons', + options: { + imageElements, + contentScriptsBG, + }, + }) + } + // Function to track when the subscription has been updated by going to our website (which the user arrives through a redirect) if (fullPageUrl === 'https://memex.garden/upgradeSuccessful') { const h2Element = document.querySelector('h2') @@ -1602,6 +1636,33 @@ export async function main( } } + // Function to track when to show the nudges + let tabOpenedTime = Date.now() + + async function checkScrollPosition() { + const scrollPosition = window.scrollY + const pageHeight = document.documentElement.scrollHeight + const elapsedTime = Date.now() - tabOpenedTime + + if (scrollPosition > 0.3 * pageHeight && elapsedTime > 1) { + const shouldShow = await updateNudgesCounter( + 'bookmarksCount', + browser, + ) + if (shouldShow) { + await inPageUI.showRibbon({ action: 'bookmarksNudge' }) + } + } + } + + const debouncedCheckScrollPosition = debounce(checkScrollPosition, 2000) + + document.addEventListener('scroll', debouncedCheckScrollPosition) + + function removeScrollListener() { + window.removeEventListener('scroll', checkScrollPosition) + } + if (analyticsBG && hasActivity) { try { await trackPageActivityIndicatorHit(analyticsBG) diff --git a/src/content-scripts/content_script/in-page-ui-injections.ts b/src/content-scripts/content_script/in-page-ui-injections.ts index ae2d15397b..b75b2420be 100644 --- a/src/content-scripts/content_script/in-page-ui-injections.ts +++ b/src/content-scripts/content_script/in-page-ui-injections.ts @@ -6,6 +6,8 @@ import { renderErrorDisplay } from '../../search-injection/error-display' import { renderSearchDisplay } from '../../search-injection/search-display' import type { ContentScriptRegistry, InPageUIInjectionsMain } from './types' import { renderUpgradeModal } from 'src/search-injection/upgrade-modal-display' +import { handleRenderPDFOpenButton } from 'src/search-injection/pdf-open-button' +import { handleRenderImgActionButtons } from 'src/search-injection/img-action-buttons' export const main: InPageUIInjectionsMain = async ({ inPageUI, @@ -36,8 +38,27 @@ export const main: InPageUIInjectionsMain = async ({ syncSettings, syncSettingsBG, annotationsFunctions, + upgradeModalProps.browserAPIs, ) } + } else if (component === 'pdf-open-button') { + await handleRenderPDFOpenButton( + syncSettings, + syncSettingsBG, + annotationsFunctions, + upgradeModalProps.browserAPIs, + options.embedElements, + options.contentScriptsBG, + ) + } else if (component === 'img-action-buttons') { + await handleRenderImgActionButtons( + syncSettings, + syncSettingsBG, + annotationsFunctions, + upgradeModalProps.browserAPIs, + options.imageElements, + options.contentScriptsBG, + ) } else if (component === 'search-engine-integration') { const url = window.location.href const matched = utils.matchURL(url) diff --git a/src/content-scripts/content_script/types.ts b/src/content-scripts/content_script/types.ts index 3ef9454bee..02e092e2ee 100644 --- a/src/content-scripts/content_script/types.ts +++ b/src/content-scripts/content_script/types.ts @@ -12,6 +12,7 @@ import type { SearchDisplayProps } from 'src/search-injection/search-display' import type { RemoteSyncSettingsInterface } from 'src/sync-settings/background/types' import type { SyncSettingsStore } from 'src/sync-settings/util' import type { UpgradeModalProps } from 'src/search-injection/upgrade-modal-display' +import { Browser } from 'webextension-polyfill' export interface ContentScriptRegistry { registerRibbonScript(main: RibbonScriptMain): Promise diff --git a/src/in-page-ui/ribbon/react/components/ribbon.tsx b/src/in-page-ui/ribbon/react/components/ribbon.tsx index 4c3707f772..13e9733976 100644 --- a/src/in-page-ui/ribbon/react/components/ribbon.tsx +++ b/src/in-page-ui/ribbon/react/components/ribbon.tsx @@ -49,10 +49,12 @@ import { OverlayModals } from '@worldbrain/memex-common/lib/common-ui/components import DeleteConfirmModal from 'src/overview/delete-confirm-modal/components/DeleteConfirmModal' import { AnnotationsSidebarInPageEventEmitter } from 'src/sidebar/annotations-sidebar/types' import { AuthenticatedUser } from '@worldbrain/memex-common/lib/authentication/types' +import { renderNudgeTooltip } from 'src/util/nudges-utils' export interface Props extends RibbonSubcomponentProps { currentUser: AuthenticatedUser setRef?: (el: HTMLElement) => void + ribbonRef: React.RefObject isExpanded: boolean theme: MemexThemeVariant isRibbonEnabled: boolean @@ -72,6 +74,8 @@ export interface Props extends RibbonSubcomponentProps { toggleFeed: () => void showFeed: boolean toggleAskAI: (instaExecute: boolean) => void + showBookmarksNudge: boolean + setShowBookmarksNudge: (value: boolean, snooze: boolean) => void toggleRabbitHole: () => void toggleQuickSearch: () => void openPDFinViewer: () => void @@ -116,7 +120,7 @@ export default class Ribbon extends Component { private annotationCreateRef // TODO: Figure out how to properly type refs to onClickOutside HOCs private spacePickerRef = createRef() - private bookmarkButtonRef = createRef() + private memexLogoRef = createRef() private tutorialButtonRef = createRef() private feedButtonRef = createRef() @@ -280,6 +284,45 @@ export default class Ribbon extends Component { ) } + private getHotKey( + name: string, + size: 'small' | 'medium', + ): JSX.Element | string { + const elData = this.shortcutsData.get(name) + const short: Shortcut = this.keyboardShortcuts[name] + + if (!elData) { + return null + } + + let source = elData.tooltip + + if (['createBookmark'].includes(name)) { + source = this.props.bookmark.isBookmarked + ? elData.toggleOff + : elData.toggleOn + } + if (['toggleSidebar'].includes(name)) { + source = this.props.sidebar.isSidebarOpen + ? elData.toggleOff + : elData.toggleOn + } + + return short.shortcut && short.enabled ? ( + + { + + } + + ) : ( + source + ) + } + private hideListPicker = () => { this.props.lists.setShowListsPicker(false) } @@ -909,6 +952,22 @@ export default class Ribbon extends Component { ) } + renderBookmarksNudge = () => { + if (this.props.showBookmarksNudge) { + return renderNudgeTooltip( + 'Looks like an article worth saving!', + 'Hover over the brain icon or use hotkeys to save with Memex.', + this.getHotKey('createBookmark', 'medium'), + '450px', + () => this.props.setShowBookmarksNudge(false, false), // hide forever + () => this.props.setShowBookmarksNudge(false, true), // snooze + this.props.getRootElement, + this.memexLogoRef.current, + 'top-end', + ) + } + } + renderFeedButton() { const topRight = this.props.ribbonPosition === 'topRight' const bottomRight = this.props.ribbonPosition === 'bottomRight' @@ -1987,6 +2046,8 @@ export default class Ribbon extends Component { ribbonPosition={this.props.ribbonPosition} isYoutube={isYoutube} > + {this.renderBookmarksNudge()} + {this.props.hasFeedActivity && ( { ? 'greyScale7' : 'greyScale5' } + containerRef={this.memexLogoRef} /> )} diff --git a/src/in-page-ui/ribbon/react/components/types.ts b/src/in-page-ui/ribbon/react/components/types.ts index ce229d7c5e..19690ef29a 100644 --- a/src/in-page-ui/ribbon/react/components/types.ts +++ b/src/in-page-ui/ribbon/react/components/types.ts @@ -29,7 +29,6 @@ export interface RibbonSubcomponentProps { contentSharingBG: ContentSharingInterface bgScriptBG: RemoteBGScriptInterface<'caller'> onListShare?: SpacePickerDependencies['onListShare'] - selectRibbonPositionOption: (option) => void hasFeedActivity: boolean showConfirmDeletion: boolean } diff --git a/src/in-page-ui/ribbon/react/containers/ribbon/index.tsx b/src/in-page-ui/ribbon/react/containers/ribbon/index.tsx index ada2399af6..8cadc6c36c 100644 --- a/src/in-page-ui/ribbon/react/containers/ribbon/index.tsx +++ b/src/in-page-ui/ribbon/react/containers/ribbon/index.tsx @@ -27,6 +27,7 @@ export interface RibbonContainerProps extends RibbonContainerOptions { analyticsBG: AnalyticsCoreInterface theme: MemexThemeVariant browserAPIs: Browser + // setVisibility: (visibility: boolean) => void } export default class RibbonContainer extends StatefulUIElement< @@ -137,6 +138,12 @@ export default class RibbonContainer extends StatefulUIElement< this.props.setRibbonShouldAutoHide(true) } else if (event.action === 'list') { this.processEvent('setShowListsPicker', { value: true }) + } else if (event.action === 'bookmarksNudge') { + this.props.inPageUI.hideRibbon() + this.processEvent('setShowBookmarksNudge', { + value: true, + snooze: null, + }) } } @@ -167,7 +174,7 @@ export default class RibbonContainer extends StatefulUIElement< this.state.themeVariant || this.props.theme, }) }} - ref={this.ribbonRef} + ribbonRef={this.ribbonRef} setRef={this.props.setRef} getListDetailsById={(id) => { const listDetails = this.props.annotationsCache.getListByLocalId( @@ -191,6 +198,13 @@ export default class RibbonContainer extends StatefulUIElement< !this.state.showRemoveMenu, ) }} + showBookmarksNudge={this.state.showBookmarksNudge} + setShowBookmarksNudge={(value, snooze) => { + this.processEvent('setShowBookmarksNudge', { + value, + snooze, + }) + }} toggleAskAI={(instaExecute: boolean) => { this.processEvent('toggleAskAI', instaExecute) }} diff --git a/src/in-page-ui/ribbon/react/containers/ribbon/logic.ts b/src/in-page-ui/ribbon/react/containers/ribbon/logic.ts index 0ae1601737..47d3f915a9 100644 --- a/src/in-page-ui/ribbon/react/containers/ribbon/logic.ts +++ b/src/in-page-ui/ribbon/react/containers/ribbon/logic.ts @@ -23,6 +23,7 @@ import { getTelegramUserDisplayName } from '@worldbrain/memex-common/lib/telegra import type { AnalyticsCoreInterface } from '@worldbrain/memex-common/lib/analytics/types' import type { MemexThemeVariant } from '@worldbrain/memex-common/lib/common-ui/styles/types' import { AuthenticatedUser } from '@worldbrain/memex-common/lib/authentication/types' +import { disableNudgeType } from 'src/util/nudges-utils' export type PropKeys = keyof Pick< Base, @@ -67,6 +68,7 @@ export interface RibbonContainerState { annotations: number search: ValuesOf pausing: ValuesOf + showBookmarksNudge: boolean hasFeedActivity: boolean isTrial: boolean signupDate: number @@ -88,6 +90,7 @@ export type RibbonContainerEvents = UIEvent< setTutorialId: { tutorialIdToOpen: string } toggleShowTutorial: null toggleFeed: null + setShowBookmarksNudge: { value: boolean; snooze: boolean } toggleReadingView: null toggleAskAI: boolean | null deletePage: null @@ -198,6 +201,7 @@ export class RibbonContainerLogic extends UILogic< themeVariant: null, showRabbitHoleButton: false, showConfirmDeletion: false, + showBookmarksNudge: false, } } @@ -960,6 +964,26 @@ export class RibbonContainerLogic extends UILogic< listId: event.value, }) } + setShowBookmarksNudge: EventHandler<'setShowBookmarksNudge'> = async ({ + event, + }) => { + if (event.value) { + this.dependencies.setRibbonShouldAutoHide(false) + } else { + this.dependencies.setRibbonShouldAutoHide(true) + } + + this.emitMutation({ + showBookmarksNudge: { $set: event.value }, + }) + + if (!event.snooze) { + await disableNudgeType( + 'bookmarksCount', + this.dependencies.browserAPIs, + ) + } + } setShowListsPicker: EventHandler<'setShowListsPicker'> = async ({ event, diff --git a/src/in-page-ui/shared-state/shared-in-page-ui-state.ts b/src/in-page-ui/shared-state/shared-in-page-ui-state.ts index 5bde503f29..e5d9d348e6 100644 --- a/src/in-page-ui/shared-state/shared-in-page-ui-state.ts +++ b/src/in-page-ui/shared-state/shared-in-page-ui-state.ts @@ -225,7 +225,12 @@ export class SharedInPageUIState implements SharedInPageUIInterface { return } - await this._setState('ribbon', true) + if (options?.action === 'bookmarksNudge') { + await this._setState('ribbon', false) + } else { + await this._setState('ribbon', true) + } + maybeEmitAction() } diff --git a/src/in-page-ui/shared-state/types.ts b/src/in-page-ui/shared-state/types.ts index f4975483b4..07246457e4 100644 --- a/src/in-page-ui/shared-state/types.ts +++ b/src/in-page-ui/shared-state/types.ts @@ -38,7 +38,12 @@ export type InPageUISidebarAction = | 'add_media_range_to_ai_context' | 'analyse_image_with_ai' -export type InPageUIRibbonAction = 'comment' | 'tag' | 'list' | 'bookmark' +export type InPageUIRibbonAction = + | 'comment' + | 'tag' + | 'list' + | 'bookmark' + | 'bookmarksNudge' export type InPageUIComponent = ContentScriptComponent export type InPageUIComponentShowState = { diff --git a/src/overview/help-btn/components/help-btn.tsx b/src/overview/help-btn/components/help-btn.tsx index ab465cb2ef..896550951e 100644 --- a/src/overview/help-btn/components/help-btn.tsx +++ b/src/overview/help-btn/components/help-btn.tsx @@ -12,6 +12,7 @@ import { AuthenticatedUser } from '@worldbrain/memex-common/lib/authentication/t import { SETTINGS_URL } from 'src/constants' export interface Props { + currentUser: AuthenticatedUser theme: MemexThemeVariant toggleTheme: () => void getRootElement: () => HTMLElement @@ -91,7 +92,7 @@ export class HelpBtn extends React.PureComponent { ? 'https://memex.featurebase.app' : this.state.showChangeLog ? 'https://memex.featurebase.app/changelog' - : 'https://go.crisp.chat/chat/embed/?website_id=05013744-c145-49c2-9c84-bfb682316599' + : `https://go.crisp.chat/chat/embed/?website_id=05013744-c145-49c2-9c84-bfb682316599&user_email=${this.props.currentUser.email}` } height={600} width={500} diff --git a/src/page-indexing/utils.ts b/src/page-indexing/utils.ts index 94460b211a..75461a88a7 100644 --- a/src/page-indexing/utils.ts +++ b/src/page-indexing/utils.ts @@ -31,7 +31,7 @@ export const isUrlSupported = (params: { } // Ignore PDFs that are just urls and not the reader - if (params.fullUrl.endsWith('.pdf')) { + if (params.fullUrl.includes('.pdf')) { if (!params.fullUrl.includes('pdfjs/viewer.html?file')) { return false } diff --git a/src/pdf/util.ts b/src/pdf/util.ts index 13b5ebede6..e38a950fae 100644 --- a/src/pdf/util.ts +++ b/src/pdf/util.ts @@ -7,7 +7,8 @@ export const constructPDFViewerUrl = ( ): string => args.runtimeAPI.getURL(PDF_VIEWER_HTML) + '?file=' + - encodeURIComponent(urlToPdf) + encodeURIComponent(urlToPdf) + + '#pagemode=none' // this removes the sidebar to open by default export const isUrlPDFViewerUrl = ( url: string, diff --git a/src/search-injection/components/youtubeActionBar.tsx b/src/search-injection/components/youtubeActionBar.tsx index f5138935aa..6f7f93e827 100644 --- a/src/search-injection/components/youtubeActionBar.tsx +++ b/src/search-injection/components/youtubeActionBar.tsx @@ -1,7 +1,8 @@ +import { PrimaryAction } from '@worldbrain/memex-common/lib/common-ui/components/PrimaryAction' import Icon from '@worldbrain/memex-common/lib/common-ui/components/icon' +import KeyboardShortcuts from '@worldbrain/memex-common/lib/common-ui/components/keyboard-shortcuts' import { PopoutBox } from '@worldbrain/memex-common/lib/common-ui/components/popout-box' import TextArea from '@worldbrain/memex-common/lib/common-ui/components/text-area' -import TextField from '@worldbrain/memex-common/lib/common-ui/components/text-field' import { TooltipBox } from '@worldbrain/memex-common/lib/common-ui/components/tooltip-box' import TutorialBox from '@worldbrain/memex-common/lib/common-ui/components/tutorial-box' import VideoRangeSelector from '@worldbrain/memex-common/lib/common-ui/components/video-range-selector' @@ -11,17 +12,29 @@ import { getHTML5VideoTimestamp, } from '@worldbrain/memex-common/lib/editor/utils' import React, { Component } from 'react' +import { + ONBOARDING_NUDGES_DEFAULT, + ONBOARDING_NUDGES_MAX_COUNT, + ONBOARDING_NUDGES_STORAGE, +} from 'src/content-scripts/constants' +import { getKeyboardShortcutsState } from 'src/in-page-ui/keyboard-shortcuts/content_script/detection' +import { + BaseKeyboardShortcuts, + Shortcut, +} from 'src/in-page-ui/keyboard-shortcuts/types' +import { + ShortcutElData, + shortcuts, +} from 'src/options/settings/keyboard-shortcuts' import { RemoteSyncSettingsInterface } from 'src/sync-settings/background/types' import { SyncSettingsStore, createSyncSettingsStore, } from 'src/sync-settings/util' +import { renderNudgeTooltip, updateNudgesCounter } from 'src/util/nudges-utils' import { sleepPromise } from 'src/util/promises' import styled from 'styled-components' -import { Range } from 'react-range' -import { runInTab } from 'src/util/webextensionRPC' -import { InPageUIContentScriptRemoteInterface } from 'src/in-page-ui/content_script/types' -import browser from 'webextension-polyfill' +import browser, { Browser } from 'webextension-polyfill' interface Props { runtime: any @@ -29,6 +42,8 @@ interface Props { getRootElement: (() => HTMLElement) | null syncSettingsBG: RemoteSyncSettingsInterface syncSettings: SyncSettingsStore<'openAI'> + browserAPIs: Browser + shortcutsData: ShortcutElData[] } interface State { @@ -43,9 +58,16 @@ interface State { toSecondsPosition: number videoDuration: number adsRunning: boolean + showYoutubeSummaryNudge: boolean } export default class YoutubeButtonMenu extends React.Component { + static defaultProps: Pick = { + shortcutsData: shortcuts, + } + + private shortcutsData: Map + private keyboardShortcuts: BaseKeyboardShortcuts memexButtonContainerRef = React.createRef() parentContainerRef = React.createRef() // Assuming you have a ref to the parent container summarizeButtonRef = React.createRef() // Assuming you have a ref to the parent container @@ -60,6 +82,13 @@ export default class YoutubeButtonMenu extends React.Component { syncSettingsBG: this.props.syncSettingsBG, }) + this.shortcutsData = new Map( + props.shortcutsData.map((s) => [s.name, s]) as [ + string, + ShortcutElData, + ][], + ) + this.state = { YTChapterContainerVisible: false, existingMemexButtons: false, @@ -72,12 +101,14 @@ export default class YoutubeButtonMenu extends React.Component { toSecondsPosition: 100, videoDuration: 0, adsRunning: false, + showYoutubeSummaryNudge: false, } } async componentDidMount() { this.getYoutubeVideoDuration() + this.keyboardShortcuts = await getKeyboardShortcutsState() if (this.syncSettings != null) { let summarizeVideoPromptSetting try { @@ -122,6 +153,45 @@ export default class YoutubeButtonMenu extends React.Component { await sleepPromise(1000) this.adjustScaleToFitParent() + + const shouldShow = await updateNudgesCounter( + 'youtubeSummaryCount', + this.props.browserAPIs, + ) + + if (shouldShow) { + this.setState({ + showYoutubeSummaryNudge: true, + }) + } + } + + private getHotKey( + name: string, + size: 'small' | 'medium', + ): JSX.Element | string { + const elData = this.shortcutsData.get(name) + const short: Shortcut = this.keyboardShortcuts[name] + + if (!elData) { + return null + } + + let source = elData.tooltip + + return short.shortcut && short.enabled ? ( + + { + + } + + ) : ( + source + ) } async getYoutubeVideoDuration() { @@ -325,6 +395,46 @@ export default class YoutubeButtonMenu extends React.Component { this.setState({ noteSeconds: event.target.value }) } + hideYTNudge = async () => { + this.setState({ + showYoutubeSummaryNudge: false, + }) + + const onboardingNudgesStorage = await this.props.browserAPIs.storage.local.get( + ONBOARDING_NUDGES_STORAGE, + ) + let onboardingNudgesValues = + onboardingNudgesStorage[ONBOARDING_NUDGES_STORAGE] ?? + ONBOARDING_NUDGES_DEFAULT + + onboardingNudgesValues.youtubeSummaryCount = null + + await this.props.browserAPIs.storage.local.set({ + [ONBOARDING_NUDGES_STORAGE]: onboardingNudgesValues, + }) + } + snoozeNudge = async () => { + this.setState({ + showYoutubeSummaryNudge: false, + }) + } + + renderYouTubeSummaryNudge = () => { + if (this.state.showYoutubeSummaryNudge) { + return renderNudgeTooltip( + "Don't waste time watching this video", + 'Use Memex to summarize and ask questions instead', + this.getHotKey('askAI', 'medium'), + '450px', + this.hideYTNudge, + this.snoozeNudge, + this.props.getRootElement, + this.summarizeButtonRef.current, + 'top-start', + ) + } + } + renderPromptTooltip = (ref) => { let elementRef if (ref === 'summarizeVideo') { @@ -497,6 +607,9 @@ export default class YoutubeButtonMenu extends React.Component { // } onClick={(event) => { this.handleSummarizeButtonClick(event) + this.setState({ + showYoutubeSummaryNudge: false, + }) }} ref={this.summarizeButtonRef} > @@ -568,6 +681,7 @@ export default class YoutubeButtonMenu extends React.Component { )} + {this.renderYouTubeSummaryNudge()} ) } @@ -738,3 +852,11 @@ const BottomArea = styled.div` padding: 0 10px; box-sizing: border-box; ` + +const TooltipContent = styled.div` + display: flex; + align-items: center; + grid-gap: 10px; + flex-direction: row; + justify-content: center; +` diff --git a/src/search-injection/constants.ts b/src/search-injection/constants.ts index ebeaf0c4df..c16ea2a830 100644 --- a/src/search-injection/constants.ts +++ b/src/search-injection/constants.ts @@ -81,4 +81,6 @@ export const SEARCH_INJECTION_DEFAULT = { export const REACT_ROOTS = { youtubeInterface: '__MEMEX-YOUTUBE-INTERFACE-ROOT', searchEngineInjection: '__MEMEX-SEARCH-INJECTION-ROOT', + pdfOpenButtons: '__PDF-OPEN-BUTTONS-INJECTION-ROOT', + imgActionButtons: '__IMG-ACTION-BUTTONS-INJECTION-ROOT', } diff --git a/src/search-injection/img-action-buttons.tsx b/src/search-injection/img-action-buttons.tsx new file mode 100644 index 0000000000..1f5aa12d84 --- /dev/null +++ b/src/search-injection/img-action-buttons.tsx @@ -0,0 +1,238 @@ +/* +DOM manipulation helper functions +*/ +import React from 'react' +import ReactDOM from 'react-dom' +import styled, { StyleSheetManager, ThemeProvider } from 'styled-components' + +import { + loadThemeVariant, + theme, +} from 'src/common-ui/components/design-library/theme' +import type { SyncSettingsStoreInterface } from 'src/sync-settings/types' +import type { MemexThemeVariant } from '@worldbrain/memex-common/lib/common-ui/styles/types' +import { getHTML5VideoTimestamp } from '@worldbrain/memex-common/lib/editor/utils' +import { Browser, runtime } from 'webextension-polyfill' +import YoutubeButtonMenu from './components/youtubeActionBar' +import { sleepPromise } from 'src/util/promises' +import { RemoteSyncSettingsInterface } from 'src/sync-settings/background/types' +import { + SyncSettingsStore, + createSyncSettingsStore, +} from 'src/sync-settings/util' +import * as constants from './constants' +import { ContentScriptsInterface } from 'src/content-scripts/background/types' +import { PrimaryAction } from '@worldbrain/memex-common/lib/common-ui/components/PrimaryAction' +import { blobToDataURL } from 'src/util/blob-utils' + +interface RootProps { + rootEl: HTMLElement + syncSettingsBG: RemoteSyncSettingsInterface + syncSettings: SyncSettingsStore<'openAI'> + annotationsFunctions: any + browserAPIs: Browser + contentScriptsBG: ContentScriptsInterface<'caller'> + imageUrl: string + imageData: string +} + +interface RootState { + themeVariant?: MemexThemeVariant +} + +class Root extends React.Component { + parentContainerRef = React.createRef() // Assuming you have a ref to the parent container + + state: RootState = {} + + async componentDidMount() { + this.setState({ + themeVariant: await loadThemeVariant(), + }) + } + + render() { + const { themeVariant } = this.state + if (!themeVariant) { + return null + } + const { props } = this + + return ( + + + + { + await this.props.annotationsFunctions.analyseImageAsWithAI( + this.props.imageData, + ) + event.stopPropagation() + event.preventDefault() + }} + /> + { + await this.props.annotationsFunctions.saveImageAsNewNote( + this.props.imageData, + ) + event.stopPropagation() + event.preventDefault() + }} + /> + + + + ) + } +} + +export const handleRenderImgActionButtons = async ( + syncSettings: SyncSettingsStore<'openAI'>, + syncSettingsBG: RemoteSyncSettingsInterface, + annotationsFunctions: any, + browserAPIs: Browser, + imageElements: HTMLCollectionOf, + contentScriptsBG: ContentScriptsInterface<'caller'>, +) => { + if (imageElements.length === 0) { + return + } + + const renderComponent = (imageElement, target, index) => { + const existingButton = document.getElementById( + constants.REACT_ROOTS.imgActionButtons + '-' + index, + ) + + if (existingButton) { + existingButton.remove() + } + + // Create a span to wrap the imageElement + const wrapperSpan = document.createElement('span') + wrapperSpan.style.display = 'inline-block' // Ensure the span does not break the flow + wrapperSpan.style.position = 'relative' // Positioning context for the absolute positioned target + + // Insert the wrapper before the imageElement in the DOM + imageElement.parentNode.insertBefore(wrapperSpan, imageElement) + + // Move the imageElement inside the wrapperSpan + wrapperSpan.appendChild(imageElement) + + // Set the ID for the target and append it to the wrapperSpan + target.setAttribute( + 'id', + constants.REACT_ROOTS.imgActionButtons + '-' + index, + ) + wrapperSpan.appendChild(target) + } + + for (let i = 0; i < imageElements.length; i++) { + const target = document.createElement('span') + target.setAttribute( + 'id', + constants.REACT_ROOTS.imgActionButtons + '-' + i, + ) + + let element = imageElements[i] + let imageUrl = null + + if (element.src) { + imageUrl = element.src + } + + if (imageUrl == null) { + continue + } + + let imageData = null + + if (imageUrl.includes('data:image')) { + imageData = imageUrl + } else { + try { + const imageBlob = await fetch(imageUrl).then((res: any) => + res.blob(), + ) + imageData = await blobToDataURL(imageBlob) + } catch (error) { + continue + } + } + + if (imageData == null) { + continue + } + + const arrayOfSpecialCases = ['https://www.google.com/search?'] + + const currentUrl = window.location.href + + if (arrayOfSpecialCases.some((url) => currentUrl.includes(url))) { + element = element.parentNode.parentNode.parentNode + if (element.getAttribute('jsaction')) { + element.setAttribute('jsaction', null) + } + } + + renderComponent(element, target, i) + + let renderTimeout + + element.onmouseenter = () => { + renderTimeout = setTimeout(() => { + ReactDOM.render( + + + , + target, + ) + }, 500) // Delay of 500 milliseconds + } + + element.onmouseleave = (event) => { + clearTimeout(renderTimeout) // Cancel the scheduled rendering + // Check if the related target is a descendant of the main target + if (!target.contains(event.relatedTarget as Node)) { + ReactDOM.unmountComponentAtNode(target) + } + } + } +} + +const RootPosContainer = styled.div` + position: absolute; + top: 5px; + padding: 5px; + right: 5px; +` + +const ParentContainer = styled.div<{}>` + overflow: hidden; + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; + grid-gap: 3px; + padding: 2px; + &::-webkit-scrollbar { + display: none; + } +` diff --git a/src/search-injection/pdf-open-button.tsx b/src/search-injection/pdf-open-button.tsx new file mode 100644 index 0000000000..b87e7c7894 --- /dev/null +++ b/src/search-injection/pdf-open-button.tsx @@ -0,0 +1,170 @@ +/* +DOM manipulation helper functions +*/ +import React from 'react' +import ReactDOM from 'react-dom' +import styled, { StyleSheetManager, ThemeProvider } from 'styled-components' + +import { + loadThemeVariant, + theme, +} from 'src/common-ui/components/design-library/theme' +import type { SyncSettingsStoreInterface } from 'src/sync-settings/types' +import type { MemexThemeVariant } from '@worldbrain/memex-common/lib/common-ui/styles/types' +import { getHTML5VideoTimestamp } from '@worldbrain/memex-common/lib/editor/utils' +import { Browser, runtime } from 'webextension-polyfill' +import YoutubeButtonMenu from './components/youtubeActionBar' +import { sleepPromise } from 'src/util/promises' +import { RemoteSyncSettingsInterface } from 'src/sync-settings/background/types' +import { + SyncSettingsStore, + createSyncSettingsStore, +} from 'src/sync-settings/util' +import * as constants from './constants' +import { ContentScriptsInterface } from 'src/content-scripts/background/types' +import { PrimaryAction } from '@worldbrain/memex-common/lib/common-ui/components/PrimaryAction' + +interface RootProps { + rootEl: HTMLElement + syncSettingsBG: RemoteSyncSettingsInterface + syncSettings: SyncSettingsStore<'openAI'> + annotationsFunctions: any + browserAPIs: Browser + contentScriptsBG: ContentScriptsInterface<'caller'> + pdfOriginalUrl: string + buttonBarHeight: string +} + +interface RootState { + themeVariant?: MemexThemeVariant +} + +class Root extends React.Component { + parentContainerRef = React.createRef() // Assuming you have a ref to the parent container + + state: RootState = {} + + async componentDidMount() { + this.setState({ + themeVariant: await loadThemeVariant(), + }) + } + + render() { + const { themeVariant } = this.state + if (!themeVariant) { + return null + } + const { props } = this + + return ( + + + { + await this.props.contentScriptsBG.openPdfInViewer({ + fullPageUrl: this.props.pdfOriginalUrl, + }) + }} + ref={this.parentContainerRef} + buttonBarHeight={props.buttonBarHeight} + > + + Annotate & Summarize this PDF with Memex + + + + ) + } +} + +export const handleRenderPDFOpenButton = async ( + syncSettings: SyncSettingsStore<'openAI'>, + syncSettingsBG: RemoteSyncSettingsInterface, + annotationsFunctions: any, + browserAPIs: Browser, + embedElements: HTMLCollectionOf, + contentScriptsBG: ContentScriptsInterface<'caller'>, +) => { + let pdfOriginalUrl = null + let buttonBarHeight = '40px' + + if ( + window.location.href.includes('https://arxiv.org/pdf/') && + !window.location.href.includes('.pdf') + ) { + pdfOriginalUrl = window.location.href + '.pdf' + } + if (window.location.href.includes('.pdf')) { + pdfOriginalUrl = window.location.href + } + + const target = document.createElement('div') + + target.setAttribute('id', constants.REACT_ROOTS.pdfOpenButtons) + const renderComponent = (embedElement, index) => { + const existingButton = document.getElementById( + constants.REACT_ROOTS.pdfOpenButtons + '-' + index, + ) + + if (existingButton) { + existingButton.remove() + } + + target.setAttribute( + 'id', + constants.REACT_ROOTS.pdfOpenButtons + '-' + index, + ) + + embedElement.style.top = buttonBarHeight + embedElement.style.height = `calc(100% - ${buttonBarHeight})` + + embedElement.parentNode.insertBefore(target, embedElement) + } + + for (let i = 0; i < embedElements.length; i++) { + let element = embedElements[i] + if (element.type === 'application/pdf') { + renderComponent(element, i) + } + if (element.src && element.src.includes('.pdf')) { + pdfOriginalUrl = element.src + } + + if (pdfOriginalUrl == null) { + return + } + + ReactDOM.render( + , + target, + ) + } +} + +const ParentContainer = styled.div<{ + buttonBarHeight: string +}>` + width: 100%; + height: ${(props) => props.buttonBarHeight}; + overflow: hidden; + &::-webkit-scrollbar { + display: none; + } +` diff --git a/src/search-injection/types.ts b/src/search-injection/types.ts index 3293d138af..6c0bfb7920 100644 --- a/src/search-injection/types.ts +++ b/src/search-injection/types.ts @@ -1,6 +1,7 @@ import { PowerUpModalVersion } from 'src/authentication/upgrade-modal/types' import type { ErrorDisplayProps } from './error-display' import { AuthRemoteFunctionsInterface } from 'src/authentication/background/types' +import { ContentScriptsInterface } from 'src/content-scripts/background/types' export type SearchEngineName = 'google' | 'duckduckgo' | 'brave' | 'bing' export interface SearchEngineInfo { @@ -32,6 +33,9 @@ export interface OnDemandInPageUIProps { limitReachedNotif: PowerUpModalVersion authBG: AuthRemoteFunctionsInterface } + embedElements?: HTMLCollectionOf + imageElements?: HTMLCollectionOf + contentScriptsBG?: ContentScriptsInterface<'caller'> } export type OnDemandInPageUIComponents = @@ -40,3 +44,5 @@ export type OnDemandInPageUIComponents = | 'dashboard' | 'error-display' | 'upgrade-modal' + | 'pdf-open-button' + | 'img-action-buttons' diff --git a/src/search-injection/youtubeInterface.tsx b/src/search-injection/youtubeInterface.tsx index 3016854b26..2c37f285b5 100644 --- a/src/search-injection/youtubeInterface.tsx +++ b/src/search-injection/youtubeInterface.tsx @@ -12,7 +12,7 @@ import { import type { SyncSettingsStoreInterface } from 'src/sync-settings/types' import type { MemexThemeVariant } from '@worldbrain/memex-common/lib/common-ui/styles/types' import { getHTML5VideoTimestamp } from '@worldbrain/memex-common/lib/editor/utils' -import { runtime } from 'webextension-polyfill' +import { Browser, runtime } from 'webextension-polyfill' import YoutubeButtonMenu from './components/youtubeActionBar' import { sleepPromise } from 'src/util/promises' import { RemoteSyncSettingsInterface } from 'src/sync-settings/background/types' @@ -24,6 +24,7 @@ interface RootProps { syncSettingsBG: RemoteSyncSettingsInterface syncSettings: SyncSettingsStore<'openAI'> annotationsFunctions: any + browserAPIs: Browser } interface RootState { @@ -55,6 +56,7 @@ class Root extends React.Component { syncSettingsBG={props.syncSettingsBG} syncSettings={props.syncSettings} getRootElement={() => props.rootEl} + browserAPIs={props.browserAPIs} /> @@ -66,6 +68,7 @@ export const handleRenderYoutubeInterface = async ( syncSettings: SyncSettingsStore<'openAI'>, syncSettingsBG: RemoteSyncSettingsInterface, annotationsFunctions: any, + browserAPIs: Browser, ) => { const existingButton = document.getElementById( constants.REACT_ROOTS.youtubeInterface, @@ -253,6 +256,7 @@ export const handleRenderYoutubeInterface = async ( rootEl={target} syncSettings={syncSettings} annotationsFunctions={annotationsFunctions} + browserAPIs={browserAPIs} syncSettingsBG={syncSettingsBG} />, target, diff --git a/src/util/nudges-utils.tsx b/src/util/nudges-utils.tsx new file mode 100644 index 0000000000..067fa50271 --- /dev/null +++ b/src/util/nudges-utils.tsx @@ -0,0 +1,179 @@ +import React, { Component } from 'react' +import { PopoutBox } from '@worldbrain/memex-common/lib/common-ui/components/popout-box' +import { + ONBOARDING_NUDGES_DEFAULT, + ONBOARDING_NUDGES_MAX_COUNT, + ONBOARDING_NUDGES_STORAGE, +} from 'src/content-scripts/constants' +import { Browser } from 'webextension-polyfill' +import styled from 'styled-components' +import { PrimaryAction } from '@worldbrain/memex-common/lib/common-ui/components/PrimaryAction' +import { Placement } from '@popperjs/core' + +export async function updateNudgesCounter( + nudgeType: string, + browserAPIs: Browser, +) { + const onboardingNudgesStorage = await browserAPIs.storage.local.get( + ONBOARDING_NUDGES_STORAGE, + ) + let onboardingNudgesValues = + onboardingNudgesStorage[ONBOARDING_NUDGES_STORAGE] ?? + ONBOARDING_NUDGES_DEFAULT + + let nudgeKeyCount = onboardingNudgesValues[nudgeType] + if (nudgeKeyCount == null) { + return false + } else { + nudgeKeyCount = nudgeKeyCount + 1 + if (nudgeKeyCount > ONBOARDING_NUDGES_MAX_COUNT[nudgeType]) { + nudgeKeyCount = 0 + } + + onboardingNudgesValues[nudgeType] = nudgeKeyCount + + await browserAPIs.storage.local.set({ + [ONBOARDING_NUDGES_STORAGE]: onboardingNudgesValues, + }) + + if (nudgeKeyCount === ONBOARDING_NUDGES_MAX_COUNT[nudgeType]) { + return true + } + return false + } +} +export async function disableNudgeType( + nudgeType: string, + browserAPIs: Browser, +) { + const onboardingNudgesStorage = await browserAPIs.storage.local.get( + ONBOARDING_NUDGES_STORAGE, + ) + let onboardingNudgesValues = + onboardingNudgesStorage[ONBOARDING_NUDGES_STORAGE] ?? + ONBOARDING_NUDGES_DEFAULT + + let nudgeKeyValues = onboardingNudgesValues[nudgeType] + if (nudgeKeyValues == null) { + return false + } else { + onboardingNudgesValues[nudgeType] = null + + await browserAPIs.storage.local.set({ + [ONBOARDING_NUDGES_STORAGE]: onboardingNudgesValues, + }) + return true + } +} + +export function renderNudgeTooltip( + nudgeTitle: string | JSX.Element, + nudgeText: string | JSX.Element, + hotKeys: JSX.Element | string | null, + width: string, + hideNudge: () => void, + snoozeNudge: () => void, + getRootElement: () => HTMLElement, + targetElementRef: HTMLElement | HTMLDivElement | any, + placement: Placement, +) { + return ( + + + {hotKeys ? {hotKeys} : null} + {nudgeTitle ? {nudgeTitle} : null} + {nudgeText ? {nudgeText} : null} + + + + + + + ) +} + +const NudgeContainer = styled.div<{ + width: string +}>` + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + grid-gap: 5px; + padding: 20px; + background: ${(props) => props.theme.colors.prime1}; + border-radius: 14px; + width: ${(props) => (props.width ? props.width : '300px')}; + position: relative; +` + +const HotKeysBox = styled.div` + position: absolute; + top: 20px; + right: 20px; +` + +const NudgeTitle = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + grid-gap: 10px; + line-height: 1.5; + color: ${(props) => props.theme.colors.black1}; + font-size: 18px; + font-weight: 900; + text-align: left; +` +const NudgeText = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + grid-gap: 10px; + line-height: 1.5; + color: ${(props) => props.theme.colors.black}; + font-size: 16px; + text-align: left; +` +const NudgeBottomNote = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + grid-gap: 10px; + line-height: 1.5; + padding: 10px 0px 0 0px; + color: ${(props) => props.theme.colors.blac}; + font-size: 12px; + text-align: center; + border-top: 1px solid ${(props) => props.theme.colors.white}; + box-sizing: border-box; + width: 100%; + margin-top: 15px; + margin-bottom: -5px; +`