Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce "Mark as unread" functionality in Expensify.cash #2433

Merged
merged 23 commits into from
May 5, 2021
Merged
Show file tree
Hide file tree
Changes from 10 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
49 changes: 37 additions & 12 deletions src/libs/actions/Report.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ const typingWatchTimers = {};
// Keeps track of the max sequence number for each report
const reportMaxSequenceNumbers = {};

// Keeps track of the max sequence number for each report excluding loading actions
const reportMaxSequenceNumbersNotLoading = {};
Gonals marked this conversation as resolved.
Show resolved Hide resolved

// Keeps track of the last read for each report
const lastReadSequenceNumbers = {};

Expand Down Expand Up @@ -328,15 +331,24 @@ function fetchChatReportsByIDs(chatList) {
*
* @param {Number} reportID
* @param {Number} sequenceNumber
* @param {Boolean} [saveNewMarkerSequenceNumber]
*/
function setLocalLastRead(reportID, sequenceNumber) {
function setLocalLastRead(reportID, sequenceNumber, saveNewMarkerSequenceNumber) {
lastReadSequenceNumbers[reportID] = sequenceNumber;

// Update the report optimistically
Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {
unreadActionCount: 0,
lastVisitedTimestamp: Date.now(),
});
if (saveNewMarkerSequenceNumber) {
Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {
unreadActionCount: Math.max(reportMaxSequenceNumbersNotLoading[reportID] - sequenceNumber, 0),
lastVisitedTimestamp: Date.now(),
newMarkerSequenceNumber: sequenceNumber,
});
} else {
Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {
unreadActionCount: 0,
Gonals marked this conversation as resolved.
Show resolved Hide resolved
lastVisitedTimestamp: Date.now(),
});
}
}

/**
Expand Down Expand Up @@ -800,21 +812,18 @@ function addAction(reportID, text, file) {
* network layer handle the delayed write.
*
* @param {Number} reportID
* @param {Number} sequenceNumber
* @param {Number} [sequenceNumber]
*/
function updateLastReadActionID(reportID, sequenceNumber) {
const currentMaxSequenceNumber = reportMaxSequenceNumbers[reportID];
if (sequenceNumber < currentMaxSequenceNumber) {
return;
}
Gonals marked this conversation as resolved.
Show resolved Hide resolved
const lastReadSequenceNumber = sequenceNumber || reportMaxSequenceNumbersNotLoading[reportID];

setLocalLastRead(reportID, sequenceNumber);
setLocalLastRead(reportID, lastReadSequenceNumber, sequenceNumber !== undefined);
Gonals marked this conversation as resolved.
Show resolved Hide resolved

// Mark the report as not having any unread items
API.Report_UpdateLastRead({
accountID: currentUserAccountID,
reportID,
sequenceNumber,
sequenceNumber: lastReadSequenceNumber || 0,
Gonals marked this conversation as resolved.
Show resolved Hide resolved
});
}

