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 22 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
41 changes: 29 additions & 12 deletions src/libs/actions/Report.js
Original file line number Diff line number Diff line change
Expand Up @@ -387,14 +387,19 @@ function setLocalIOUReportData(iouReportObject, chatReportID) {
* Update the lastRead actionID and timestamp in local memory and Onyx
*
* @param {Number} reportID
* @param {Number} sequenceNumber
* @param {Number} lastReadSequenceNumber
*/
function setLocalLastRead(reportID, sequenceNumber) {
lastReadSequenceNumbers[reportID] = sequenceNumber;
function setLocalLastRead(reportID, lastReadSequenceNumber) {
lastReadSequenceNumbers[reportID] = lastReadSequenceNumber;
const reportMaxSequenceNumber = reportMaxSequenceNumbers[reportID];

// Determine the number of unread actions by deducting the last read sequence from the total. If, for some reason,
// the last read sequence is higher than the actual last sequence, let's just assume all actions are read
const unreadActionCount = Math.max(reportMaxSequenceNumber - lastReadSequenceNumber, 0);

// Update the report optimistically
// Update the report optimistically.
Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {
unreadActionCount: 0,
unreadActionCount,
lastVisitedTimestamp: Date.now(),
});
}
Expand Down Expand Up @@ -929,21 +934,32 @@ function addAction(reportID, text, file) {
* network layer handle the delayed write.
*
* @param {Number} reportID
* @param {Number} sequenceNumber
* @param {Number} [sequenceNumber] This can be used to set the last read actionID to a specific
* spot (eg. mark-as-unread). Otherwise, when this param is omitted, the highest sequence number becomes the one that
* is last read (meaning that the entire report history has been read)
*/
function updateLastReadActionID(reportID, sequenceNumber) {
const currentMaxSequenceNumber = reportMaxSequenceNumbers[reportID];
if (sequenceNumber < currentMaxSequenceNumber) {
return;
}
Gonals marked this conversation as resolved.
Show resolved Hide resolved
// Need to subtract 1 from sequenceNumber so that the "New" marker appears in the right spot (the last read
// action). If 1 isn't subtracted then the "New" marker appears one row below the action (the first unread action)
const lastReadSequenceNumber = (sequenceNumber - 1) || reportMaxSequenceNumbers[reportID];

setLocalLastRead(reportID, sequenceNumber);
setLocalLastRead(reportID, lastReadSequenceNumber);

// Mark the report as not having any unread items
API.Report_UpdateLastRead({
accountID: currentUserAccountID,
reportID,
sequenceNumber,
sequenceNumber: lastReadSequenceNumber,
});
}

/**
* @param {Number} reportID
* @param {Number} sequenceNumber
*/
function setNewMarkerPosition(reportID, sequenceNumber) {
Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {
newMarkerSequenceNumber: sequenceNumber,
});
}

Expand Down Expand Up @@ -1031,6 +1047,7 @@ export {
fetchOrCreateChatReport,
addAction,
updateLastReadActionID,
setNewMarkerPosition,
subscribeToReportTypingEvents,
subscribeToUserEvents,
unsubscribeFromReportChannel,
Expand Down
171 changes: 88 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 {setNewMarkerPosition, 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,96 @@ 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,
successIcon: Checkmark,
shouldShow: true,
onPress: () => {
updateLastReadActionID(this.props.reportID, this.props.reportAction.sequenceNumber);
setNewMarkerPosition(this.props.reportID, this.props.reportAction.sequenceNumber);
},
},

{
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
2 changes: 1 addition & 1 deletion src/pages/home/report/ReportActionItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ class ReportActionItem extends Component {
measureContent={() => (
<ReportActionContextMenu
isVisible
reportID={-1}
reportID={this.props.reportID}
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
reportAction={this.props.action}
/>
)}
Expand Down
Loading