Skip to content
Draft
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
2 changes: 1 addition & 1 deletion Mobile-Expensify
5 changes: 5 additions & 0 deletions src/libs/Pusher/EventType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export default {
USER_IS_TYPING: 'client-userIsTyping',
MULTIPLE_EVENTS: 'multipleEvents',
CONCIERGE_REASONING: 'conciergeReasoning',
CONCIERGE_DRAFT_STARTED: 'conciergeDraftStarted',
CONCIERGE_DRAFT_UPDATED: 'conciergeDraftUpdated',
CONCIERGE_DRAFT_COMPLETED: 'conciergeDraftCompleted',
CONCIERGE_DRAFT_FAILED: 'conciergeDraftFailed',
CONCIERGE_DRAFT_CLEARED: 'conciergeDraftCleared',

// An event that the server sends back to the client in response to a "ping" API command
PONG: 'pong',
Expand Down
20 changes: 20 additions & 0 deletions src/libs/Pusher/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,30 @@ type ConciergeReasoningEvent = {
loopCount: number;
};

type ConciergeDraftEvent = {
reportID: string;
reportActionID: string;
streamSessionID: string;
sequence: number;
status: 'started' | 'updated' | 'completed' | 'failed' | 'cleared';
created: string;
bodyMarkdown?: string;
finalRenderedHTML?: string;
startedAt?: string;
terminalReason?: string;
updatedAt?: string;
};

type PusherEventMap = {
[TYPE.USER_IS_TYPING]: UserIsTypingEvent;
[TYPE.USER_IS_LEAVING_ROOM]: UserIsLeavingRoomEvent;
[TYPE.PONG]: PingPongEvent;
[TYPE.CONCIERGE_REASONING]: ConciergeReasoningEvent;
[TYPE.CONCIERGE_DRAFT_STARTED]: ConciergeDraftEvent;
[TYPE.CONCIERGE_DRAFT_UPDATED]: ConciergeDraftEvent;
[TYPE.CONCIERGE_DRAFT_COMPLETED]: ConciergeDraftEvent;
[TYPE.CONCIERGE_DRAFT_FAILED]: ConciergeDraftEvent;
[TYPE.CONCIERGE_DRAFT_CLEARED]: ConciergeDraftEvent;
};

type EventData<EventName extends string> = {chunk?: string; id?: string; index?: number; final?: boolean} & (EventName extends keyof PusherEventMap
Expand Down Expand Up @@ -103,6 +122,7 @@ export type {
UserIsLeavingRoomEvent,
PingPongEvent,
ConciergeReasoningEvent,
ConciergeDraftEvent,
EventData,
EventCallbackError,
ChunkedDataEvents,
Expand Down
132 changes: 132 additions & 0 deletions src/pages/inbox/ConciergeDraftContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import {getReportChatType} from '@selectors/Report';
import React, {createContext, useCallback, useContext, useEffect, useMemo, useState} from 'react';
import useOnyx from '@hooks/useOnyx';
import {getReportChannelName} from '@libs/actions/Report';
import Log from '@libs/Log';
import Pusher from '@libs/Pusher';
import type {ConciergeDraftEvent} from '@libs/Pusher/types';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {ReportAction} from '@src/types/onyx';
import type {ConciergeDraft} from './conciergeDraftState';
import {applyConciergeDraftEvent} from './conciergeDraftState';

type ConciergeDraftState = {
draftReportAction: ReportAction | null;
hasActiveDraft: boolean;
};

type ConciergeDraftActions = {
clearDraft: () => void;
};

const defaultState: ConciergeDraftState = {
draftReportAction: null,
hasActiveDraft: false,
};

const defaultActions: ConciergeDraftActions = {
clearDraft: () => {},
};

const ConciergeDraftStateContext = createContext<ConciergeDraftState>(defaultState);
const ConciergeDraftActionsContext = createContext<ConciergeDraftActions>(defaultActions);

function ConciergeDraftProvider({reportID, children}: React.PropsWithChildren<{reportID: string | undefined}>) {
const [chatType] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getReportChatType});
const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID);
const isConciergeChat = reportID === conciergeReportID;
const isAdmin = chatType === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS;
const isAgentZeroChat = isConciergeChat || isAdmin;

if (!reportID || !isAgentZeroChat) {
return children;
}

return (
<ConciergeDraftGate
key={reportID}
reportID={reportID}
>
{children}
</ConciergeDraftGate>
);
}

function ConciergeDraftGate({reportID, children}: React.PropsWithChildren<{reportID: string}>) {
const [draft, setDraft] = useState<ConciergeDraft | null>(null);

const clearDraft = useCallback(() => {
setDraft(null);
}, []);

useEffect(() => {
const channelName = getReportChannelName(reportID);
const handleResubscribe = () => {
clearDraft();
};
const eventTypes = [
Pusher.TYPE.CONCIERGE_DRAFT_STARTED,
Pusher.TYPE.CONCIERGE_DRAFT_UPDATED,
Pusher.TYPE.CONCIERGE_DRAFT_COMPLETED,
Pusher.TYPE.CONCIERGE_DRAFT_FAILED,
Pusher.TYPE.CONCIERGE_DRAFT_CLEARED,
] as const;

const subscriptions = eventTypes.map((eventType) => {
const listener = Pusher.subscribe(
channelName,
eventType,
(eventData) => {
const conciergeDraftEvent = eventData as ConciergeDraftEvent;
setDraft((currentDraft) => applyConciergeDraftEvent(currentDraft, conciergeDraftEvent, reportID));
},
handleResubscribe,
);

listener.catch((error: unknown) => {
Log.hmmm('Failed to subscribe to Pusher concierge draft events', {eventType, reportID, error});
});

return listener;
});

return () => {
for (const subscription of subscriptions) {
subscription.unsubscribe();
}
};
}, [clearDraft, reportID]);

const stateValue = useMemo<ConciergeDraftState>(
() => ({
draftReportAction: draft?.reportAction ?? null,
hasActiveDraft: !!draft?.reportAction,
}),
[draft?.reportAction],
);

const actionsValue = useMemo<ConciergeDraftActions>(
() => ({
clearDraft,
}),
[clearDraft],
);

return (
<ConciergeDraftActionsContext.Provider value={actionsValue}>
<ConciergeDraftStateContext.Provider value={stateValue}>{children}</ConciergeDraftStateContext.Provider>
</ConciergeDraftActionsContext.Provider>
);
}