Expand Down Expand Up @@ -875,6 +884,21 @@ function handleReportChanged(report) {
// Store the max sequence number for each report
reportMaxSequenceNumbers[report.reportID] = report.maxSequenceNumber;

// Store the max sequence number for each report excluding loading actions
Onyx.connect({
Gonals marked this conversation as resolved.
Show resolved Hide resolved
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`,
callback: actions => reportMaxSequenceNumbersNotLoading[report.reportID] = _.chain(actions)

// We want to avoid marking any pending actions as read since
// 1. Any action ID that hasn't been delivered by the server is a temporary action ID.
// 2. We already set a comment someone has authored as the lastReadActionID_<accountID> rNVP on the server
// and should sync it locally when we handle it via Pusher or Airship
.reject(action => action.loading)
.pluck('sequenceNumber')
.max()
.value(),
});

Gonals marked this conversation as resolved.
Show resolved Hide resolved
// Store optimistic actions IDs for each report
optimisticReportActionIDs[report.reportID] = report.optimisticReportActionIDs;
}
Expand Down Expand Up @@ -902,6 +926,7 @@ export {
fetchOrCreateChatReport,
addAction,
updateLastReadActionID,
setLocalLastRead,
subscribeToReportCommentEvents,
subscribeToReportTypingEvents,
unsubscribeFromReportChannel,
Expand Down
167 changes: 84 additions & 83 deletions src/pages/home/report/ReportActionContextMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,81 +7,19 @@ import {
Clipboard as ClipboardIcon, LinkCopy, Mail, Pencil, Trashcan, Checkmark,
} from '../../../components/Icon/Expensicons';
import getReportActionContextMenuStyles from '../../../styles/getReportActionContextMenuStyles';
import {updateLastReadActionID} from '../../../libs/actions/Report';
import ReportActionContextMenuItem from './ReportActionContextMenuItem';
import ReportActionPropTypes from './ReportActionPropTypes';
import Clipboard from '../../../libs/Clipboard';
import {isReportMessageAttachment} from '../../../libs/reportUtils';

/**
* A list of all the context actions in this menu.
*/
const CONTEXT_ACTIONS = [
// Copy to clipboard
{
text: 'Copy to Clipboard',
icon: ClipboardIcon,
successText: 'Copied!',
successIcon: Checkmark,
shouldShow: true,

// If return value is true, we switch the `text` and `icon` on
// `ReportActionContextMenuItem` with `successText` and `successIcon` which will fallback to
// the `text` and `icon`
onPress: (action) => {
const message = _.last(lodashGet(action, 'message', null));
const html = lodashGet(message, 'html', '');
const text = lodashGet(message, 'text', '');
const isAttachment = _.has(action, 'isAttachment')
? action.isAttachment
: isReportMessageAttachment(text);
if (!isAttachment) {
Clipboard.setString(text);
} else {
Clipboard.setString(html);
}
},
},

// Copy chat link
{
text: 'Copy Link',
icon: LinkCopy,
shouldShow: false,
onPress: () => {},
},

// Mark as Unread
{
text: 'Mark as Unread',
icon: Mail,
shouldShow: false,
onPress: () => {},
},

// Edit Comment
{
text: 'Edit Comment',
icon: Pencil,
shouldShow: false,
onPress: () => {},
},

// Delete Comment
{
text: 'Delete Comment',
icon: Trashcan,
shouldShow: false,
onPress: () => {},
},
];

const propTypes = {
// The ID of the report this report action is attached to.
// eslint-disable-next-line react/no-unused-prop-types
reportID: PropTypes.number.isRequired,

// The report action this context menu is attached to.
reportAction: PropTypes.shape(ReportActionPropTypes),
reportAction: PropTypes.shape(ReportActionPropTypes).isRequired,
Gonals marked this conversation as resolved.
Show resolved Hide resolved

// If true, this component will be a small, row-oriented menu that displays icons but not text.
// If false, this component will be a larger, column-oriented menu that displays icons alongside text in each row.
Expand All @@ -92,29 +30,92 @@ const propTypes = {
};

const defaultProps = {
reportAction: {},
isMini: false,
isVisible: false,
};

const ReportActionContextMenu = (props) => {
const wrapperStyle = getReportActionContextMenuStyles(props.isMini);
return props.isVisible && (
<View style={wrapperStyle}>
{CONTEXT_ACTIONS.map(contextAction => contextAction.shouldShow && (
<ReportActionContextMenuItem
icon={contextAction.icon}
text={contextAction.text}
successIcon={contextAction.successIcon}
successText={contextAction.successText}
isMini={props.isMini}
onPress={() => contextAction.onPress(props.reportAction)}
key={contextAction.text}
/>
))}
</View>
);
};
class ReportActionContextMenu extends React.Component {
constructor(props) {
super(props);

// A list of all the context actions in this menu.
this.CONTEXT_ACTIONS = [
Gonals marked this conversation as resolved.
Show resolved Hide resolved
// Copy to clipboard
{
text: 'Copy to Clipboard',
icon: ClipboardIcon,
successText: 'Copied!',
successIcon: Checkmark,
shouldShow: true,

// If return value is true, we switch the `text` and `icon` on
// `ReportActionContextMenuItem` with `successText` and `successIcon` which will fallback to
// the `text` and `icon`
onPress: () => {
const message = _.last(lodashGet(this.props.reportAction, 'message', null));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this should be a class method so that it prevents this constructor from turning into a monolithic block of logic and functions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see this is being discussed in the delete comments PR, so I'll wait for the resolution over there

const html = lodashGet(message, 'html', '');
const text = lodashGet(message, 'text', '');
const isAttachment = _.has(this.props.reportAction, 'isAttachment')
? this.props.reportAction.isAttachment
: isReportMessageAttachment(text);
if (!isAttachment) {
Clipboard.setString(text);
} else {
Clipboard.setString(html);
}
},
},

{
text: 'Copy Link',
icon: LinkCopy,
shouldShow: false,
onPress: () => {},
},

{
text: 'Mark as Unread',
icon: Mail,
shouldShow: true,
onPress: () => updateLastReadActionID(this.props.reportID, this.props.reportAction.sequenceNumber - 1),
Gonals marked this conversation as resolved.
Show resolved Hide resolved
},

{
text: 'Edit Comment',
icon: Pencil,
shouldShow: false,
onPress: () => {},
},

{
text: 'Delete Comment',
icon: Trashcan,
shouldShow: false,
onPress: () => {},
},
];

this.wrapperStyle = getReportActionContextMenuStyles(this.props.isMini);
}

render() {
return this.props.isVisible && (
<View style={this.wrapperStyle}>
{this.CONTEXT_ACTIONS.map(contextAction => contextAction.shouldShow && (
<ReportActionContextMenuItem
icon={contextAction.icon}
text={contextAction.text}
successIcon={contextAction.successIcon}
successText={contextAction.successText}
isMini={this.props.isMini}
key={contextAction.text}
onPress={contextAction.onPress}
/>
))}
</View>
);
}
}
Gonals marked this conversation as resolved.
Show resolved Hide resolved

ReportActionContextMenu.propTypes = propTypes;
ReportActionContextMenu.defaultProps = defaultProps;
Expand Down
Loading