From 8754e22586e55190f2e2fa9a04e010ad5eef8556 Mon Sep 17 00:00:00 2001 From: ecency Date: Mon, 6 Jul 2026 19:09:21 +0000 Subject: [PATCH 1/2] feat(editor): surface daily quest and streak chip --- .../markdownEditor/children/editorToolbar.tsx | 70 +++++++++++- .../styles/editorToolbarStyles.ts | 28 +++++ .../view/markdownEditorView.tsx | 1 + src/config/locales/en-US.json | 5 + src/utils/questChip.test.ts | 106 ++++++++++++++++++ src/utils/questChip.ts | 39 +++++++ 6 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 src/utils/questChip.test.ts create mode 100644 src/utils/questChip.ts diff --git a/src/components/markdownEditor/children/editorToolbar.tsx b/src/components/markdownEditor/children/editorToolbar.tsx index bb79b49232..18c80af966 100644 --- a/src/components/markdownEditor/children/editorToolbar.tsx +++ b/src/components/markdownEditor/children/editorToolbar.tsx @@ -29,7 +29,9 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { SheetManager } from 'react-native-actions-sheet'; import { IconButton, UploadsGalleryModal } from '../..'; import { hasClipboardImage as detectClipboardImage } from '../../../utils/clipboard'; -import { useAppSelector } from '../../../hooks'; +import { deriveQuestChipState } from '../../../utils/questChip'; +import { useAppSelector, useAuth } from '../../../hooks'; +import { useGetQuestsQuery } from '../../../providers/queries/pointQueries'; import { SheetNames } from '../../../navigation/sheets'; import { MediaInsertData, @@ -42,6 +44,10 @@ import { DEFAULT_USER_DRAFT_ID } from '../../../redux/constants/constants'; import { TextFormatModal } from './textFormatModal'; import { selectCurrentAccount } from '../../../redux/selectors'; +// Session-wide dismiss flag for the quest chip; once closed it stays hidden +// until the app restarts. +let isQuestChipDismissedSession = false; + type Props = { draftId?: string; postBody: string; @@ -49,6 +55,7 @@ type Props = { isEditing: boolean; isPreviewActive: boolean; isEditMode: boolean; + isReply?: boolean; suggestedPrompt?: string; setIsUploading: (isUploading: boolean) => void; handleMediaInsert: (data: MediaInsertData[]) => void; @@ -67,6 +74,7 @@ export const EditorToolbar = ({ isEditing, isPreviewActive, isEditMode, + isReply, suggestedPrompt, setIsUploading, handleMediaInsert, @@ -86,6 +94,9 @@ export const EditorToolbar = ({ (state) => state.editor.pollDraftsMap[draftId || DEFAULT_USER_DRAFT_ID], ); + const { username } = useAuth(); + const { data: questsData } = useGetQuestsQuery(isReply ? undefined : username); + const uploadsGalleryModalRef = useRef(null); const textFormatModalRef = useRef(null); const extensionHeight = useRef(0); @@ -96,6 +107,7 @@ export const EditorToolbar = ({ const [isKeyboardVisible, setKeyboardVisible] = useState(false); const [keyboardHeight, setKeyboardHeight] = useState(0); const [hasClipboardImage, setHasClipboardImage] = useState(false); + const [isQuestChipDismissed, setIsQuestChipDismissed] = useState(isQuestChipDismissedSession); const dismissedClipboardRef = useRef(false); useEffect(() => { @@ -187,6 +199,15 @@ export const EditorToolbar = ({ dismissedClipboardRef.current = true; }; + const _dismissQuestChip = () => { + isQuestChipDismissedSession = true; + setIsQuestChipDismissed(true); + }; + + const _openPerks = () => { + navigation.navigate(ROUTES.SCREENS.PERKS); + }; + const _showAiAssist = () => { SheetManager.show(SheetNames.AI_ASSIST, { payload: { @@ -383,10 +404,57 @@ export const EditorToolbar = ({ ); }; + const _renderQuestChip = () => { + const questChip = deriveQuestChipState(questsData); + if ( + isReply || + isQuestChipDismissed || + isPreviewActive || + isExtensionVisible || + !questChip?.visible + ) { + return null; + } + + const parts = [ + `${intl.formatMessage({ id: 'quest_chip.daily_post' })} ${questChip.postProgress}/${ + questChip.postGoal + }`, + ]; + if (questChip.streakCurrent > 0) { + parts.push( + `🔥 ${intl.formatMessage({ id: 'quest_chip.streak' }, { n: questChip.streakCurrent })}`, + ); + } + if (questChip.atRisk) { + parts.push(intl.formatMessage({ id: 'quest_chip.keep_streak' })); + } + + const _textStyle = [styles.questChipText, questChip.atRisk && styles.questChipTextAtRisk]; + + return ( + + + {parts.join(' · ')} + + + × + + + ); + }; + return ( {_renderExtension()} {_renderClipboardChip()} + {_renderQuestChip()} {!isPreviewActive && ( diff --git a/src/components/markdownEditor/styles/editorToolbarStyles.ts b/src/components/markdownEditor/styles/editorToolbarStyles.ts index 693de4009b..98308ed12e 100644 --- a/src/components/markdownEditor/styles/editorToolbarStyles.ts +++ b/src/components/markdownEditor/styles/editorToolbarStyles.ts @@ -130,4 +130,32 @@ export default EStyleSheet.create({ fontSize: 12, fontWeight: '600', }, + questChipWrapper: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + marginHorizontal: 12, + marginTop: 6, + marginBottom: 4, + paddingLeft: 12, + paddingRight: 4, + paddingVertical: 6, + borderRadius: 16, + backgroundColor: '$primaryLightBackground', + } as ViewStyle, + questChip: { + flexShrink: 1, + } as ViewStyle, + questChipClose: { + paddingHorizontal: 8, + paddingVertical: 2, + } as ViewStyle, + questChipText: { + color: '$primaryDarkText', + fontSize: 12, + fontWeight: '600', + }, + questChipTextAtRisk: { + color: '#f97316', + }, }); diff --git a/src/components/markdownEditor/view/markdownEditorView.tsx b/src/components/markdownEditor/view/markdownEditorView.tsx index 1e2613e873..f464da9bbe 100644 --- a/src/components/markdownEditor/view/markdownEditorView.tsx +++ b/src/components/markdownEditor/view/markdownEditorView.tsx @@ -521,6 +521,7 @@ const MarkdownEditorView = ({ isPreviewActive={isPreviewActive} paramFiles={paramFiles} isEditMode={isEdit} + isReply={isReply} suggestedPrompt={fields?.title?.trim() || undefined} setIsUploading={setIsUploading} handleMediaInsert={_handleMediaInsert} diff --git a/src/config/locales/en-US.json b/src/config/locales/en-US.json index 34710e36f2..95bcecddf0 100644 --- a/src/config/locales/en-US.json +++ b/src/config/locales/en-US.json @@ -35,6 +35,11 @@ "streak_freeze_buying": "Protecting", "streak_freeze_error": "Could not buy a streak freeze. Please try again." }, + "quest_chip": { + "daily_post": "Daily post", + "streak": "{n} day streak", + "keep_streak": "Keep your streak alive" + }, "waves": { "for_you": "For you", "following": "Following", diff --git a/src/utils/questChip.test.ts b/src/utils/questChip.test.ts new file mode 100644 index 0000000000..68eb620f2a --- /dev/null +++ b/src/utils/questChip.test.ts @@ -0,0 +1,106 @@ +import { deriveQuestChipState } from './questChip'; + +jest.mock('@ecency/sdk', () => ({ + getQuestCatalogEntry: jest.fn((tier: string, id: string) => + tier === 'daily' && id === 'post' + ? { id: 'post', tier: 'daily', goal: 1, i18nKey: 'post', icon: 'pencil' } + : undefined, + ), +})); + +const quests = (overrides: any = {}) => ({ + period: { day: '2026-07-06', week: '2026-W28', month: '2026-07', day_resets_in_secs: 3600 }, + daily: [{ id: 'post', progress: 0, cap: 3 }], + weekly: [], + monthly: [], + streak: { current: 0, best: 0, at_risk: false }, + ...overrides, +}); + +describe('deriveQuestChipState', () => { + it('returns null until quest data has arrived', () => { + expect(deriveQuestChipState(undefined)).toBeNull(); + expect(deriveQuestChipState(null)).toBeNull(); + }); + + it('is visible when the daily post quest is incomplete and there is no streak', () => { + expect(deriveQuestChipState(quests() as any)).toEqual({ + visible: true, + postedToday: false, + postProgress: 0, + postGoal: 1, + streakCurrent: 0, + atRisk: false, + }); + }); + + it('hides once the daily post quest is complete and there is no streak', () => { + const state = deriveQuestChipState( + quests({ daily: [{ id: 'post', progress: 1, cap: 3 }] }) as any, + ); + expect(state).toEqual({ + visible: false, + postedToday: true, + postProgress: 1, + postGoal: 1, + streakCurrent: 0, + atRisk: false, + }); + }); + + it('stays visible after posting when a streak is active', () => { + const state = deriveQuestChipState( + quests({ + daily: [{ id: 'post', progress: 2, cap: 3 }], + streak: { current: 5, best: 9, at_risk: false }, + }) as any, + ); + expect(state).toEqual({ + visible: true, + postedToday: true, + postProgress: 2, + postGoal: 1, + streakCurrent: 5, + atRisk: false, + }); + }); + + it('flags at-risk only while a streak is active', () => { + const atRisk = deriveQuestChipState( + quests({ streak: { current: 3, best: 3, at_risk: true } }) as any, + ); + expect(atRisk?.atRisk).toBe(true); + expect(atRisk?.visible).toBe(true); + + const noStreak = deriveQuestChipState( + quests({ streak: { current: 0, best: 3, at_risk: true } }) as any, + ); + expect(noStreak?.atRisk).toBe(false); + }); + + it('treats a missing daily post quest as zero progress', () => { + const state = deriveQuestChipState( + quests({ daily: [{ id: 'comment', progress: 4, cap: 25 }] }) as any, + ); + expect(state).toEqual({ + visible: true, + postedToday: false, + postProgress: 0, + postGoal: 1, + streakCurrent: 0, + atRisk: false, + }); + }); + + it('tolerates missing daily and streak sections', () => { + const state = deriveQuestChipState(quests({ daily: undefined, streak: undefined }) as any); + expect(state).toEqual({ + visible: true, + postedToday: false, + postProgress: 0, + postGoal: 1, + streakCurrent: 0, + atRisk: false, + }); + }); +}); diff --git a/src/utils/questChip.ts b/src/utils/questChip.ts new file mode 100644 index 0000000000..1a463c2c19 --- /dev/null +++ b/src/utils/questChip.ts @@ -0,0 +1,39 @@ +import { getQuestCatalogEntry } from '@ecency/sdk'; +import type { QuestsResponse } from '@ecency/sdk'; + +export interface QuestChipState { + visible: boolean; + postedToday: boolean; + postProgress: number; + postGoal: number; + streakCurrent: number; + atRisk: boolean; +} + +/** + * Derives the editor quest chip state from the quests endpoint response. + * Returns null until quest data has arrived. The chip is worth showing when + * the user has an active streak to protect or has not completed the daily + * post quest yet. + */ +export const deriveQuestChipState = (quests?: QuestsResponse | null): QuestChipState | null => { + if (!quests) { + return null; + } + + const postQuest = quests.daily?.find((q) => q.id === 'post'); + const postGoal = getQuestCatalogEntry('daily', 'post')?.goal ?? 1; + const postProgress = Math.max(0, postQuest?.progress ?? 0); + const postedToday = postProgress >= postGoal; + const streakCurrent = Math.max(0, quests.streak?.current ?? 0); + const atRisk = streakCurrent > 0 && !!quests.streak?.at_risk; + + return { + visible: streakCurrent > 0 || !postedToday, + postedToday, + postProgress, + postGoal, + streakCurrent, + atRisk, + }; +}; From 12673fceb410cc410021ae40baa06e6600ce75ce Mon Sep 17 00:00:00 2001 From: ecency Date: Mon, 6 Jul 2026 19:17:16 +0000 Subject: [PATCH 2/2] fix(editor): scope quest chip dismissal per account and tighten visibility --- .../markdownEditor/children/editorToolbar.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/components/markdownEditor/children/editorToolbar.tsx b/src/components/markdownEditor/children/editorToolbar.tsx index 18c80af966..f8bbbb4bc7 100644 --- a/src/components/markdownEditor/children/editorToolbar.tsx +++ b/src/components/markdownEditor/children/editorToolbar.tsx @@ -44,9 +44,9 @@ import { DEFAULT_USER_DRAFT_ID } from '../../../redux/constants/constants'; import { TextFormatModal } from './textFormatModal'; import { selectCurrentAccount } from '../../../redux/selectors'; -// Session-wide dismiss flag for the quest chip; once closed it stays hidden -// until the app restarts. -let isQuestChipDismissedSession = false; +// Per-account session dismissals for the quest chip; once closed it stays +// hidden for that account until the app restarts. +const questChipDismissedUsers = new Set(); type Props = { draftId?: string; @@ -95,7 +95,7 @@ export const EditorToolbar = ({ ); const { username } = useAuth(); - const { data: questsData } = useGetQuestsQuery(isReply ? undefined : username); + const { data: questsData } = useGetQuestsQuery(isReply || isEditMode ? undefined : username); const uploadsGalleryModalRef = useRef(null); const textFormatModalRef = useRef(null); @@ -107,7 +107,9 @@ export const EditorToolbar = ({ const [isKeyboardVisible, setKeyboardVisible] = useState(false); const [keyboardHeight, setKeyboardHeight] = useState(0); const [hasClipboardImage, setHasClipboardImage] = useState(false); - const [isQuestChipDismissed, setIsQuestChipDismissed] = useState(isQuestChipDismissedSession); + const [isQuestChipDismissed, setIsQuestChipDismissed] = useState( + !!username && questChipDismissedUsers.has(username), + ); const dismissedClipboardRef = useRef(false); useEffect(() => { @@ -200,7 +202,9 @@ export const EditorToolbar = ({ }; const _dismissQuestChip = () => { - isQuestChipDismissedSession = true; + if (username) { + questChipDismissedUsers.add(username); + } setIsQuestChipDismissed(true); }; @@ -406,11 +410,14 @@ export const EditorToolbar = ({ const _renderQuestChip = () => { const questChip = deriveQuestChipState(questsData); + const clipboardChipVisible = hasClipboardImage && !dismissedClipboardRef.current; if ( isReply || + isEditMode || isQuestChipDismissed || isPreviewActive || isExtensionVisible || + clipboardChipVisible || !questChip?.visible ) { return null;