Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion src/components/markdownEditor/children/editorToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -42,13 +44,18 @@ import { DEFAULT_USER_DRAFT_ID } from '../../../redux/constants/constants';
import { TextFormatModal } from './textFormatModal';
import { selectCurrentAccount } from '../../../redux/selectors';

// Per-account session dismissals for the quest chip; once closed it stays
// hidden for that account until the app restarts.
const questChipDismissedUsers = new Set<string>();

type Props = {
draftId?: string;
postBody: string;
paramFiles: any[];
isEditing: boolean;
isPreviewActive: boolean;
isEditMode: boolean;
isReply?: boolean;
suggestedPrompt?: string;
setIsUploading: (isUploading: boolean) => void;
handleMediaInsert: (data: MediaInsertData[]) => void;
Expand All @@ -67,6 +74,7 @@ export const EditorToolbar = ({
isEditing,
isPreviewActive,
isEditMode,
isReply,
suggestedPrompt,
setIsUploading,
handleMediaInsert,
Expand All @@ -86,6 +94,9 @@ export const EditorToolbar = ({
(state) => state.editor.pollDraftsMap[draftId || DEFAULT_USER_DRAFT_ID],
);

const { username } = useAuth();
const { data: questsData } = useGetQuestsQuery(isReply || isEditMode ? undefined : username);

const uploadsGalleryModalRef = useRef<typeof UploadsGalleryModal>(null);
const textFormatModalRef = useRef(null);
const extensionHeight = useRef(0);
Expand All @@ -96,6 +107,9 @@ export const EditorToolbar = ({
const [isKeyboardVisible, setKeyboardVisible] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
const [hasClipboardImage, setHasClipboardImage] = useState(false);
const [isQuestChipDismissed, setIsQuestChipDismissed] = useState(
!!username && questChipDismissedUsers.has(username),
);
const dismissedClipboardRef = useRef(false);

useEffect(() => {
Expand Down Expand Up @@ -187,6 +201,17 @@ export const EditorToolbar = ({
dismissedClipboardRef.current = true;
};

const _dismissQuestChip = () => {
if (username) {
questChipDismissedUsers.add(username);
}
setIsQuestChipDismissed(true);
};

const _openPerks = () => {
navigation.navigate(ROUTES.SCREENS.PERKS);
};

const _showAiAssist = () => {
SheetManager.show(SheetNames.AI_ASSIST, {
payload: {
Expand Down Expand Up @@ -383,10 +408,60 @@ 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;
}

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 (
<View style={styles.questChipWrapper}>
<TouchableOpacity activeOpacity={0.8} onPress={_openPerks} style={styles.questChip}>
<Text style={_textStyle}>{parts.join(' · ')}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={_dismissQuestChip}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
style={styles.questChipClose}
accessibilityRole="button"
accessibilityLabel={intl.formatMessage({ id: 'alert.cancel' })}
>
<Text style={_textStyle}>×</Text>
</TouchableOpacity>
</View>
);
};

return (
<View style={_keyboardAdjustedStyle}>
{_renderExtension()}
{_renderClipboardChip()}
{_renderQuestChip()}

Comment thread
greptile-apps[bot] marked this conversation as resolved.
{!isPreviewActive && (
<View style={_buttonsContainerStyle}>
Expand Down
28 changes: 28 additions & 0 deletions src/components/markdownEditor/styles/editorToolbarStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
});
1 change: 1 addition & 0 deletions src/components/markdownEditor/view/markdownEditorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ const MarkdownEditorView = ({
isPreviewActive={isPreviewActive}
paramFiles={paramFiles}
isEditMode={isEdit}
isReply={isReply}
suggestedPrompt={fields?.title?.trim() || undefined}
setIsUploading={setIsUploading}
handleMediaInsert={_handleMediaInsert}
Expand Down
5 changes: 5 additions & 0 deletions src/config/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
106 changes: 106 additions & 0 deletions src/utils/questChip.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
39 changes: 39 additions & 0 deletions src/utils/questChip.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
Loading