function useConciergeDraft(): ConciergeDraftState {
return useContext(ConciergeDraftStateContext);
}

function useConciergeDraftActions(): ConciergeDraftActions {
return useContext(ConciergeDraftActionsContext);
}

export {ConciergeDraftProvider, useConciergeDraft, useConciergeDraftActions};
export type {ConciergeDraftState, ConciergeDraftActions};
17 changes: 10 additions & 7 deletions src/pages/inbox/ReportScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import CONST from '@src/CONST';
import SCREENS from '@src/SCREENS';
import AccountManagerBanner from './AccountManagerBanner';
import {AgentZeroStatusProvider} from './AgentZeroStatusContext';
import {ConciergeDraftProvider} from './ConciergeDraftContext';
import DeleteTransactionNavigateBackHandler from './DeleteTransactionNavigateBackHandler';
import LinkedActionNotFoundGuard from './LinkedActionNotFoundGuard';
import ReactionListWrapper from './ReactionListWrapper';
Expand Down Expand Up @@ -78,13 +79,15 @@ function ReportScreen({route, navigation}: ReportScreenProps) {
<View style={[styles.flex1, styles.flexRow]}>
<WideRHPReceiptPanel />
<AgentZeroStatusProvider reportID={reportIDFromRoute}>
<View
style={[styles.flex1, styles.justifyContentEnd, styles.overflowHidden]}
testID="report-actions-view-wrapper"
>
<ReportActionsList />
<ReportFooter />
</View>
<ConciergeDraftProvider reportID={reportIDFromRoute}>
<View
style={[styles.flex1, styles.justifyContentEnd, styles.overflowHidden]}
testID="report-actions-view-wrapper"
>
<ReportActionsList />
<ReportFooter />
</View>
</ConciergeDraftProvider>
</AgentZeroStatusProvider>
</View>
<PortalHost name="suggestions" />
Expand Down
85 changes: 85 additions & 0 deletions src/pages/inbox/conciergeDraftState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import Parser from '@libs/Parser';
import type {ConciergeDraftEvent} from '@libs/Pusher/types';
import {getParsedComment} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import type {ReportAction} from '@src/types/onyx';

type ConciergeDraft = {
reportAction: ReportAction;
sequence: number;
status: ConciergeDraftEvent['status'];
streamSessionID: string;
terminalReason?: string;
};

type BuildConciergeDraftReportActionParams = {
bodyMarkdown?: string;
created: string;
finalRenderedHTML?: string;
reportActionID: string;
reportID: string;
};

function buildConciergeDraftReportAction({bodyMarkdown, created, finalRenderedHTML, reportActionID, reportID}: BuildConciergeDraftReportActionParams): ReportAction | null {
const html = finalRenderedHTML ?? (bodyMarkdown ? getParsedComment(bodyMarkdown, {reportID}) : '');

if (!html) {
return null;
}

return {
reportActionID,
reportID,
actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
actorAccountID: CONST.ACCOUNT_ID.CONCIERGE,
person: [{style: 'strong', text: CONST.CONCIERGE_DISPLAY_NAME, type: 'TEXT'}],
created,
message: [{type: CONST.REPORT.MESSAGE.TYPE.COMMENT, html, text: Parser.htmlToText(html)}],
originalMessage: {html, whisperedTo: []},
shouldShow: true,
} as ReportAction;
}

function applyConciergeDraftEvent(currentDraft: ConciergeDraft | null, event: ConciergeDraftEvent, reportID: string): ConciergeDraft | null {
if (event.reportID !== reportID) {
return currentDraft;
}

const isSameStreamSession = currentDraft?.streamSessionID === event.streamSessionID;

if (isSameStreamSession && event.sequence <= currentDraft.sequence) {
return currentDraft;
}

if (!isSameStreamSession && currentDraft && event.status !== 'started' && event.status !== 'updated') {
return currentDraft;
}

if (event.status === 'failed' || event.status === 'cleared') {
return isSameStreamSession ? null : currentDraft;
}

const nextReportAction =
buildConciergeDraftReportAction({
bodyMarkdown: event.bodyMarkdown,
created: event.created,
finalRenderedHTML: event.finalRenderedHTML,
reportActionID: event.reportActionID,
reportID: event.reportID,
}) ?? currentDraft?.reportAction;

if (!nextReportAction) {
return currentDraft;
}

return {
reportAction: nextReportAction,
sequence: event.sequence,
status: event.status,
streamSessionID: event.streamSessionID,
terminalReason: event.terminalReason,
};
}

export {applyConciergeDraftEvent, buildConciergeDraftReportAction};
export type {ConciergeDraft};
Loading
Loading