diff --git a/packages/extension/package.json b/packages/extension/package.json
index 8372fb227e..db532da291 100644
--- a/packages/extension/package.json
+++ b/packages/extension/package.json
@@ -1,6 +1,6 @@
{
"name": "extension",
- "version": "3.36.11",
+ "version": "3.36.12",
"scripts": {
"dev:chrome": "cross-env NODE_ENV=development cross-env TARGET_BROWSER=chrome webpack --watch",
"dev:firefox": "cross-env NODE_ENV=development cross-env TARGET_BROWSER=firefox webpack --watch",
diff --git a/packages/shared/src/components/auth/RegistrationForm.tsx b/packages/shared/src/components/auth/RegistrationForm.tsx
index 0869003ff5..91ef4c8ea1 100644
--- a/packages/shared/src/components/auth/RegistrationForm.tsx
+++ b/packages/shared/src/components/auth/RegistrationForm.tsx
@@ -26,7 +26,6 @@ import ConditionalWrapper from '../ConditionalWrapper';
import AuthContainer from './AuthContainer';
import { onValidateHandles } from '../../hooks/useProfileForm';
import ExperienceLevelDropdown from '../profile/ExperienceLevelDropdown';
-import { LanguageDropdown } from '../profile/LanguageDropdown';
import Alert, { AlertType } from '../widgets/Alert';
import { isDevelopment } from '../../lib/constants';
@@ -296,10 +295,6 @@ const RegistrationForm = ({
}
saveHintSpace
/>
-
Your email will be used to send you product and community updates
diff --git a/packages/shared/src/components/auth/SocialRegistrationForm.tsx b/packages/shared/src/components/auth/SocialRegistrationForm.tsx
index b118569460..7a23bd2d05 100644
--- a/packages/shared/src/components/auth/SocialRegistrationForm.tsx
+++ b/packages/shared/src/components/auth/SocialRegistrationForm.tsx
@@ -27,7 +27,6 @@ import ConditionalWrapper from '../ConditionalWrapper';
import type { SignBackProvider } from '../../hooks/auth/useSignBack';
import { useSignBack } from '../../hooks/auth/useSignBack';
import ExperienceLevelDropdown from '../profile/ExperienceLevelDropdown';
-import { LanguageDropdown } from '../profile/LanguageDropdown';
export interface SocialRegistrationFormProps extends AuthFormProps {
className?: string;
@@ -258,7 +257,6 @@ export const SocialRegistrationForm = ({
hint={experienceLevelHint}
saveHintSpace
/>
-
Your email will be used to send you product and community updates
diff --git a/packages/shared/src/components/buttons/common.ts b/packages/shared/src/components/buttons/common.ts
index 190d1d6a02..69ff8f18f6 100644
--- a/packages/shared/src/components/buttons/common.ts
+++ b/packages/shared/src/components/buttons/common.ts
@@ -210,8 +210,16 @@ export const useGetIconWithSize = (
className: classNames(
icon.props.className,
!iconOnly && 'text-base',
- !iconOnly && iconPosition === ButtonIconPosition.Left && '-ml-2 mr-1',
- !iconOnly && iconPosition === ButtonIconPosition.Right && '-mr-2 ml-1',
+ !iconOnly && iconPosition === ButtonIconPosition.Left && 'mr-1',
+ !iconOnly &&
+ !icon.props?.size &&
+ iconPosition === ButtonIconPosition.Left &&
+ '-ml-2',
+ !iconOnly && iconPosition === ButtonIconPosition.Right && 'ml-1',
+ !iconOnly &&
+ !icon.props?.size &&
+ iconPosition === ButtonIconPosition.Right &&
+ '-mr-2',
),
});
};
diff --git a/packages/shared/src/components/cards/common/ShowMoreContent.tsx b/packages/shared/src/components/cards/common/ShowMoreContent.tsx
index d10a2c46b9..8c46380eb9 100644
--- a/packages/shared/src/components/cards/common/ShowMoreContent.tsx
+++ b/packages/shared/src/components/cards/common/ShowMoreContent.tsx
@@ -38,7 +38,10 @@ export default function ShowMoreContent({
return (
-
+
{contentPrefix}
{getContent()}{' '}
{displayShowMoreLink() && (
diff --git a/packages/shared/src/components/feeds/FeedSettings/components/SmartPrompts.tsx b/packages/shared/src/components/feeds/FeedSettings/components/SmartPrompts.tsx
new file mode 100644
index 0000000000..e909a194cd
--- /dev/null
+++ b/packages/shared/src/components/feeds/FeedSettings/components/SmartPrompts.tsx
@@ -0,0 +1,126 @@
+import React from 'react';
+import type { ReactElement } from 'react';
+import {
+ Typography,
+ TypographyColor,
+ TypographyTag,
+ TypographyType,
+} from '../../../typography/Typography';
+import { PlusUser } from '../../../PlusUser';
+import { LogEvent, Origin, TargetId } from '../../../../lib/log';
+import { Button, ButtonSize, ButtonVariant } from '../../../buttons/Button';
+import { plusUrl } from '../../../../lib/constants';
+import { DevPlusIcon } from '../../../icons';
+import { usePlusSubscription, useToastNotification } from '../../../../hooks';
+import { usePromptsQuery } from '../../../../hooks/prompt/usePromptsQuery';
+import { FilterCheckbox } from '../../../fields/FilterCheckbox';
+import { useSettingsContext } from '../../../../contexts/SettingsContext';
+import { labels } from '../../../../lib';
+import { useLogContext } from '../../../../contexts/LogContext';
+import { useFeedSettingsEditContext } from '../FeedSettingsEditContext';
+import { SimpleTooltip } from '../../../tooltips';
+import ConditionalWrapper from '../../../ConditionalWrapper';
+
+export const SmartPrompts = (): ReactElement => {
+ const { editFeedSettings } = useFeedSettingsEditContext();
+ const { isPlus, logSubscriptionEvent } = usePlusSubscription();
+ const { displayToast } = useToastNotification();
+ const { logEvent } = useLogContext();
+ const { flags, updatePromptFlag } = useSettingsContext();
+ const { prompt: promptFlags } = flags;
+ const { data: prompts, isLoading } = usePromptsQuery();
+
+ return (
+
+
+
+
+ Level up how you interact with posts using AI-powered prompts. Extract
+ insights, refine content, or run custom instructions to get more out
+ of every post in one click.
+
+
+
+
+ {prompts?.map(({ id, label, description }) => (
+
{
+ return (
+
+ {child as ReactElement}
+
+ );
+ }}
+ >
+ {
+ const newState = !promptFlags?.[id] || false;
+ editFeedSettings(() => updatePromptFlag(id, newState));
+ displayToast(
+ labels.feed.settings.globalPreferenceNotice.smartPrompt,
+ );
+
+ logEvent({
+ event_name: LogEvent.ToggleSmartPrompts,
+ target_type: id,
+ target_id: newState ? TargetId.On : TargetId.Off,
+ extra: JSON.stringify({
+ origin: Origin.Settings,
+ }),
+ });
+ }}
+ descriptionClassName="text-text-tertiary"
+ >
+ {label}
+
+
+ ))}
+
+ {!isPlus && (
+ }
+ onClick={() => {
+ logSubscriptionEvent({
+ event_name: LogEvent.UpgradeSubscription,
+ target_id: TargetId.ClickbaitShield,
+ });
+ }}
+ >
+ Upgrade to Plus
+
+ )}
+
+ );
+};
diff --git a/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsAISection.tsx b/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsAISection.tsx
index 02972ae760..2bf7a27297 100644
--- a/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsAISection.tsx
+++ b/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsAISection.tsx
@@ -25,6 +25,7 @@ import {
import { Divider } from '../../../utilities';
import { Switch } from '../../../fields/Switch';
import { labels } from '../../../../lib';
+import { SmartPrompts } from '../components/SmartPrompts';
export const FeedSettingsAISection = (): ReactElement => {
const { isPlus, logSubscriptionEvent } = usePlusSubscription();
@@ -38,6 +39,39 @@ export const FeedSettingsAISection = (): ReactElement => {
return (
<>
+
+
+
+ Preferred language
+
+
+ Choose your preferred language for the post titles on the feed
+
+
+ {
+ onLanguageChange(value);
+
+ displayToast(
+ labels.feed.settings.globalPreferenceNotice.contentLanguage,
+ );
+ }}
+ icon={null}
+ />
+
+
@@ -127,38 +161,7 @@ export const FeedSettingsAISection = (): ReactElement => {
)}
-
-
-
- Preferred language
-
-
- Choose your preferred language for the post titles on the feed
-
-
- {
- onLanguageChange(value);
-
- displayToast(
- labels.feed.settings.globalPreferenceNotice.contentLanguage,
- );
- }}
- icon={null}
- />
-
+
>
);
};
diff --git a/packages/shared/src/components/icons/CustomPrompt/filled.svg b/packages/shared/src/components/icons/CustomPrompt/filled.svg
new file mode 100644
index 0000000000..5f0275b0a4
--- /dev/null
+++ b/packages/shared/src/components/icons/CustomPrompt/filled.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/shared/src/components/icons/CustomPrompt/index.tsx b/packages/shared/src/components/icons/CustomPrompt/index.tsx
new file mode 100644
index 0000000000..12013d0dec
--- /dev/null
+++ b/packages/shared/src/components/icons/CustomPrompt/index.tsx
@@ -0,0 +1,10 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { IconProps } from '../../Icon';
+import Icon from '../../Icon';
+import OutlinedIcon from './outlined.svg';
+import FilledIcon from './filled.svg';
+
+export const CustomPromptIcon = (props: IconProps): ReactElement => (
+
+);
diff --git a/packages/shared/src/components/icons/CustomPrompt/outlined.svg b/packages/shared/src/components/icons/CustomPrompt/outlined.svg
new file mode 100644
index 0000000000..88dbaaa14f
--- /dev/null
+++ b/packages/shared/src/components/icons/CustomPrompt/outlined.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/shared/src/components/icons/EditPrompt/filled.svg b/packages/shared/src/components/icons/EditPrompt/filled.svg
new file mode 100644
index 0000000000..50831dfe79
--- /dev/null
+++ b/packages/shared/src/components/icons/EditPrompt/filled.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/packages/shared/src/components/icons/EditPrompt/index.tsx b/packages/shared/src/components/icons/EditPrompt/index.tsx
new file mode 100644
index 0000000000..9fe1983544
--- /dev/null
+++ b/packages/shared/src/components/icons/EditPrompt/index.tsx
@@ -0,0 +1,10 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { IconProps } from '../../Icon';
+import Icon from '../../Icon';
+import OutlinedIcon from './outlined.svg';
+import FilledIcon from './filled.svg';
+
+export const EditPromptIcon = (props: IconProps): ReactElement => (
+
+);
diff --git a/packages/shared/src/components/icons/EditPrompt/outlined.svg b/packages/shared/src/components/icons/EditPrompt/outlined.svg
new file mode 100644
index 0000000000..0005ac16c4
--- /dev/null
+++ b/packages/shared/src/components/icons/EditPrompt/outlined.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/packages/shared/src/components/icons/TLDR/filled.svg b/packages/shared/src/components/icons/TLDR/filled.svg
new file mode 100644
index 0000000000..ee8f08331d
--- /dev/null
+++ b/packages/shared/src/components/icons/TLDR/filled.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/packages/shared/src/components/icons/TLDR/index.tsx b/packages/shared/src/components/icons/TLDR/index.tsx
new file mode 100644
index 0000000000..16e65ab51b
--- /dev/null
+++ b/packages/shared/src/components/icons/TLDR/index.tsx
@@ -0,0 +1,10 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { IconProps } from '../../Icon';
+import Icon from '../../Icon';
+import OutlinedIcon from './outlined.svg';
+import FilledIcon from './filled.svg';
+
+export const TLDRIcon = (props: IconProps): ReactElement => (
+
+);
diff --git a/packages/shared/src/components/icons/TLDR/outlined.svg b/packages/shared/src/components/icons/TLDR/outlined.svg
new file mode 100644
index 0000000000..075d96a96f
--- /dev/null
+++ b/packages/shared/src/components/icons/TLDR/outlined.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts
index b9a8517e7b..bc1d0b8d69 100644
--- a/packages/shared/src/components/icons/index.ts
+++ b/packages/shared/src/components/icons/index.ts
@@ -129,4 +129,7 @@ export * from './ShieldWarning';
export * from './ShieldPlus';
export * from './Sidebar';
export * from './Folder';
+export * from './EditPrompt';
+export * from './CustomPrompt';
+export * from './TLDR';
export * from './Privacy';
diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx
index 4570c15bc5..a5a653c666 100644
--- a/packages/shared/src/components/modals/common.tsx
+++ b/packages/shared/src/components/modals/common.tsx
@@ -214,6 +214,12 @@ const AddToCustomFeedModal = dynamic(
),
);
+const SmartPromptModal = dynamic(() =>
+ import(
+ /* webpackChunkName: "smartPromptModal" */ './plus/SmartPromptModal'
+ ).then((mod) => mod.SmartPromptModal),
+);
+
const CookieConsentModal = dynamic(
() =>
import(
@@ -275,6 +281,7 @@ export const modals = {
[LazyModal.ClickbaitShield]: ClickbaitShieldModal,
[LazyModal.MoveBookmark]: MoveBookmarkModal,
[LazyModal.AddToCustomFeed]: AddToCustomFeedModal,
+ [LazyModal.SmartPrompt]: SmartPromptModal,
[LazyModal.CookieConsent]: CookieConsentModal,
[LazyModal.ReportUser]: ReportUserModal,
[LazyModal.GiftPlus]: GiftPlusModal,
diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts
index 4fb570ad2b..f079250bb0 100644
--- a/packages/shared/src/components/modals/common/types.ts
+++ b/packages/shared/src/components/modals/common/types.ts
@@ -64,6 +64,7 @@ export enum LazyModal {
ReportUser = 'reportUser',
GiftPlus = 'giftPlus',
GiftPlusReceived = 'giftPlusReceived',
+ SmartPrompt = 'smartPrompt',
}
export type ModalTabItem = {
diff --git a/packages/shared/src/components/modals/plus/SmartPromptModal.tsx b/packages/shared/src/components/modals/plus/SmartPromptModal.tsx
new file mode 100644
index 0000000000..4b00d30428
--- /dev/null
+++ b/packages/shared/src/components/modals/plus/SmartPromptModal.tsx
@@ -0,0 +1,62 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { ModalProps } from '../common/Modal';
+import { Modal } from '../common/Modal';
+import { Image } from '../../image/Image';
+import { smartPromptModalImage } from '../../../lib/image';
+import {
+ Typography,
+ TypographyColor,
+ TypographyType,
+} from '../../typography/Typography';
+import { PlusUser } from '../../PlusUser';
+import { Button, ButtonVariant } from '../../buttons/Button';
+import { webappUrl } from '../../../lib/constants';
+import { DevPlusIcon } from '../../icons';
+import { LogEvent, TargetId } from '../../../lib/log';
+import { usePlusSubscription } from '../../../hooks';
+
+export const SmartPromptModal = ({ ...props }: ModalProps): ReactElement => {
+ const { logSubscriptionEvent } = usePlusSubscription();
+ return (
+
+
+
+
+
+
+ Level up how you interact with posts using AI-powered prompts. Extract
+ insights, refine content, or run custom instructions to get more out
+ of every post in one click.
+
+
}
+ onClick={async () => {
+ logSubscriptionEvent({
+ event_name: LogEvent.UpgradeSubscription,
+ target_id: TargetId.SmartPrompt,
+ });
+ }}
+ >
+ Upgrade to Plus
+
+
+
+ );
+};
diff --git a/packages/shared/src/components/plus/ClickbaitTrial.tsx b/packages/shared/src/components/plus/PostUpgradeToPlus.tsx
similarity index 62%
rename from packages/shared/src/components/plus/ClickbaitTrial.tsx
rename to packages/shared/src/components/plus/PostUpgradeToPlus.tsx
index 30ac12626e..cc37054f6b 100644
--- a/packages/shared/src/components/plus/ClickbaitTrial.tsx
+++ b/packages/shared/src/components/plus/PostUpgradeToPlus.tsx
@@ -1,5 +1,6 @@
-import type { ReactElement } from 'react';
-import React, { useState } from 'react';
+import type { PropsWithChildren, ReactElement } from 'react';
+import React, { useCallback, useState } from 'react';
+import classNames from 'classnames';
import { PlusUser } from '../PlusUser';
import CloseButton from '../CloseButton';
import { ButtonSize, ButtonVariant } from '../buttons/common';
@@ -12,26 +13,48 @@ import { Button } from '../buttons/Button';
import { webappUrl } from '../../lib/constants';
import { DevPlusIcon } from '../icons';
import { usePlusSubscription } from '../../hooks';
-import { LogEvent, TargetId } from '../../lib/log';
+import type { TargetId } from '../../lib/log';
+import { LogEvent } from '../../lib/log';
-export const ClickbaitTrial = (): ReactElement => {
+type PostUpgradeToPlusProps = {
+ targetId: TargetId;
+ title: ReactElement | string;
+ className?: string;
+ onClose?: () => void;
+};
+
+export const PostUpgradeToPlus = ({
+ targetId: target_id,
+ title,
+ children,
+ className,
+ onClose,
+}: PostUpgradeToPlusProps & PropsWithChildren): ReactElement => {
const [show, setShow] = useState(true);
const { logSubscriptionEvent } = usePlusSubscription();
+ const onCloseClick = useCallback(() => {
+ onClose?.();
+ setShow(false);
+ }, [onClose]);
+
if (!show) {
return null;
}
return (
-
+
{
- setShow(false);
- }}
+ onClick={onCloseClick}
/>
{
color={TypographyColor.Primary}
className="py-2"
>
- Want to automatically optimize titles across your feed?
-
-
- Clickbait Shield uses AI to automatically optimize post titles by fixing
- common problems like clickbait, lack of clarity, and overly promotional
- language.
-
-
- The result is clearer, more informative titles that help you quickly
- find the content you actually need.
+ {title}
+
{children}
{
onClick={() => {
logSubscriptionEvent({
event_name: LogEvent.UpgradeSubscription,
- target_id: TargetId.ClickbaitShield,
+ target_id,
});
}}
>
@@ -74,9 +89,7 @@ export const ClickbaitTrial = (): ReactElement => {
className="flex-1"
variant={ButtonVariant.Tertiary}
size={ButtonSize.Small}
- onClick={() => {
- setShow(false);
- }}
+ onClick={onCloseClick}
>
Not now
diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx
index fab1cc4491..7e84e243b7 100644
--- a/packages/shared/src/components/post/PostContent.tsx
+++ b/packages/shared/src/components/post/PostContent.tsx
@@ -4,7 +4,6 @@ import React, { useEffect } from 'react';
import dynamic from 'next/dynamic';
import { isVideoPost } from '../../graphql/posts';
import PostMetadata from '../cards/common/PostMetadata';
-import PostSummary from '../cards/common/PostSummary';
import { PostWidgets } from './PostWidgets';
import { TagLinks } from '../TagLinks';
import PostToc from '../widgets/PostToc';
@@ -27,6 +26,7 @@ import { withPostById } from './withPostById';
import { PostClickbaitShield } from './common/PostClickbaitShield';
import { useSmartTitle } from '../../hooks/post/useSmartTitle';
import { SharedByUserBanner } from '../SharedByUserBanner';
+import { SmartPrompt } from './smartPrompts/SmartPrompt';
export const SCROLL_OFFSET = 80;
export const ONBOARDING_OFFSET = 120;
@@ -164,9 +164,7 @@ export function PostContentRaw({
className="mb-7"
/>
)}
- {post.summary && (
-
- )}
+ {post.summary &&
}
{
const { openModal } = useLazyModal();
@@ -57,7 +58,19 @@ export const PostClickbaitShield = ({ post }: { post: Post }): ReactElement => {
{fetchedSmartTitle ? (
<>
This title was optimized with Clickbait Shield
-
+
+ Clickbait Shield uses AI to automatically optimize post titles by
+ fixing common problems like clickbait, lack of clarity, and overly
+ promotional language.
+
+
+ The result is clearer, more informative titles that help you
+ quickly find the content you actually need.
+
>
) : (
<>
diff --git a/packages/shared/src/components/post/smartPrompts/CustomPrompt.tsx b/packages/shared/src/components/post/smartPrompts/CustomPrompt.tsx
new file mode 100644
index 0000000000..0db8fa92a9
--- /dev/null
+++ b/packages/shared/src/components/post/smartPrompts/CustomPrompt.tsx
@@ -0,0 +1,118 @@
+import React, { useCallback, useMemo, useState } from 'react';
+import type { ReactElement } from 'react';
+import {
+ Button,
+ ButtonColor,
+ ButtonSize,
+ ButtonVariant,
+} from '../../buttons/Button';
+import type { Post } from '../../../graphql/posts';
+import { useSmartPrompt } from '../../../hooks/prompt/useSmartPrompt';
+import { usePromptsQuery } from '../../../hooks/prompt/usePromptsQuery';
+import { PromptDisplay } from '../../../graphql/prompt';
+import { SearchProgressBar } from '../../search';
+import { isNullOrUndefined } from '../../../lib/func';
+import Alert, { AlertType } from '../../widgets/Alert';
+import { labels } from '../../../lib';
+import { RenderMarkdown } from '../../RenderMarkdown';
+import { CopyIcon, EditIcon } from '../../icons';
+import { useCopyText } from '../../../hooks/useCopy';
+import { postLogEvent } from '../../../lib/feed';
+import { LogEvent } from '../../../lib/log';
+import { useLogContext } from '../../../contexts/LogContext';
+
+type CustomPromptProps = {
+ post: Post;
+};
+
+export const CustomPrompt = ({ post }: CustomPromptProps): ReactElement => {
+ const { logEvent } = useLogContext();
+ const { data: prompts } = usePromptsQuery();
+ const [isEdit, setIsEdit] = useState(false);
+ const [copying, copy] = useCopyText();
+ const prompt = useMemo(
+ () => prompts?.find((p) => p.id === PromptDisplay.CustomPrompt),
+ [prompts],
+ );
+ const { executePrompt, data, isPending } = useSmartPrompt({ post, prompt });
+ const onSubmitCustomPrompt = useCallback(
+ (e) => {
+ e.preventDefault();
+ logEvent(
+ postLogEvent(LogEvent.SmartPrompt, post, {
+ extra: { prompt: 'custom-prompt' },
+ }),
+ );
+ setIsEdit(false);
+ executePrompt(e.target[0].value);
+ },
+ [executePrompt, logEvent, post],
+ );
+
+ if (!data || isEdit) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {!!data?.chunks?.[0]?.steps && (
+
+
+ {!!data?.chunks?.[0]?.status && (
+
+ {data?.chunks?.[0]?.status}
+
+ )}
+
+ )}
+ {!isNullOrUndefined(data?.chunks?.[0]?.error?.code) && (
+
+ )}
+
+
+ }
+ variant={ButtonVariant.Tertiary}
+ size={ButtonSize.Small}
+ color={copying ? ButtonColor.Avocado : undefined}
+ onClick={() => copy({ textToCopy: data?.chunks?.[0]?.response })}
+ />
+ }
+ variant={ButtonVariant.Tertiary}
+ size={ButtonSize.Small}
+ onClick={() => setIsEdit(true)}
+ />
+
+
+ );
+};
diff --git a/packages/shared/src/components/post/smartPrompts/PromptButtons.tsx b/packages/shared/src/components/post/smartPrompts/PromptButtons.tsx
new file mode 100644
index 0000000000..50f602e335
--- /dev/null
+++ b/packages/shared/src/components/post/smartPrompts/PromptButtons.tsx
@@ -0,0 +1,159 @@
+import React, { forwardRef, useCallback, useMemo, useState } from 'react';
+import type { ReactElement, Ref } from 'react';
+import { ColorName } from '../../../styles/colors';
+import { ArrowIcon, CustomPromptIcon } from '../../icons';
+import type { ButtonProps } from '../../buttons/Button';
+import {
+ Button,
+ ButtonIconPosition,
+ ButtonSize,
+ ButtonVariant,
+} from '../../buttons/Button';
+import { IconSize } from '../../Icon';
+import { usePromptsQuery } from '../../../hooks/prompt/usePromptsQuery';
+import { ElementPlaceholder } from '../../ElementPlaceholder';
+import type { PromptFlags } from '../../../graphql/prompt';
+import { PromptDisplay } from '../../../graphql/prompt';
+import { usePromptButtons } from '../../../hooks/prompt/usePromptButtons';
+import { usePlusSubscription, useViewSize, ViewSize } from '../../../hooks';
+import { SimpleTooltip } from '../../tooltips';
+import { promptColorMap, PromptIconMap } from './common';
+import { LazyModal } from '../../modals/common/types';
+import { useLazyModal } from '../../../hooks/useLazyModal';
+import { useSettingsContext } from '../../../contexts/SettingsContext';
+
+type PromptButtonProps = ButtonProps<'button'> & {
+ active: boolean;
+ flags: PromptFlags;
+};
+
+const PromptButton = forwardRef(
+ (
+ { children, flags, active, ...props }: PromptButtonProps,
+ ref?: Ref,
+ ): ReactElement => {
+ const PromptIcon = PromptIconMap[flags.icon] || CustomPromptIcon;
+ const variant = active ? ButtonVariant.Primary : ButtonVariant.Subtle;
+ const color = active ? flags.color : undefined;
+ return (
+
+ }
+ {...props}
+ ref={ref}
+ >
+ {children}
+
+ );
+ },
+);
+PromptButton.displayName = 'PromptButton';
+
+type PromptButtonsProps = {
+ activePrompt: string;
+ setActivePrompt: (prompt: string) => void;
+ width: number;
+};
+
+export const PromptButtons = ({
+ activePrompt,
+ setActivePrompt,
+ width,
+}: PromptButtonsProps): ReactElement => {
+ const isMobile = useViewSize(ViewSize.MobileL);
+ const [showAll, setShowAll] = useState(false);
+ const { openModal } = useLazyModal();
+ const { data, isLoading } = usePromptsQuery();
+ const { flags: settingFlags } = useSettingsContext();
+ const { prompt: promptFlags } = settingFlags || {};
+ const prompts = useMemo(() => {
+ return data?.filter((prompt) => promptFlags?.[prompt.id] !== false);
+ }, [data, promptFlags]);
+
+ const { isPlus } = usePlusSubscription();
+ const promptList = usePromptButtons({
+ prompts,
+ width,
+ offset: 82,
+ base: 16,
+ showAll: showAll || isMobile,
+ });
+
+ const promptsCount = prompts?.length || 0;
+ const remainingTags = promptsCount - promptList?.length;
+
+ const onPromptClick = useCallback(
+ (id) => {
+ if (isMobile && !isPlus) {
+ openModal({
+ type: LazyModal.SmartPrompt,
+ });
+ return;
+ }
+ setActivePrompt(id);
+ },
+ [isMobile, isPlus, openModal, setActivePrompt],
+ );
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
setActivePrompt(PromptDisplay.TLDR)}
+ >
+ TLDR
+
+
+ {promptList?.map(({ id, label, flags, description }) => (
+
+ onPromptClick(id)}
+ >
+ {label}
+
+
+ ))}
+
+ {!showAll && !isMobile && promptList?.length > 0 && remainingTags > 0 && (
+
+ }
+ iconPosition={ButtonIconPosition.Right}
+ onClick={() => setShowAll(true)}
+ >
+ {remainingTags}+ More
+
+
+ )}
+
+ );
+};
diff --git a/packages/shared/src/components/post/smartPrompts/SmartPrompt.tsx b/packages/shared/src/components/post/smartPrompts/SmartPrompt.tsx
new file mode 100644
index 0000000000..0c1d60870d
--- /dev/null
+++ b/packages/shared/src/components/post/smartPrompts/SmartPrompt.tsx
@@ -0,0 +1,111 @@
+import React, { useRef, useState } from 'react';
+import type { ReactElement } from 'react';
+import type { Post } from '../../../graphql/posts';
+import { Tab, TabContainer } from '../../tabs/TabContainer';
+import { useActions, usePlusSubscription } from '../../../hooks';
+import { PromptButtons } from './PromptButtons';
+import { PromptDisplay } from '../../../graphql/prompt';
+import { PostUpgradeToPlus } from '../../plus/PostUpgradeToPlus';
+import { LogEvent, TargetId } from '../../../lib/log';
+import ShowMoreContent from '../../cards/common/ShowMoreContent';
+import { SmartPromptResponse } from './SmartPromptResponse';
+import { CustomPrompt } from './CustomPrompt';
+import { ActionType } from '../../../graphql/actions';
+import { postLogEvent } from '../../../lib/feed';
+import { useLogContext } from '../../../contexts/LogContext';
+
+export const SmartPrompt = ({ post }: { post: Post }): ReactElement => {
+ const { logEvent } = useLogContext();
+ const { isPlus } = usePlusSubscription();
+ const { completeAction, checkHasCompleted } = useActions();
+ const [activeDisplay, setActiveDisplay] = useState(
+ PromptDisplay.TLDR,
+ );
+ const [activePrompt, setActivePrompt] = useState(PromptDisplay.TLDR);
+ const elementRef = useRef(null);
+ const width = elementRef?.current?.getBoundingClientRect()?.width || 0;
+ const triedSmartPrompts = checkHasCompleted(ActionType.SmartPrompt);
+
+ const onSetActivePrompt = (prompt: string) => {
+ setActivePrompt(prompt);
+
+ if (!isPlus && prompt !== PromptDisplay.TLDR && triedSmartPrompts) {
+ setActiveDisplay(PromptDisplay.UpgradeToPlus);
+ return;
+ }
+
+ if (prompt !== PromptDisplay.TLDR) {
+ if (!triedSmartPrompts) {
+ completeAction(ActionType.SmartPrompt);
+ }
+ }
+
+ if (
+ prompt !== PromptDisplay.TLDR &&
+ prompt !== PromptDisplay.CustomPrompt
+ ) {
+ logEvent(
+ postLogEvent(LogEvent.SmartPrompt, post, {
+ extra: { prompt },
+ }),
+ );
+ }
+
+ switch (prompt) {
+ case PromptDisplay.TLDR:
+ setActiveDisplay(PromptDisplay.TLDR);
+ break;
+ case PromptDisplay.CustomPrompt:
+ setActiveDisplay(PromptDisplay.CustomPrompt);
+ break;
+ default:
+ setActiveDisplay(PromptDisplay.SmartPrompt);
+ break;
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ setActiveDisplay(PromptDisplay.TLDR);
+ }}
+ >
+ Level up how you interact with posts using AI-powered prompts.
+ Extract insights, refine content, or run custom instructions to get
+ more out of every post in one click.
+
+
+
+
+ );
+};
diff --git a/packages/shared/src/components/post/smartPrompts/SmartPromptResponse.tsx b/packages/shared/src/components/post/smartPrompts/SmartPromptResponse.tsx
new file mode 100644
index 0000000000..3574b07116
--- /dev/null
+++ b/packages/shared/src/components/post/smartPrompts/SmartPromptResponse.tsx
@@ -0,0 +1,83 @@
+import React, { useEffect, useMemo } from 'react';
+import type { ReactElement } from 'react';
+import type { Post } from '../../../graphql/posts';
+import { RenderMarkdown } from '../../RenderMarkdown';
+import { SearchProgressBar } from '../../search';
+import Alert, { AlertType } from '../../widgets/Alert';
+import { isNullOrUndefined } from '../../../lib/func';
+import { labels } from '../../../lib';
+import { usePromptsQuery } from '../../../hooks/prompt/usePromptsQuery';
+import { useSmartPrompt } from '../../../hooks/prompt/useSmartPrompt';
+import {
+ Button,
+ ButtonColor,
+ ButtonSize,
+ ButtonVariant,
+} from '../../buttons/Button';
+import { CopyIcon } from '../../icons';
+import { useCopyText } from '../../../hooks/useCopy';
+
+type SmartPromptResponseProps = {
+ post: Post;
+ activePrompt: string;
+};
+
+export const SmartPromptResponse = ({
+ post,
+ activePrompt,
+}: SmartPromptResponseProps): ReactElement => {
+ const [copying, copy] = useCopyText();
+ const { data: prompts } = usePromptsQuery();
+ const prompt = useMemo(
+ () => prompts?.find((p) => p.id === activePrompt),
+ [activePrompt, prompts],
+ );
+
+ const { executePrompt, data, isPending } = useSmartPrompt({ post, prompt });
+
+ useEffect(() => {
+ if (!prompt.prompt || data) {
+ return;
+ }
+
+ executePrompt(prompt.prompt);
+ }, [prompt, executePrompt, data]);
+
+ return (
+
+ {!!data?.chunks?.[0]?.steps && (
+
+
+ {!!data?.chunks?.[0]?.status && (
+
+ {data?.chunks?.[0]?.status}
+
+ )}
+
+ )}
+ {!isNullOrUndefined(data?.chunks?.[0]?.error?.code) && (
+
+ )}
+
+
+ }
+ variant={ButtonVariant.Tertiary}
+ size={ButtonSize.Small}
+ color={copying ? ButtonColor.Avocado : undefined}
+ onClick={() => copy({ textToCopy: data?.chunks?.[0]?.response })}
+ />
+
+
+ );
+};
diff --git a/packages/shared/src/components/post/smartPrompts/common.ts b/packages/shared/src/components/post/smartPrompts/common.ts
new file mode 100644
index 0000000000..c97cba908b
--- /dev/null
+++ b/packages/shared/src/components/post/smartPrompts/common.ts
@@ -0,0 +1,22 @@
+import { CustomPromptIcon, EditPromptIcon, TLDRIcon } from '../../icons';
+
+export const PromptIconMap = {
+ TLDR: TLDRIcon,
+ CustomPrompt: CustomPromptIcon,
+ EditPrompt: EditPromptIcon,
+};
+
+export const promptColorMap = {
+ burger: 'text-accent-burger-default',
+ blueCheese: 'text-accent-blueCheese-default',
+ avocado: 'text-accent-avocado-default',
+ lettuce: 'text-accent-lettuce-default',
+ cheese: 'text-accent-cheese-default',
+ bun: 'text-accent-bun-default',
+ ketchup: 'text-accent-ketchup-default',
+ bacon: 'text-accent-bacon-default',
+ cabbage: 'text-accent-cabbage-default',
+ onion: 'text-accent-onion-default',
+ water: 'text-accent-water-default',
+ salt: 'text-accent-salt-default',
+};
diff --git a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx
index 61176b55f3..c234ee83fc 100644
--- a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx
+++ b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx
@@ -12,21 +12,13 @@ import {
DocsIcon,
TerminalIcon,
FeedbackIcon,
- HammerIcon,
AppIcon,
DevPlusIcon,
PrivacyIcon,
DownloadIcon,
} from '../icons';
import { NavDrawer } from '../drawers/NavDrawer';
-import {
- docs,
- feedback,
- managePlusUrl,
- plusUrl,
- privacyPolicy,
- termsOfService,
-} from '../../lib/constants';
+import { docs, feedback, managePlusUrl, plusUrl } from '../../lib/constants';
import { useLazyModal } from '../../hooks/useLazyModal';
import { LazyModal } from '../modals/common/types';
import { anchorDefaultRel } from '../../lib/strings';
@@ -119,7 +111,8 @@ const useMenuItems = (): NavItemProps[] => {
});
}
- items.push(
+ return [
+ ...items,
{
label: 'Invite friends',
icon: ,
@@ -127,10 +120,6 @@ const useMenuItems = (): NavItemProps[] => {
},
{ label: 'Devcard', icon: , href: '/devcard' },
{ label: 'Privacy', icon: , href: '/account/privacy' },
- );
-
- return [
- ...items,
{
label: 'Logout',
icon: ,
@@ -195,20 +184,6 @@ const useMenuItems = (): NavItemProps[] => {
target: '_blank',
rel: anchorDefaultRel,
},
- {
- label: 'Privacy policy',
- icon: ,
- href: privacyPolicy,
- target: '_blank',
- rel: anchorDefaultRel,
- },
- {
- label: 'Terms of service',
- icon: ,
- href: termsOfService,
- target: '_blank',
- rel: anchorDefaultRel,
- },
].filter(Boolean);
}, [
isPlus,
diff --git a/packages/shared/src/components/sidebar/sections/NetworkSection.tsx b/packages/shared/src/components/sidebar/sections/NetworkSection.tsx
index 4a542ae749..0c2b8f2e46 100644
--- a/packages/shared/src/components/sidebar/sections/NetworkSection.tsx
+++ b/packages/shared/src/components/sidebar/sections/NetworkSection.tsx
@@ -2,7 +2,12 @@ import type { ReactElement } from 'react';
import React, { useMemo } from 'react';
import type { SidebarMenuItem } from '../common';
import { ListIcon } from '../common';
-import { DefaultSquadIcon, NewSquadIcon, SourceIcon } from '../../icons';
+import {
+ DefaultSquadIcon,
+ NewSquadIcon,
+ SourceIcon,
+ TimerIcon,
+} from '../../icons';
import { Section } from '../Section';
import { Origin } from '../../../lib/log';
import { useSquadNavigation } from '../../../hooks';
@@ -11,13 +16,17 @@ import { SquadImage } from '../../squads/SquadImage';
import { SidebarSettingsFlags } from '../../../graphql/settings';
import { webappUrl } from '../../../lib/constants';
import type { SidebarSectionProps } from './common';
+import { useSquadPendingPosts } from '../../../hooks/squads/useSquadPendingPosts';
+import { Typography, TypographyColor } from '../../typography/Typography';
export const NetworkSection = ({
isItemsButton,
...defaultRenderSectionProps
}: SidebarSectionProps): ReactElement => {
const { squads } = useAuthContext();
+ const { count, isModeratorInAnySquad } = useSquadPendingPosts();
const { openNewSquad } = useSquadNavigation();
+
const menuItems: SidebarMenuItem[] = useMemo(() => {
const squadItems =
squads?.map((squad) => {
@@ -33,8 +42,22 @@ export const NetworkSection = ({
path: `${webappUrl}squads/${handle}`,
};
}) ?? [];
-
return [
+ isModeratorInAnySquad &&
+ count > 0 && {
+ icon: () => } />,
+ title: 'Pending Posts',
+ path: `${webappUrl}squads/moderate`,
+ rightIcon: () => (
+
+ {count}
+
+ ),
+ },
{
icon: (active: boolean) => (
} />
@@ -51,7 +74,7 @@ export const NetworkSection = ({
requiresLogin: true,
},
].filter(Boolean);
- }, [openNewSquad, squads]);
+ }, [openNewSquad, squads, count, isModeratorInAnySquad]);
return (
) => {
return (
}
size={ButtonSize.Small}
tag="a"
diff --git a/packages/shared/src/components/squads/SquadTabs.tsx b/packages/shared/src/components/squads/SquadTabs.tsx
index 076ed18411..c377f3bc84 100644
--- a/packages/shared/src/components/squads/SquadTabs.tsx
+++ b/packages/shared/src/components/squads/SquadTabs.tsx
@@ -2,9 +2,9 @@ import type { ReactElement } from 'react';
import React from 'react';
import { TabContainer, Tab } from '../tabs/TabContainer';
import { webappUrl } from '../../lib/constants';
-import type { Squad } from '../../graphql/sources';
import { SourcePermissions } from '../../graphql/sources';
import { verifyPermission } from '../../graphql/squads';
+import { useSquad } from '../../hooks';
export enum SquadTab {
Settings = 'Settings',
@@ -13,15 +13,17 @@ export enum SquadTab {
interface SquadTabsProps {
active: SquadTab;
- squad: Squad;
+ handle: string;
}
-export function SquadTabs({ active, squad }: SquadTabsProps): ReactElement {
- const { handle, moderationPostCount } = squad;
+export function SquadTabs({ active, handle }: SquadTabsProps): ReactElement {
+ const { squad } = useSquad({
+ handle,
+ });
const isModerator = verifyPermission(squad, SourcePermissions.ModeratePost);
const squadLink = `${webappUrl}squads/${handle}`;
- const pendingTabLabel = moderationPostCount
- ? `${SquadTab.PendingPosts} (${moderationPostCount})`
+ const pendingTabLabel = squad?.moderationPostCount
+ ? `${SquadTab.PendingPosts} (${squad?.moderationPostCount})`
: SquadTab.PendingPosts;
const links = [
@@ -33,7 +35,10 @@ export function SquadTabs({ active, squad }: SquadTabsProps): ReactElement {
},
]
: []),
- { label: pendingTabLabel, url: `${squadLink}/moderate` },
+ {
+ label: pendingTabLabel,
+ url: `${webappUrl}squads/moderate?handle=${handle}`,
+ },
];
const controlledActive =
diff --git a/packages/shared/src/components/squads/moderation/SquadModerationEmptyScreen.tsx b/packages/shared/src/components/squads/moderation/SquadModerationEmptyScreen.tsx
index a387485478..f140668b34 100644
--- a/packages/shared/src/components/squads/moderation/SquadModerationEmptyScreen.tsx
+++ b/packages/shared/src/components/squads/moderation/SquadModerationEmptyScreen.tsx
@@ -1,11 +1,8 @@
import type { ReactElement } from 'react';
import React from 'react';
import { VIcon } from '../../icons';
-import type { Squad } from '../../../graphql/sources';
-import { SourcePermissions } from '../../../graphql/sources';
import { SquadEmptyScreen } from './SquadEmptyScreen';
import { ElementPlaceholder } from '../../ElementPlaceholder';
-import { verifyPermission } from '../../../graphql/squads';
const ModerationItemSkeleton = () => (
@@ -37,15 +34,13 @@ const ModerationItemSkeleton = () => (
);
export const EmptyModerationList = ({
- squad,
isFetched,
+ isModerator,
}: {
- squad: Squad;
+ isModerator: boolean;
isFetched: boolean;
}): ReactElement => {
- const isModerator = verifyPermission(squad, SourcePermissions.ModeratePost);
-
- if (!isFetched || !squad) {
+ if (!isFetched || !isModerator) {
return (
diff --git a/packages/shared/src/components/squads/moderation/SquadModerationItem.tsx b/packages/shared/src/components/squads/moderation/SquadModerationItem.tsx
index 66452681f7..fe6745dea4 100644
--- a/packages/shared/src/components/squads/moderation/SquadModerationItem.tsx
+++ b/packages/shared/src/components/squads/moderation/SquadModerationItem.tsx
@@ -1,5 +1,6 @@
import type { ReactElement } from 'react';
import React from 'react';
+import { useSearchParams } from 'next/navigation';
import {
Typography,
TypographyColor,
@@ -21,13 +22,16 @@ import { useTruncatedSummary } from '../../../hooks';
import type { SquadModerationItemProps } from './useSourceModerationItem';
import { useSourceModerationItem } from './useSourceModerationItem';
import { SquadModerationItemContextMenu } from './SquadModerationItemContextMenu';
+import SourceProfilePicture from '../../profile/SourceProfilePicture';
export function SquadModerationItem(
props: SquadModerationItemProps,
): ReactElement {
+ const searchParams = useSearchParams();
+ const handle = searchParams?.get('handle');
const { context, modal, user } = useSourceModerationItem(props);
const { data, squad, onApprove, onReject, isPending } = props;
- const { rejectionReason, createdBy, createdAt, image, status } = data;
+ const { rejectionReason, createdBy, createdAt, image, status, source } = data;
const IconComponent =
status === SourcePostModerationStatus.Rejected ? WarningIcon : TimerIcon;
@@ -45,6 +49,21 @@ export function SquadModerationItem(
onClick={modal.open}
type="button"
/>
+ {!handle && (
+
+
+
+ {source.name}
+
+
+ )}
@@ -45,7 +43,9 @@ export function SquadModerationList({
);
if (!list.length) {
- return ;
+ return (
+
+ );
}
return (
@@ -57,10 +57,7 @@ export function SquadModerationList({
variant={ButtonVariant.Primary}
size={ButtonSize.Small}
onClick={() =>
- moderate.onApprove(
- list.map((request) => request.id),
- squad.id,
- )
+ moderate.onApprove(list.map((request) => request.id))
}
>
Approve all {list.length} posts
@@ -78,8 +75,8 @@ export function SquadModerationList({
squad={squad}
data={item}
isPending={isPending}
- onReject={() => moderate.onReject(item.id, squad.id)}
- onApprove={() => moderate.onApprove([item.id], squad.id)}
+ onReject={() => moderate.onReject(item.id, item.source.id)}
+ onApprove={() => moderate.onApprove([item.id], item.source.id)}
/>
))}
diff --git a/packages/shared/src/components/squads/moderation/useSourceModerationItem.ts b/packages/shared/src/components/squads/moderation/useSourceModerationItem.ts
index f64cf63e93..8888499654 100644
--- a/packages/shared/src/components/squads/moderation/useSourceModerationItem.ts
+++ b/packages/shared/src/components/squads/moderation/useSourceModerationItem.ts
@@ -1,5 +1,6 @@
import type { MouseEventHandler } from 'react';
import { useId } from 'react';
+import { useSearchParams } from 'next/navigation';
import type { SourcePostModeration } from '../../../graphql/squads';
import { verifyPermission } from '../../../graphql/squads';
import useContextMenu from '../../../hooks/useContextMenu';
@@ -35,16 +36,19 @@ interface UseSourceModerationItem {
export const useSourceModerationItem = ({
data,
- squad,
onApprove,
onReject,
}: SquadModerationItemProps): UseSourceModerationItem => {
+ const squad = data.source as Squad;
+ const searchParams = useSearchParams();
const contextMenuId = useId();
const { isOpen, onMenuClick } = useContextMenu({ id: contextMenuId });
const { openModal, closeModal } = useLazyModal();
- const isModerator = verifyPermission(squad, SourcePermissions.ModeratePost);
+ const isModerator =
+ verifyPermission(squad, SourcePermissions.ModeratePost) ||
+ !searchParams?.get('handle');
const { onDelete } = useSourceModerationList({ squad });
diff --git a/packages/shared/src/components/video/YoutubeVideoWithoutConsent.tsx b/packages/shared/src/components/video/YoutubeVideoWithoutConsent.tsx
index 72f6f97d37..0a0b446335 100644
--- a/packages/shared/src/components/video/YoutubeVideoWithoutConsent.tsx
+++ b/packages/shared/src/components/video/YoutubeVideoWithoutConsent.tsx
@@ -18,7 +18,7 @@ export interface YoutubeVideoWithoutConsentProps {
const Container = classed(
'div',
- 'relative w-full overflow-hidden rounded-16 pt-[56.25%]',
+ 'relative mb-7 w-full overflow-hidden rounded-16 pt-[56.25%]',
);
const Background = classed(
diff --git a/packages/shared/src/contexts/SettingsContext.tsx b/packages/shared/src/contexts/SettingsContext.tsx
index 17430a863d..ce08b84980 100644
--- a/packages/shared/src/contexts/SettingsContext.tsx
+++ b/packages/shared/src/contexts/SettingsContext.tsx
@@ -59,6 +59,7 @@ export interface SettingsContextData extends Omit {
flag: keyof SettingsFlags,
value: string | boolean,
) => Promise;
+ updatePromptFlag: (flag: string, value: boolean) => Promise;
syncSettings: (bootUserId?: string) => Promise;
onToggleHeaderPlacement(): Promise;
setOnboardingChecklistView: (value: ChecklistViewState) => Promise;
@@ -278,6 +279,17 @@ export const SettingsContextProvider = ({
[flag]: value,
},
}),
+ updatePromptFlag: (flag: keyof SettingsFlags, value: boolean) =>
+ setSettings({
+ ...settings,
+ flags: {
+ ...settings.flags,
+ prompt: {
+ ...settings.flags.prompt,
+ [flag]: value,
+ },
+ },
+ }),
setSettings,
applyThemeMode,
}),
diff --git a/packages/shared/src/graphql/actions.ts b/packages/shared/src/graphql/actions.ts
index 53f332fc21..8aa14f042f 100644
--- a/packages/shared/src/graphql/actions.ts
+++ b/packages/shared/src/graphql/actions.ts
@@ -39,6 +39,7 @@ export enum ActionType {
EditTag = 'edit_tag',
ContentTypes = 'content_types',
StreakTimezoneMismatch = 'streak_timezone_mismatch',
+ SmartPrompt = 'smart_prompt',
}
export interface Action {
diff --git a/packages/shared/src/graphql/prompt.ts b/packages/shared/src/graphql/prompt.ts
new file mode 100644
index 0000000000..94045154fd
--- /dev/null
+++ b/packages/shared/src/graphql/prompt.ts
@@ -0,0 +1,39 @@
+import { gql } from 'graphql-request';
+import type { ColorName } from '../styles/colors';
+
+export type PromptFlags = {
+ icon?: string;
+ color?: ColorName;
+};
+
+export type Prompt = {
+ id: string;
+ label: string;
+ prompt: string;
+ description?: string;
+ createdAt: Date;
+ updatedAt: Date;
+ flags?: PromptFlags;
+};
+
+export enum PromptDisplay {
+ TLDR = 'tldr',
+ UpgradeToPlus = 'upgrade-to-plus',
+ SmartPrompt = 'smart-prompt',
+ CustomPrompt = 'custom-prompt',
+}
+
+export const PROMPTS_QUERY = gql`
+ query Prompts {
+ prompts {
+ id
+ label
+ description
+ prompt
+ flags {
+ icon
+ color
+ }
+ }
+ }
+`;
diff --git a/packages/shared/src/graphql/search.ts b/packages/shared/src/graphql/search.ts
index df884f2b9b..6d5aa6709d 100644
--- a/packages/shared/src/graphql/search.ts
+++ b/packages/shared/src/graphql/search.ts
@@ -363,8 +363,16 @@ export const getSearchUrl = (params: SearchUrlParams): string => {
return `${searchUrl}${searchParamsString ? `?${searchParamsString}` : ''}`;
};
+export const smartPromptQueryUrl = `${apiUrl}/search/prompts`;
export const searchQueryUrl = `${apiUrl}/search/query`;
+export const sendPrompt = async (
+ params: URLSearchParams,
+ url?: string,
+): Promise => {
+ return new EventSource(`${url || searchQueryUrl}?${params}`);
+};
+
export const sendSearchQuery = async (
query: string,
token: string,
@@ -374,7 +382,25 @@ export const sendSearchQuery = async (
token,
});
- return new EventSource(`${searchQueryUrl}?${params}`);
+ return sendPrompt(params);
+};
+
+export const sendSmartPromptQuery = async ({
+ query,
+ token,
+ post,
+}: {
+ query: string;
+ token: string;
+ post: Post;
+}): Promise => {
+ const params = new URLSearchParams({
+ prompt: query,
+ token,
+ post_id: post.id,
+ });
+
+ return sendPrompt(params, smartPromptQueryUrl);
};
export type SearchSuggestion = {
diff --git a/packages/shared/src/graphql/settings.ts b/packages/shared/src/graphql/settings.ts
index 2ca50e2964..bb1f00a38e 100644
--- a/packages/shared/src/graphql/settings.ts
+++ b/packages/shared/src/graphql/settings.ts
@@ -17,6 +17,7 @@ export type SettingsFlags = {
sidebarBookmarksExpanded: boolean;
clickbaitShieldEnabled: boolean;
timezoneMismatchIgnore?: string;
+ prompt?: Record;
};
export enum SidebarSettingsFlags {
diff --git a/packages/shared/src/graphql/squads.ts b/packages/shared/src/graphql/squads.ts
index f971f7b183..9d1f0063aa 100644
--- a/packages/shared/src/graphql/squads.ts
+++ b/packages/shared/src/graphql/squads.ts
@@ -633,7 +633,7 @@ const SOURCE_POST_MODERATION_FRAGMENT = gql`
export const SQUAD_PENDING_POSTS_QUERY = gql`
query sourcePostModerations(
- $sourceId: ID!
+ $sourceId: ID
$status: [String]
$first: Int
$after: String
@@ -662,14 +662,12 @@ export const SQUAD_MODERATE_POST_MUTATION = gql`
mutation ModerateSourcePost(
$postIds: [ID]!
$status: String
- $sourceId: ID!
$rejectionReason: String
$moderatorMessage: String
) {
moderateSourcePosts(
postIds: $postIds
status: $status
- sourceId: $sourceId
rejectionReason: $rejectionReason
moderatorMessage: $moderatorMessage
) {
@@ -702,16 +700,14 @@ export interface SquadPostRejectionProps extends SquadPostModerationProps {
note?: string;
}
-export const squadApproveMutation = ({
- postIds,
- sourceId,
-}: SquadPostModerationProps): Promise => {
+export const squadApproveMutation = (
+ postIds: string[],
+): Promise => {
return gqlClient
.request<{
moderateSourcePosts: SourcePostModeration[];
}>(SQUAD_MODERATE_POST_MUTATION, {
postIds,
- sourceId,
status: SourcePostModerationStatus.Approved,
})
.then((res) => res.moderateSourcePosts);
@@ -719,7 +715,6 @@ export const squadApproveMutation = ({
export const squadRejectMutation = ({
postIds,
- sourceId,
reason,
note,
}: SquadPostRejectionProps): Promise => {
@@ -728,7 +723,6 @@ export const squadRejectMutation = ({
moderateSourcePosts: SourcePostModeration[];
}>(SQUAD_MODERATE_POST_MUTATION, {
postIds,
- sourceId,
status: SourcePostModerationStatus.Rejected,
rejectionReason: reason,
moderatorMessage: note,
diff --git a/packages/shared/src/hooks/auth/useOnboarding.tsx b/packages/shared/src/hooks/auth/useOnboarding.tsx
index 26d2e34570..95642ed147 100644
--- a/packages/shared/src/hooks/auth/useOnboarding.tsx
+++ b/packages/shared/src/hooks/auth/useOnboarding.tsx
@@ -33,7 +33,7 @@ export const useOnboarding = (): UseOnboarding => {
return {
shouldShowAuthBanner,
- isOnboardingReady: isActionsFetched && isAuthReady,
+ isOnboardingReady: isAuthReady && (isActionsFetched || !user),
hasCompletedEditTags,
hasCompletedContentTypes,
completeStep: (action: ActionType) => completeAction(action),
diff --git a/packages/shared/src/hooks/chat/types.ts b/packages/shared/src/hooks/chat/types.ts
index b48c4f53e5..d285de10d9 100644
--- a/packages/shared/src/hooks/chat/types.ts
+++ b/packages/shared/src/hooks/chat/types.ts
@@ -1,5 +1,4 @@
import type { QueryKey } from '@tanstack/react-query';
-import type { MouseEvent } from 'react';
import type { Search, SearchChunkSource } from '../../graphql/search';
export interface UseChatProps {
@@ -32,7 +31,7 @@ export interface UseChat {
queryKey: QueryKey;
data: Search;
isLoading: boolean;
- handleSubmit(event: MouseEvent, value: string): void;
+ handleSubmit(prompt: string, event?: MouseEvent): Promise;
}
export interface CreatePayload {
diff --git a/packages/shared/src/hooks/chat/useChatSession.ts b/packages/shared/src/hooks/chat/useChatSession.ts
index eaceac35cf..fa0d1a70a4 100644
--- a/packages/shared/src/hooks/chat/useChatSession.ts
+++ b/packages/shared/src/hooks/chat/useChatSession.ts
@@ -3,7 +3,7 @@ import { useQueryClient, useQuery } from '@tanstack/react-query';
import { useAuthContext } from '../../contexts/AuthContext';
import type { Search } from '../../graphql/search';
import { getSearchSession } from '../../graphql/search';
-import { generateQueryKey, RequestKey } from '../../lib/query';
+import { generateQueryKey, RequestKey, StaleTime } from '../../lib/query';
import type { UseChatSessionProps, UseChatSession } from './types';
export const useChatSession = ({
@@ -27,6 +27,7 @@ export const useChatSession = ({
return getSearchSession(id);
},
enabled: !!id,
+ staleTime: StaleTime.OneHour,
});
return {
diff --git a/packages/shared/src/hooks/chat/useChatStream.ts b/packages/shared/src/hooks/chat/useChatStream.ts
index 13bc0bedfc..149a230c7b 100644
--- a/packages/shared/src/hooks/chat/useChatStream.ts
+++ b/packages/shared/src/hooks/chat/useChatStream.ts
@@ -33,12 +33,15 @@ export const useChatStream = (): UseChatStream => {
const [sessionId, setSessionId] = useState(null);
const executePrompt = useCallback(
- async (value: string) => {
- if (!value) {
+ async (prompt: string) => {
+ if (!prompt) {
return;
}
- if (sourceRef.current?.OPEN) {
+ if (
+ sourceRef.current &&
+ sourceRef.current?.readyState === sourceRef.current?.OPEN
+ ) {
sourceRef.current.close();
}
@@ -87,7 +90,7 @@ export const useChatStream = (): UseChatStream => {
...payload,
createdAt: new Date(),
status: data.status,
- prompt: value,
+ prompt,
}),
);
@@ -164,7 +167,7 @@ export const useChatStream = (): UseChatStream => {
logErrorEvent(code);
};
- const source = await sendSearchQuery(value, accessToken?.token);
+ const source = await sendSearchQuery(prompt, accessToken?.token);
source.addEventListener('message', onMessage);
source.addEventListener('error', onError);
sourceRef.current = source;
@@ -174,7 +177,10 @@ export const useChatStream = (): UseChatStream => {
useEffect(() => {
return () => {
- if (sourceRef.current?.OPEN) {
+ if (
+ sourceRef.current &&
+ sourceRef.current?.readyState === sourceRef.current?.OPEN
+ ) {
sourceRef.current.close();
}
};
@@ -182,11 +188,6 @@ export const useChatStream = (): UseChatStream => {
return {
id: sessionId,
- handleSubmit: useCallback(
- (_, value: string) => {
- executePrompt(value);
- },
- [executePrompt],
- ),
+ handleSubmit: executePrompt,
};
};
diff --git a/packages/shared/src/hooks/input/useMarkdownInput.ts b/packages/shared/src/hooks/input/useMarkdownInput.ts
index 34966d9e6e..59c53e516b 100644
--- a/packages/shared/src/hooks/input/useMarkdownInput.ts
+++ b/packages/shared/src/hooks/input/useMarkdownInput.ts
@@ -351,9 +351,13 @@ export const useMarkdownInput = ({
const onPaste: ClipboardEventHandler = async (e) => {
const pastedText = e.clipboardData.getData('text');
if (isValidHttpUrl(pastedText)) {
- e.preventDefault();
- await onLinkPaste(pastedText);
- return;
+ const cursor = getCursorType(textarea);
+
+ if (cursor === CursorType.Highlighted) {
+ e.preventDefault();
+ await onLinkPaste(pastedText);
+ return;
+ }
}
if (e.clipboardData.files?.length && isUploadEnabled) {
diff --git a/packages/shared/src/hooks/prompt/usePromptButtons.ts b/packages/shared/src/hooks/prompt/usePromptButtons.ts
new file mode 100644
index 0000000000..a45481e4cb
--- /dev/null
+++ b/packages/shared/src/hooks/prompt/usePromptButtons.ts
@@ -0,0 +1,63 @@
+import { useMemo } from 'react';
+import type { Prompt } from '../../graphql/prompt';
+
+interface UseFeedTags {
+ prompts: Prompt[];
+ width: number;
+ base?: number;
+ offset?: number;
+ showAll?: boolean;
+}
+
+const basePadding = 25;
+const char = 8;
+const gap = 8;
+const iconSize = 24;
+
+export const usePromptButtons = ({
+ base = basePadding,
+ prompts,
+ width,
+ offset = 0,
+ showAll = false,
+}: UseFeedTags): Prompt[] => {
+ return useMemo(() => {
+ if (showAll) {
+ return prompts;
+ }
+
+ if (!prompts?.length || width === 0) {
+ return [];
+ }
+
+ let totalLength = offset;
+
+ return prompts.reduce((items, tag, index) => {
+ const baseWidth = base + gap;
+ const minWidth = index === 0 ? base : baseWidth;
+ const addition = tag.label.length * char + minWidth + iconSize;
+ const remaining = prompts.length - (items.length + 1); // the value 1 is for the tag we are about to add here
+
+ totalLength += addition;
+
+ if (remaining === 0) {
+ if (totalLength <= width) {
+ items.push(tag);
+ }
+
+ return items;
+ }
+
+ const remainingChars = remaining.toString().length * char + iconSize;
+ const remainingWidth = baseWidth + remainingChars;
+
+ if (totalLength + remainingWidth > width) {
+ return items;
+ }
+
+ items.push(tag);
+
+ return items;
+ }, []);
+ }, [showAll, prompts, width, offset, base]);
+};
diff --git a/packages/shared/src/hooks/prompt/usePromptsQuery.ts b/packages/shared/src/hooks/prompt/usePromptsQuery.ts
new file mode 100644
index 0000000000..1e775e955a
--- /dev/null
+++ b/packages/shared/src/hooks/prompt/usePromptsQuery.ts
@@ -0,0 +1,40 @@
+import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
+import { useQuery } from '@tanstack/react-query';
+import { generateQueryKey, RequestKey, StaleTime } from '../../lib/query';
+import { gqlClient } from '../../graphql/common';
+import { useAuthContext } from '../../contexts/AuthContext';
+import type { Prompt } from '../../graphql/prompt';
+import { PROMPTS_QUERY } from '../../graphql/prompt';
+
+type UsePromptsQueryProps = {
+ queryOptions?: Partial>;
+};
+
+type UsePromptsQuery = UseQueryResult;
+
+export const usePromptsQuery = ({
+ queryOptions,
+}: UsePromptsQueryProps = {}): UsePromptsQuery => {
+ const { user } = useAuthContext();
+ const enabled = !!user;
+
+ const queryResult = useQuery({
+ queryKey: generateQueryKey(RequestKey.Prompts, user),
+
+ queryFn: async () => {
+ const result = await gqlClient.request<{
+ prompts: Prompt[];
+ }>(PROMPTS_QUERY);
+
+ return result.prompts;
+ },
+ staleTime: StaleTime.OneHour,
+ ...queryOptions,
+ enabled:
+ typeof queryOptions?.enabled !== 'undefined'
+ ? queryOptions.enabled && enabled
+ : enabled,
+ });
+
+ return queryResult;
+};
diff --git a/packages/shared/src/hooks/prompt/useSmartPrompt.ts b/packages/shared/src/hooks/prompt/useSmartPrompt.ts
new file mode 100644
index 0000000000..b26cf2d957
--- /dev/null
+++ b/packages/shared/src/hooks/prompt/useSmartPrompt.ts
@@ -0,0 +1,149 @@
+import { useCallback, useEffect, useMemo, useRef } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { useAuthContext } from '../../contexts/AuthContext';
+import { generateQueryKey, RequestKey, StaleTime } from '../../lib/query';
+import type { Post } from '../../graphql/posts';
+import type { Prompt } from '../../graphql/prompt';
+import type {
+ Search,
+ SearchChunk,
+ SearchChunkError,
+} from '../../graphql/search';
+import {
+ initializeSearchSession,
+ searchErrorCodeToMessage,
+ sendSmartPromptQuery,
+ updateSearchData,
+} from '../../graphql/search';
+import type { CreatePayload, TokenPayload, UseChatMessage } from '../chat';
+import { UseChatMessageType } from '../chat';
+
+export const useSmartPrompt = ({
+ post,
+ prompt,
+}: {
+ post: Post;
+ prompt: Prompt;
+}): {
+ executePrompt: (value: string) => Promise;
+ data: Search;
+ isPending: boolean;
+} => {
+ const { user, accessToken } = useAuthContext();
+ const client = useQueryClient();
+ const sourceRef = useRef();
+
+ const queryKey = useMemo(
+ () => generateQueryKey(RequestKey.Prompts, user, post.id, prompt.id),
+ [post.id, prompt.id, user],
+ );
+
+ const { data, isPending } = useQuery({
+ queryKey,
+ enabled: !!prompt.prompt,
+ staleTime: StaleTime.OneHour,
+ });
+
+ const executePrompt = useCallback(
+ async (value: string) => {
+ if (!value) {
+ return;
+ }
+
+ if (
+ sourceRef.current &&
+ sourceRef.current?.readyState === sourceRef.current?.OPEN
+ ) {
+ sourceRef.current.close();
+ }
+
+ const setSearchQuery = (chunk: Partial) => {
+ client.setQueryData(queryKey, (previous) =>
+ updateSearchData(previous, chunk),
+ );
+ };
+
+ const onMessage = (event: MessageEvent) => {
+ const messageData: UseChatMessage = JSON.parse(event.data);
+
+ switch (messageData.type) {
+ case UseChatMessageType.SessionCreated: {
+ const payload = messageData.payload as CreatePayload;
+
+ client.setQueryData(
+ queryKey,
+ initializeSearchSession({
+ ...payload,
+ createdAt: new Date(),
+ status: messageData.status,
+ prompt: value,
+ }),
+ );
+
+ break;
+ }
+ case UseChatMessageType.StatusUpdated:
+ setSearchQuery({ status: messageData.status });
+ break;
+ case UseChatMessageType.NewTokenReceived:
+ setSearchQuery({
+ response: (messageData.payload as TokenPayload).token,
+ });
+ break;
+ case UseChatMessageType.Completed: {
+ setSearchQuery({ completedAt: new Date() });
+ sourceRef.current?.close();
+ break;
+ }
+ case UseChatMessageType.Error: {
+ const errorPayload = messageData.payload as SearchChunkError;
+ const message =
+ errorPayload.message ||
+ searchErrorCodeToMessage[errorPayload.code];
+
+ setSearchQuery({
+ error: { ...errorPayload, message },
+ progress: -1,
+ });
+
+ sourceRef.current?.close();
+ break;
+ }
+ case UseChatMessageType.SessionFound:
+ client.setQueryData(queryKey, messageData.payload as Search);
+ sourceRef.current?.close();
+ break;
+ default:
+ break;
+ }
+ };
+
+ const source = await sendSmartPromptQuery({
+ query: value,
+ token: accessToken?.token,
+ post,
+ });
+ source.addEventListener('message', onMessage);
+ sourceRef.current = source;
+ },
+ [accessToken?.token, client, post, queryKey],
+ );
+
+ useEffect(() => {
+ return () => {
+ if (
+ sourceRef.current &&
+ sourceRef.current?.readyState === sourceRef.current?.OPEN
+ ) {
+ client.resetQueries({ queryKey, exact: true });
+ sourceRef.current.close();
+ }
+ };
+ }, [client, queryKey]);
+
+ return {
+ executePrompt,
+ data,
+ isPending,
+ };
+};
diff --git a/packages/shared/src/hooks/squads/useSourceModerationList.ts b/packages/shared/src/hooks/squads/useSourceModerationList.ts
index 3a67c50d20..4ac0c55fa0 100644
--- a/packages/shared/src/hooks/squads/useSourceModerationList.ts
+++ b/packages/shared/src/hooks/squads/useSourceModerationList.ts
@@ -24,6 +24,7 @@ import { useLogContext } from '../../contexts/LogContext';
import { postLogEvent } from '../../lib/feed';
import type { Post } from '../../graphql/posts';
import type { Connection } from '../../graphql/common';
+import { useAuthContext } from '../../contexts/AuthContext';
export const rejectReasons: { value: PostModerationReason; label: string }[] = [
{
@@ -62,12 +63,12 @@ export const rejectReasons: { value: PostModerationReason; label: string }[] = [
export interface UseSourceModerationList {
onApprove: (
ids: string[],
- sourceId: string,
+ sourceId?: string,
onSuccess?: MouseEventHandler,
) => Promise;
onReject: (
id: string,
- sourceId: string,
+ sourceId?: string,
onSuccess?: MouseEventHandler,
) => void;
onDelete: (postId: string) => Promise;
@@ -90,20 +91,20 @@ const getLogPostsFromModerationArray = (data: SourcePostModeration[]) => {
export const useSourceModerationList = ({
squad,
}: {
- squad: Squad;
+ squad?: Squad;
}): UseSourceModerationList => {
const { openModal, closeModal } = useLazyModal();
const { displayToast } = useToastNotification();
const { showPrompt } = usePrompt();
const { logEvent } = useLogContext();
- const { user } = squad.currentMember;
+ const { user } = useAuthContext();
const queryClient = useQueryClient();
const listQueryKey = generateQueryKey(
RequestKey.SquadPostRequests,
user,
- squad.id,
+ squad?.id,
);
- const squadQueryKey = generateQueryKey(RequestKey.Squad, user, squad.handle);
+ const squadQueryKey = generateQueryKey(RequestKey.Squad, user, squad?.handle);
const handleOptimistic = useCallback(
(data: SquadPostModerationProps) => {
@@ -112,14 +113,18 @@ export const useSourceModerationList = ({
InfiniteData>
>(listQueryKey);
- const currentSquad = queryClient.getQueryData(squadQueryKey);
- queryClient.setQueryData(squadQueryKey, (sqd) => {
- return {
- ...sqd,
- moderationPostCount:
- currentSquad.moderationPostCount - data.postIds.length,
- };
- });
+ const currentSquad = queryClient.getQueryData(
+ squadQueryKey,
+ );
+ if (currentSquad) {
+ queryClient.setQueryData(squadQueryKey, (sqd) => {
+ return {
+ ...sqd,
+ moderationPostCount:
+ currentSquad.moderationPostCount - data.postIds.length,
+ };
+ });
+ }
queryClient.setQueryData>>(
listQueryKey,
@@ -149,11 +154,8 @@ export const useSourceModerationList = ({
isPending: isPendingApprove,
isSuccess: isSuccessApprove,
} = useMutation({
- mutationFn: ({ postIds, sourceId }: SquadPostModerationProps) =>
- squadApproveMutation({
- postIds,
- sourceId,
- }),
+ mutationFn: ({ postIds }: SquadPostModerationProps) =>
+ squadApproveMutation(postIds),
onMutate: (data) => handleOptimistic(data),
onSuccess: (data) => {
displayToast('Post(s) approved successfully');
@@ -168,8 +170,8 @@ export const useSourceModerationList = ({
);
return;
}
- queryClient.setQueryData(listQueryKey, context.currentData);
- queryClient.setQueryData(squadQueryKey, context.currentSquad);
+ queryClient.setQueryData(listQueryKey, context?.currentData);
+ queryClient.setQueryData(squadQueryKey, context?.currentSquad);
displayToast('Failed to approve post(s)');
},
});
diff --git a/packages/shared/src/hooks/squads/useSquadPendingPosts.ts b/packages/shared/src/hooks/squads/useSquadPendingPosts.ts
index fd44d7d3af..a370b60a2d 100644
--- a/packages/shared/src/hooks/squads/useSquadPendingPosts.ts
+++ b/packages/shared/src/hooks/squads/useSquadPendingPosts.ts
@@ -1,4 +1,4 @@
-import { useCallback } from 'react';
+import { useCallback, useMemo } from 'react';
import type {
InfiniteData,
UseInfiniteQueryResult,
@@ -8,6 +8,7 @@ import type { SourcePostModeration } from '../../graphql/squads';
import {
SourcePostModerationStatus,
SQUAD_PENDING_POSTS_QUERY,
+ verifyPermission,
} from '../../graphql/squads';
import {
generateQueryKey,
@@ -17,18 +18,32 @@ import {
import { useAuthContext } from '../../contexts/AuthContext';
import type { Connection } from '../../graphql/common';
import { gqlClient } from '../../graphql/common';
+import { SourcePermissions } from '../../graphql/sources';
type UseSquadPendingPosts = UseInfiniteQueryResult<
InfiniteData>
->;
+> & {
+ count: number;
+ isModeratorInAnySquad: boolean;
+};
-export const useSquadPendingPosts = (
- squadId: string,
- status: SourcePostModerationStatus[] = [SourcePostModerationStatus.Pending],
-): UseSquadPendingPosts => {
- const { user } = useAuthContext();
+export const useSquadPendingPosts = ({
+ squadId,
+ status = [SourcePostModerationStatus.Pending],
+ enabled = true,
+}: {
+ squadId?: string;
+ status?: SourcePostModerationStatus[];
+ enabled?: boolean;
+} = {}): UseSquadPendingPosts => {
+ const { user, squads } = useAuthContext();
+ const isModeratorInAnySquad = useMemo(() => {
+ return squads?.some((squad) =>
+ verifyPermission(squad, SourcePermissions.ModeratePost),
+ );
+ }, [squads]);
- return useInfiniteQuery>({
+ const query = useInfiniteQuery>({
queryKey: generateQueryKey(RequestKey.SquadPostRequests, user, squadId),
queryFn: async ({ pageParam }) => {
return gqlClient
@@ -43,7 +58,8 @@ export const useSquadPendingPosts = (
},
initialPageParam: '',
getNextPageParam: (lastPage) => getNextPageParam(lastPage?.pageInfo),
- enabled: !!squadId,
+ enabled: enabled && (Boolean(squadId) || isModeratorInAnySquad),
+
select: useCallback((res) => {
if (!res) {
return undefined;
@@ -56,4 +72,10 @@ export const useSquadPendingPosts = (
};
}, []),
});
+
+ return {
+ ...query,
+ count: query?.data?.pages.flatMap((page) => page.edges)?.length || 0,
+ isModeratorInAnySquad,
+ };
};
diff --git a/packages/shared/src/lib/constants.ts b/packages/shared/src/lib/constants.ts
index cb980697e9..6493904193 100644
--- a/packages/shared/src/lib/constants.ts
+++ b/packages/shared/src/lib/constants.ts
@@ -141,3 +141,5 @@ export const invalidPlusRegions = [
];
export const DeletedPostId = '404';
+
+export const BROADCAST_CHANNEL_NAME = 'dailydev_broadcast';
diff --git a/packages/shared/src/lib/func.ts b/packages/shared/src/lib/func.ts
index c3e875fd62..40c2ca00c8 100644
--- a/packages/shared/src/lib/func.ts
+++ b/packages/shared/src/lib/func.ts
@@ -1,7 +1,7 @@
import type { MouseEvent } from 'react';
import type ReactModal from 'react-modal';
import type { EmptyObjectLiteral } from './kratos';
-import { isBrave, isTesting } from './constants';
+import { BROADCAST_CHANNEL_NAME, isBrave, isTesting } from './constants';
export type EmptyFunction = () => void;
export type EmptyPromise = () => Promise;
@@ -199,3 +199,17 @@ export const isMobile = (): boolean =>
export const shouldUseNativeShare = (): boolean =>
'share' in globalThis?.navigator && isMobile();
+
+interface BroadcastMessage {
+ eventKey: string;
+ [key: string]: unknown;
+}
+
+export const broadcastMessage = (
+ message: BroadcastMessage,
+ channelName: string = BROADCAST_CHANNEL_NAME,
+): void => {
+ const channel = new BroadcastChannel(channelName);
+ channel.postMessage(message);
+ channel.close();
+};
diff --git a/packages/shared/src/lib/image.ts b/packages/shared/src/lib/image.ts
index e35fcce7c0..69fcd3a84b 100644
--- a/packages/shared/src/lib/image.ts
+++ b/packages/shared/src/lib/image.ts
@@ -330,6 +330,8 @@ export const cloudinaryAndroidPWA =
export const cloudinaryAndroidPWAVideo =
'https://daily-now-res.cloudinary.com/video/upload/s--2P1N5kYQ--/v1737634404/android_-_chrome_gvepd5.mp4';
+export const smartPromptModalImage =
+ 'https://daily-now-res.cloudinary.com/image/upload/s--JVcr0aup--/f_auto/v1736060940/Streak_together_with_a_friend_glfbco';
export const smallPostImage = (url: string): string => {
if (!url) {
return cloudinaryPostImageCoverPlaceholder;
diff --git a/packages/shared/src/lib/labels.ts b/packages/shared/src/lib/labels.ts
index 19c32f9119..45ebfeb980 100644
--- a/packages/shared/src/lib/labels.ts
+++ b/packages/shared/src/lib/labels.ts
@@ -86,6 +86,7 @@ export const labels = {
globalPreferenceNotice: {
clickbaitShield: 'Clickbait shield has been applied for all feeds',
contentLanguage: 'New language preferences set for all feeds',
+ smartPrompt: 'Smart Prompt setting has been applied for all feeds',
},
},
},
diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts
index 974e1b57a8..d84db50f5b 100644
--- a/packages/shared/src/lib/log.ts
+++ b/packages/shared/src/lib/log.ts
@@ -239,6 +239,10 @@ export enum LogEvent {
ShareSource = 'share source',
ShareTag = 'share tag',
// End Share
+ // Start Smart Prompts
+ SmartPrompt = 'smart prompt',
+ ToggleSmartPrompts = 'toggle smart prompts',
+ // End Smart Prompts
}
export enum FeedItemTitle {
@@ -320,6 +324,7 @@ export enum TargetId {
StreakTimezoneLabel = 'streak timezone label',
StreakTimezoneMismatchPrompt = 'streak timezone mismatch prompt',
ContextMenu = 'context',
+ SmartPrompt = 'smart prompt',
}
export enum NotificationChannel {
diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts
index 31ada16e2e..6d3bc14f26 100644
--- a/packages/shared/src/lib/query.ts
+++ b/packages/shared/src/lib/query.ts
@@ -194,6 +194,7 @@ export enum RequestKey {
BookmarkFolders = 'bookmark_folders',
FetchedOriginalTitle = 'fetched_original_title',
GifterUser = 'gifter_user',
+ Prompts = 'smart_prompts',
}
export const getPostByIdKey = (id: string): QueryKey => [RequestKey.Post, id];
diff --git a/packages/webapp/__tests__/PostPage.tsx b/packages/webapp/__tests__/PostPage.tsx
index 2453025ddd..9f5a1b540c 100644
--- a/packages/webapp/__tests__/PostPage.tsx
+++ b/packages/webapp/__tests__/PostPage.tsx
@@ -652,8 +652,9 @@ it('should show TLDR when there is a summary', async () => {
createPostMock({ summary: 'test summary' }),
completeActionMock({ action: ActionType.BookmarkPost }),
]);
- const el = await screen.findByText('TLDR');
+ const el = await screen.findByTestId('tldr-container');
expect(el).toBeInTheDocument();
+ expect(el).toHaveTextContent('test summary');
// eslint-disable-next-line testing-library/no-node-access, testing-library/prefer-screen-queries
const link = queryByText(el.parentElement, 'Show more');
expect(link).not.toBeInTheDocument();
@@ -667,7 +668,7 @@ it('should toggle TLDR on click', async () => {
}),
completeActionMock({ action: ActionType.BookmarkPost }),
]);
- const el = await screen.findByText('TLDR');
+ const el = await screen.findByTestId('tldr-container');
expect(el).toBeInTheDocument();
// eslint-disable-next-line testing-library/no-node-access, testing-library/prefer-screen-queries
const showMoreLink = queryByText(el.parentElement, 'Show more');
@@ -684,7 +685,7 @@ it('should not show Show more link when there is a summary without reaching thre
'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores',
}),
]);
- const el = await screen.findByText('TLDR');
+ const el = await screen.findByTestId('tldr-container');
expect(el).toBeInTheDocument();
// eslint-disable-next-line testing-library/no-node-access, testing-library/prefer-screen-queries
const link = queryByText(el.parentElement, 'Show more');
@@ -699,7 +700,7 @@ it('should not cut summary when there is a summary without reaching threshold',
summary: summaryText,
}),
]);
- const el = await screen.findByText('TLDR');
+ const el = await screen.findByTestId('tldr-container');
expect(el).toBeInTheDocument();
const fullSummary = await screen.findByText(summaryText);
expect(fullSummary).toBeInTheDocument();
diff --git a/packages/webapp/components/layouts/AccountLayout/SidebarNav.tsx b/packages/webapp/components/layouts/AccountLayout/SidebarNav.tsx
index 1dfac0c0c6..eb8cf72582 100644
--- a/packages/webapp/components/layouts/AccountLayout/SidebarNav.tsx
+++ b/packages/webapp/components/layouts/AccountLayout/SidebarNav.tsx
@@ -1,20 +1,15 @@
import type { ReactElement } from 'react';
-import React, { useCallback, useContext, useEffect, useMemo } from 'react';
+import React, { useCallback, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import classNames from 'classnames';
-import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext';
+import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext';
import CloseButton from '@dailydotdev/shared/src/components/CloseButton';
import { disabledRefetch } from '@dailydotdev/shared/src/lib/func';
import { useRouter } from 'next/router';
import { isTouchDevice } from '@dailydotdev/shared/src/lib/tooltip';
-import Link from '@dailydotdev/shared/src/components/utilities/Link';
import SidebarNavItem from './SidebarNavItem';
-import {
- AccountPage,
- accountPage,
- accountSidebarPages,
- AccountSidebarPagesSection,
-} from './common';
+import type { AccountPage } from './common';
+import { accountPage } from './common';
interface SidebarNavProps {
className?: string;
@@ -38,19 +33,7 @@ function SidebarNav({
queryFn: () => false,
...disabledRefetch,
});
- const { user, isGdprCovered } = useContext(AuthContext);
-
- const keys = useMemo(
- () =>
- pageKeys.filter((key) => {
- if (key === AccountPage.Privacy) {
- return isGdprCovered;
- }
-
- return true;
- }),
- [isGdprCovered],
- );
+ const { user } = useAuthContext();
useEffect(() => {
if (!isTouchDevice()) {
@@ -84,7 +67,7 @@ function SidebarNav({
- {keys.map((key) => {
+ {pageKeys.map((key) => {
const href = `/${basePath}${accountPage[key].href}`;
const isActive = globalThis?.window?.location.pathname === href;
@@ -98,24 +81,6 @@ function SidebarNav({
/>
);
})}
- {!isGdprCovered && (
-
- {accountSidebarPages.map((accountSidebarPage) => (
-
-
- {accountSidebarPage.title}
-
-
- ))}
-
- )}
);
diff --git a/packages/webapp/components/layouts/AccountLayout/common.tsx b/packages/webapp/components/layouts/AccountLayout/common.tsx
index a0da6eb8e1..86e7fa4635 100644
--- a/packages/webapp/components/layouts/AccountLayout/common.tsx
+++ b/packages/webapp/components/layouts/AccountLayout/common.tsx
@@ -15,13 +15,6 @@ import classed from '@dailydotdev/shared/src/lib/classed';
import type { LoggedUser } from '@dailydotdev/shared/src/lib/user';
import type { ReactNode } from 'react';
import React from 'react';
-import {
- faq,
- privacyPolicy,
- reportIssue,
- requestFeature,
- termsOfService,
-} from '@dailydotdev/shared/src/lib/constants';
export interface ManageSocialProvidersProps {
type: ManageSocialProviderTypes;
@@ -58,39 +51,6 @@ export enum AccountSecurityDisplay {
ConnectEmail = 'connect_email',
}
-interface AccountSidebarPage {
- title: string;
- href: string;
- target: string;
-}
-export const accountSidebarPages: AccountSidebarPage[] = [
- {
- title: 'FAQ',
- href: faq,
- target: '_blank',
- },
- {
- title: 'Request a feature',
- href: requestFeature,
- target: '_blank',
- },
- {
- title: 'Report an issue',
- href: reportIssue,
- target: '_blank',
- },
- {
- title: 'Privacy policy',
- href: privacyPolicy,
- target: '_blank',
- },
- {
- title: 'Terms of service',
- href: termsOfService,
- target: '_blank',
- },
-];
-
export const accountPage: Record
= {
profile: {
title: 'Profile',
diff --git a/packages/webapp/components/search/SearchChatPage.tsx b/packages/webapp/components/search/SearchChatPage.tsx
index 79889df8b6..c1f8265504 100644
--- a/packages/webapp/components/search/SearchChatPage.tsx
+++ b/packages/webapp/components/search/SearchChatPage.tsx
@@ -56,7 +56,7 @@ const SearchPage = (): ReactElement => {
return;
}
- handleSubmit(undefined, query);
+ handleSubmit(query);
}, [sessionIdQuery, query, handleSubmit]);
const seo: NextSeoProps = {
diff --git a/packages/webapp/pages/callback.tsx b/packages/webapp/pages/callback.tsx
index 9de9e63f89..86d39a286c 100644
--- a/packages/webapp/pages/callback.tsx
+++ b/packages/webapp/pages/callback.tsx
@@ -1,4 +1,7 @@
-import { postWindowMessage } from '@dailydotdev/shared/src/lib/func';
+import {
+ broadcastMessage,
+ postWindowMessage,
+} from '@dailydotdev/shared/src/lib/func';
import { AuthEvent } from '@dailydotdev/shared/src/lib/kratos';
import type { ReactElement } from 'react';
import { useContext, useEffect } from 'react';
@@ -23,6 +26,7 @@ function CallbackPage(): ReactElement {
return;
}
postWindowMessage(eventKey, params);
+ broadcastMessage({ ...params, eventKey });
window.close();
} catch (err) {
const url = `${process.env.NEXT_PUBLIC_WEBAPP_URL}?${search}`;
diff --git a/packages/webapp/pages/onboarding.tsx b/packages/webapp/pages/onboarding.tsx
index f9c7f927ad..a0a05df532 100644
--- a/packages/webapp/pages/onboarding.tsx
+++ b/packages/webapp/pages/onboarding.tsx
@@ -150,7 +150,6 @@ const seo: NextSeoProps = {
};
export function OnboardPage(): ReactElement {
- const params = new URLSearchParams(window.location.search);
const { isAvailable: canUserInstallPWA } = useInstallPWA();
const {
isOnboardingReady,
@@ -225,31 +224,44 @@ export function OnboardPage(): ReactElement {
].includes(activeScreen);
useEffect(() => {
- if (!isPageReady || isLogged.current || !isOnboardingReady) {
+ if (
+ !isPageReady ||
+ isLogged.current ||
+ !isOnboardingReady ||
+ !user?.infoConfirmed
+ ) {
return;
}
- if (user?.infoConfirmed && !hasCompletedEditTags) {
+ isLogged.current = true;
+
+ if (!hasCompletedEditTags) {
setActiveScreen(OnboardingStep.EditTag);
return;
}
- if (user?.infoConfirmed && !hasCompletedContentTypes) {
+ if (!hasCompletedContentTypes) {
setActiveScreen(OnboardingStep.ContentTypes);
return;
}
- if (user?.infoConfirmed && activeScreen === OnboardingStep.Intro) {
+ if (activeScreen === OnboardingStep.Intro) {
+ const params = new URLSearchParams(window.location.search);
const afterAuth = params.get(AFTER_AUTH_PARAM);
params.delete(AFTER_AUTH_PARAM);
router.replace(getPathnameWithQuery(afterAuth || webappUrl, params));
- return;
}
- isLogged.current = true;
// @NOTE see https://dailydotdev.atlassian.net/l/cp/dK9h1zoM
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isPageReady, user, isOnboardingReady]);
+ }, [
+ isPageReady,
+ user,
+ isOnboardingReady,
+ hasCompletedEditTags,
+ hasCompletedContentTypes,
+ activeScreen,
+ ]);
const onClickNext: OnboardingOnClickNext = (options) => {
logEvent({
@@ -346,6 +358,7 @@ export function OnboardPage(): ReactElement {
: LogEvent.OnboardingSkip,
});
+ const params = new URLSearchParams(window.location.search);
const afterAuth = params.get(AFTER_AUTH_PARAM);
return router.replace({
pathname: afterAuth || '/',
diff --git a/packages/webapp/pages/squads/[handle]/edit.tsx b/packages/webapp/pages/squads/[handle]/edit.tsx
index 085290e429..2113f18b46 100644
--- a/packages/webapp/pages/squads/[handle]/edit.tsx
+++ b/packages/webapp/pages/squads/[handle]/edit.tsx
@@ -103,7 +103,7 @@ const EditSquad = ({ handle }: SquadSettingsProps): ReactElement => {
isLoading={isUpdatingSquad}
>
{squad?.moderationRequired && (
-
+
)}
diff --git a/packages/webapp/pages/squads/[handle]/moderate.tsx b/packages/webapp/pages/squads/[handle]/moderate.tsx
index e7ae7d779b..3a5d959222 100644
--- a/packages/webapp/pages/squads/[handle]/moderate.tsx
+++ b/packages/webapp/pages/squads/[handle]/moderate.tsx
@@ -1,95 +1,21 @@
import type { ReactElement } from 'react';
-import React, { useEffect } from 'react';
-import type { SquadSettingsProps } from '@dailydotdev/shared/src/components/squads/utils';
-import { ManageSquadPageContainer } from '@dailydotdev/shared/src/components/squads/utils';
-import {
- SquadTab,
- SquadTabs,
-} from '@dailydotdev/shared/src/components/squads/SquadTabs';
-import { SquadModerationList } from '@dailydotdev/shared/src/components/squads/moderation/SquadModerationList';
-import {
- PageHeader,
- PageHeaderTitle,
-} from '@dailydotdev/shared/src/components/layout/common';
-import {
- Button,
- ButtonVariant,
-} from '@dailydotdev/shared/src/components/buttons/Button';
-import { ArrowIcon } from '@dailydotdev/shared/src/components/icons';
-import { useSquad } from '@dailydotdev/shared/src/hooks';
-import { useRouter } from 'next/router';
-import type {
- GetStaticPathsResult,
- GetStaticPropsContext,
- GetStaticPropsResult,
-} from 'next';
-import type { ParsedUrlQuery } from 'querystring';
-import { verifyPermission } from '@dailydotdev/shared/src/graphql/squads';
-import { SourcePermissions } from '@dailydotdev/shared/src/graphql/sources';
-import { TypographyType } from '@dailydotdev/shared/src/components/typography/Typography';
-import { getLayout as getMainLayout } from '../../../components/layouts/MainLayout';
+import React from 'react';
+import type { GetServerSidePropsResult } from 'next';
+import { webappUrl } from '@dailydotdev/shared/src/lib/constants';
-export default function ModerateSquadPage({
- handle,
-}: SquadSettingsProps): ReactElement {
- const router = useRouter();
- const { squad, isLoading, isFetched } = useSquad({ handle });
- const isModerator = verifyPermission(squad, SourcePermissions.ModeratePost);
+const Page = (): ReactElement => {
+ return <>>;
+};
- useEffect(() => {
- if (isLoading || !isFetched) {
- return;
- }
-
- if (!squad) {
- router.push(`/404`);
- return;
- }
-
- if (!squad.moderationRequired) {
- router.push(`/squads/${handle}`);
- }
- }, [handle, isFetched, isLoading, router, squad]);
-
- if (isLoading || !squad) {
- return null;
- }
-
- return (
-
-
- }
- tag="a"
- variant={ButtonVariant.Tertiary}
- />
-
- {isModerator ? 'Squad settings' : 'Pending posts'}
-
-
-
-
-
- );
-}
-
-export async function getStaticPaths(): Promise {
- return { paths: [], fallback: true };
-}
-
-interface SquadPageParams extends ParsedUrlQuery {
- handle: string;
-}
-
-export function getStaticProps({
- params,
-}: GetStaticPropsContext): GetStaticPropsResult {
+export async function getServerSideProps(): Promise<
+ GetServerSidePropsResult
+> {
return {
- props: {
- handle: params.handle,
+ redirect: {
+ destination: `${webappUrl}squads/moderate`,
+ permanent: true,
},
};
}
-ModerateSquadPage.getLayout = getMainLayout;
+export default Page;
diff --git a/packages/webapp/pages/squads/discover/my.tsx b/packages/webapp/pages/squads/discover/my.tsx
index 68cc919055..f5ecf8083f 100644
--- a/packages/webapp/pages/squads/discover/my.tsx
+++ b/packages/webapp/pages/squads/discover/my.tsx
@@ -3,24 +3,58 @@ import React from 'react';
import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext';
import { SquadList } from '@dailydotdev/shared/src/components/cards/squad/SquadList';
import { useRouter } from 'next/router';
-import { squadCategoriesPaths } from '@dailydotdev/shared/src/lib/constants';
+import {
+ squadCategoriesPaths,
+ webappUrl,
+} from '@dailydotdev/shared/src/lib/constants';
import type { NextSeoProps } from 'next-seo';
+
+import { useSquadPendingPosts } from '@dailydotdev/shared/src/hooks/squads/useSquadPendingPosts';
+import {
+ Button,
+ ButtonVariant,
+} from '@dailydotdev/shared/src/components/buttons/Button';
+import { TimerIcon } from '@dailydotdev/shared/src/components/icons';
+
+import {
+ Typography,
+ TypographyColor,
+} from '@dailydotdev/shared/src/components/typography/Typography';
import { getLayout } from '../../../components/layouts/FeedLayout';
import { mainFeedLayoutProps } from '../../../components/layouts/MainFeedPage';
import { SquadDirectoryLayout } from '../../../../shared/src/components/squads/layout/SquadDirectoryLayout';
import { defaultSeo } from '../../../next-seo';
function MySquadsPage(): ReactElement {
+ const { count, isModeratorInAnySquad } = useSquadPendingPosts();
const { squads } = useAuthContext();
const router = useRouter();
- if (squads?.length === 0) {
+ if (squads?.length === 0 && count === 0) {
router.push(squadCategoriesPaths.discover);
return null;
}
return (
+ {isModeratorInAnySquad && count > 0 && (
+ }
+ >
+ Pending posts
+
+ {count}
+
+
+ )}
{squads?.map((squad) => (
))}
diff --git a/packages/webapp/pages/squads/moderate.tsx b/packages/webapp/pages/squads/moderate.tsx
new file mode 100644
index 0000000000..0079a0f6c1
--- /dev/null
+++ b/packages/webapp/pages/squads/moderate.tsx
@@ -0,0 +1,84 @@
+import type { ReactElement } from 'react';
+import type { GetServerSideProps } from 'next';
+import React, { useEffect } from 'react';
+import { ManageSquadPageContainer } from '@dailydotdev/shared/src/components/squads/utils';
+import {
+ SquadTab,
+ SquadTabs,
+} from '@dailydotdev/shared/src/components/squads/SquadTabs';
+import { SquadModerationList } from '@dailydotdev/shared/src/components/squads/moderation/SquadModerationList';
+import {
+ PageHeader,
+ PageHeaderTitle,
+} from '@dailydotdev/shared/src/components/layout/common';
+import {
+ Button,
+ ButtonVariant,
+} from '@dailydotdev/shared/src/components/buttons/Button';
+import { ArrowIcon } from '@dailydotdev/shared/src/components/icons';
+import { useSquad } from '@dailydotdev/shared/src/hooks';
+import { useRouter } from 'next/router';
+import { verifyPermission } from '@dailydotdev/shared/src/graphql/squads';
+import { SourcePermissions } from '@dailydotdev/shared/src/graphql/sources';
+import { TypographyType } from '@dailydotdev/shared/src/components/typography/Typography';
+import { getLayout as getMainLayout } from '../../components/layouts/MainLayout';
+
+interface ModerateSquadPageProps {
+ handle: string | null;
+}
+
+export const getServerSideProps: GetServerSideProps<
+ ModerateSquadPageProps
+> = async ({ query }) => {
+ return {
+ props: {
+ handle: (query.handle as string) || null,
+ },
+ };
+};
+
+export default function ModerateSquadPage({
+ handle,
+}: ModerateSquadPageProps): ReactElement {
+ const router = useRouter();
+ const { squad, isLoading, isFetched } = useSquad({
+ handle,
+ });
+ const isModerator =
+ verifyPermission(squad, SourcePermissions.ModeratePost) || !handle;
+
+ useEffect(() => {
+ if (isLoading || !isFetched) {
+ return;
+ }
+
+ if (handle && !squad.moderationRequired) {
+ router.push(`/squads/${handle}`);
+ }
+ }, [handle, isFetched, isLoading, router, squad]);
+
+ if (isLoading) {
+ return null;
+ }
+
+ return (
+
+
+
+ handle ? router.push(`/squads/${handle}`) : router.back()
+ }
+ icon={ }
+ variant={ButtonVariant.Tertiary}
+ />
+
+ {isModerator ? 'Squad settings' : 'Pending posts'}
+
+
+ {handle && }
+
+
+ );
+}
+
+ModerateSquadPage.getLayout = getMainLayout;