🚀Feature/ability to edit messages (#5139) [deploy]

* Feature 🚀 : Ability to delete messages in chat channels

- Sending message ID to frontend
- Deleting Message
- Use pusher to delete message realtime

* Minor Bug 🐞: Show message action only for current user

- User can delete or edit their own messages

* Test cases added

* Bug 🐞: Update message id for receiver

Message id was not sent to receiver by pusher

* Refactoring🛠: Message controller refactoring

* Test Cases📝 : Specs for Delete message added

* Feature 🚀 : Ability to edit messages

* Test Cases📝 : Specs for Edit message added

* Refactoring🔩: Refactoring for edit messages

* Refactoring🔩: Refactoring:2 for edit messages

* Refactoring🔩: Refactoring:2 for edit messages

* Test Cases📝 : Test Cases for edit message added

* Minor Bug 🐞: Show message action only for current user

- User can delete or edit their own messages

* Test cases added

* Bug 🐞: Update message id for receiver

Message id was not sent to receiver by pusher

* Feature 🚀 : Ability to edit messages

* Test Cases📝 : Specs for Edit message added

* Refactoring🔩: Refactoring for edit messages

* Refactoring🔩: Refactoring:2 for edit messages

* Refactoring🔩: Refactoring:2 for edit messages

* Bug Fix 🐞: Added space between edited and timestamp

Co-authored-by: Ben Halpern <bendhalpern@gmail.com>
This commit is contained in:
Sarthak Sharma 2019-12-20 20:49:01 +05:30 committed by Ben Halpern
parent 07fd2281cd
commit b602b7b91f
18 changed files with 393 additions and 52 deletions

18
.vscode/settings.json vendored
View file

@ -7,5 +7,21 @@
}
},
"ruby.format": "rubocop",
"ruby.intellisense": "rubyLocate"
"ruby.intellisense": "rubyLocate",
"workbench.colorCustomizations": {
"activityBar.background": "#ab307e",
"activityBar.activeBorder": "#25320e",
"activityBar.foreground": "#e7e7e7",
"activityBar.inactiveForeground": "#e7e7e799",
"activityBarBadge.background": "#25320e",
"activityBarBadge.foreground": "#e7e7e7",
"titleBar.activeBackground": "#832561",
"titleBar.inactiveBackground": "#83256199",
"titleBar.activeForeground": "#e7e7e7",
"titleBar.inactiveForeground": "#e7e7e799",
"statusBar.background": "#832561",
"statusBarItem.hoverBackground": "#ab307e",
"statusBar.foreground": "#e7e7e7"
},
"peacock.color": "#832561"
}

View file

@ -1104,6 +1104,7 @@
display: flex;
height: inherit;
align-items: stretch;
position: relative;
}
.messagecomposer__input {
@ -1249,6 +1250,7 @@
display: none;
justify-content: end;
-webkit-justify-content: flex-end;
}
.message__actions span {
@ -1256,7 +1258,6 @@
font-size: 13px;
font-weight: bold;
cursor: pointer;
color: var(--theme-secondary-color, $medium-gray);
}
.chatmessage:hover .message__actions {
@ -1287,7 +1288,6 @@
border: 2px solid;
background: var(--theme-background, #f5f6f7);
color: var(--theme-color, $black);
border-radius: 3px;
}
.modal__content h3 {
@ -1306,7 +1306,6 @@
padding: 5px 20px;
cursor: pointer;
user-select: none;
border-radius: 3px;
}
.message__delete__modal__hide {
@ -1316,4 +1315,34 @@
.message__delete__button {
background: #ff0000;
color: white;
}
}
.messageToBeEdited {
position: absolute;
display: grid;
grid-template-columns: 1fr 40px;
width: calc(100% - 14px);
top: -53px;
background: white;
padding: 0px 5px;
border: 1px solid var(--theme-top-bar-background, #dbdbdb);
border-left: 3px solid #4e57ef;
height: 50px;
overflow: hidden;
}
.closeEdit {
text-align: right;
user-select: none;
cursor: pointer;
padding: 2px 5px;
color: #4e57ef;
font-weight: bold;
}
.editHead {
position: absolute;
color: #4e57ef;
font-size: 14px;
font-weight: bold;
}

View file

@ -1,5 +1,5 @@
class MessagesController < ApplicationController
before_action :set_message, only: %i[destroy]
before_action :set_message, only: %i[destroy update]
before_action :authenticate_user!, only: %i[create]
def create
@ -50,6 +50,31 @@ class MessagesController < ApplicationController
end
end
def update
authorize @message
if @message.update(permitted_attributes(@message).merge(edited_at: Time.zone.now))
if @message.valid?
begin
message_json = create_pusher_payload(@message, "")
Pusher.trigger(@message.chat_channel.pusher_channels, "message-edited", message_json)
rescue Pusher::Error => e
logger.info "PUSHER ERROR: #{e.message}"
end
end
render json: { status: "success", message: "Message was edited" }
else
render json: {
status: "error",
message: {
chat_channel_id: @message.chat_channel_id,
message: @message.errors.full_messages,
type: "error"
}
}, status: :unauthorized
end
end
private
def create_pusher_payload(new_message, temp_id)
@ -62,6 +87,8 @@ class MessagesController < ApplicationController
username: new_message.user.username,
profile_image_url: ProfileImage.new(new_message.user).get(90),
message: new_message.message_html,
markdown: new_message.message_markdown,
edited_at: new_message.edited_at,
timestamp: Time.current,
color: new_message.preferred_user_color,
reception_method: "pushed"

View file

@ -244,23 +244,25 @@ exports[`<Chat /> should load chat 1`] = `
<div
class="activechatchannel__form"
>
<div
class="messagecomposer"
>
<textarea
class="messagecomposer__input"
id="messageform"
maxLength="1000"
onKeyDown={[Function]}
placeholder="Message goes here"
/>
<button
class="messagecomposer__submit"
onClick={[Function]}
type="button"
<div>
<div
class="messagecomposer"
>
SEND
</button>
<textarea
class="messagecomposer__input"
id="messageform"
maxLength="1000"
onKeyDown={[Function]}
placeholder="Message goes here"
/>
<button
class="messagecomposer__submit"
onClick={[Function]}
type="button"
>
SEND
</button>
</div>
</div>
</div>
</div>

View file

@ -1,22 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Compose /> behavior with message should render and test snapshot 1`] = `
<div
class="messagecomposer"
>
<textarea
class="messagecomposer__input"
id="messageform"
maxLength="1000"
onKeyDown={[Function]}
placeholder="Message goes here"
/>
<button
class="messagecomposer__submit"
onClick={[Function]}
type="button"
<div>
<div
class="messagecomposer"
>
SEND
</button>
<textarea
class="messagecomposer__input"
id="messageform"
maxLength="1000"
onKeyDown={[Function]}
placeholder="Message goes here"
/>
<button
class="messagecomposer__submit"
onClick={[Function]}
type="button"
>
SEND
</button>
</div>
</div>
`;

View file

@ -50,7 +50,7 @@ exports[`<Message /> should render and test snapshot 1`] = `
asdf
</a>
</span>
<span />
</div>
<div
class="message__actions"
@ -62,6 +62,13 @@ exports[`<Message /> should render and test snapshot 1`] = `
>
Delete
</span>
<span
onKeyUp={[Function]}
role="button"
tabIndex="0"
>
Edit
</span>
</div>
</div>
<div

View file

@ -31,6 +31,28 @@ export function sendMessage(activeChannelId, message, successCb, failureCb) {
.catch(failureCb);
}
export function editMessage(editedMessage, successCb, failureCb) {
fetch(`/messages/${editedMessage.id}`, {
method: 'PATCH',
headers: {
Accept: 'application/json',
'X-CSRF-Token': window.csrfToken,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: {
message_markdown: editedMessage.message,
user_id: window.currentUser.id,
chat_channel_id: editedMessage.activeChannelId,
},
}),
credentials: 'same-origin',
})
.then(response => response.json())
.then(successCb)
.catch(failureCb);
}
export function sendOpen(activeChannelId, successCb, failureCb) {
fetch(`/chat_channels/${activeChannelId}/open`, {
method: 'POST',

View file

@ -11,6 +11,7 @@ import {
getChannelInvites,
sendChannelInviteAction,
deleteMessage,
editMessage,
} from './actions';
import { hideMessages, scrollToBottom, setupObserver } from './util';
import Alert from './alert';
@ -67,6 +68,8 @@ export default class Chat extends Component {
messageDeleteId: null,
allMessagesLoaded: false,
currentMessageLocation: 0,
startEditing: false,
activeEditMessage: {},
};
}
@ -89,6 +92,9 @@ export default class Chat extends Component {
channel => `open-channel-${channel.chat_channel_id}`,
);
setupObserver(this.observerCallback);
if (!window.currentUser) {
window.currentUser = JSON.parse(document.body.dataset.user);
}
this.subscribePusher(
`private-message-notifications-${currentUserId}`,
);
@ -171,6 +177,7 @@ export default class Chat extends Component {
channelId: channelName,
messageCreated: this.receiveNewMessage,
messageDeleted: this.removeMessage,
messageEdited: this.updateMessage,
channelCleared: this.clearChannel,
redactUserMessages: this.redactUserMessages,
channelError: this.channelError,
@ -320,6 +327,19 @@ export default class Chat extends Component {
}));
};
updateMessage = message => {
const { activeChannelId } = this.state;
this.setState(({ messages }) => {
const newMessages = messages;
const foundIndex = messages[activeChannelId].findIndex(
oldMessage => oldMessage.id === message.id,
);
newMessages[activeChannelId][foundIndex] = message;
return { messages: newMessages };
});
};
receiveNewMessage = message => {
const { messages, activeChannelId, scrolled, chatChannels } = this.state;
const receivedChatChannelId = message.chat_channel_id;
@ -333,6 +353,15 @@ export default class Chat extends Component {
return;
}
if (
message.temp_id &&
messages[activeChannelId].findIndex(
oldmessage => oldmessage.temp_id === message.temp_id,
) > -1
) {
return;
}
if (messages[receivedChatChannelId]) {
newMessages = messages[receivedChatChannelId].slice();
newMessages.push(message);
@ -449,6 +478,34 @@ export default class Chat extends Component {
}
};
handleKeyDownEdit = e => {
const enterPressed = e.keyCode === 13;
const targetValue = e.target.value;
const messageIsEmpty = targetValue.length === 0;
const shiftPressed = e.shiftKey;
if (enterPressed) {
if (messageIsEmpty) {
e.preventDefault();
} else if (!messageIsEmpty && !shiftPressed) {
e.preventDefault();
this.handleMessageSubmitEdit(e.target.value);
e.target.value = '';
}
}
};
handleMessageSubmitEdit = message => {
const { activeChannelId, activeEditMessage } = this.state;
const editedMessage = {
activeChannelId,
id: activeEditMessage.id,
message,
};
editMessage(editedMessage, this.handleSuccess, this.handleFailure);
this.handleEditMessageClose();
};
handleMessageSubmit = message => {
const { activeChannelId } = this.state;
// should check if user has the privilege
@ -551,11 +608,30 @@ export default class Chat extends Component {
}
};
handleSubmitOnClickEdit = e => {
e.preventDefault();
const message = document.getElementById('messageform').value;
if (message.length > 0) {
this.handleMessageSubmitEdit(message);
document.getElementById('messageform').value = '';
}
};
triggerDeleteMessage = e => {
this.setState({ messageDeleteId: e.target.dataset.content });
this.setState({ showDeleteModal: true });
};
triggerEditMessage = e => {
const { messages, activeChannelId } = this.state;
this.setState({
activeEditMessage: messages[activeChannelId].filter(
message => message.id === parseInt(e.target.dataset.content, 10),
)[0],
});
this.setState({ startEditing: true });
};
handleSuccess = response => {
const { activeChannelId } = this.state;
if (response.status === 'success') {
@ -762,17 +838,19 @@ export default class Chat extends Component {
}
return messages[activeChannelId].map(message => (
<Message
currentUserId={currentUserId}
currentUserId={window.currentUser.id}
id={message.id}
user={message.username}
userID={message.user_id}
profileImageUrl={message.profile_image_url}
message={message.message}
timestamp={showTimestamp ? message.timestamp : null}
editedAt={message.edited_at}
color={message.color}
type={message.type}
onContentTrigger={this.triggerActiveContent}
onDeleteMessageTrigger={this.triggerDeleteMessage}
onEditMessageTrigger={this.triggerEditMessage}
/>
));
};
@ -1008,7 +1086,13 @@ export default class Chat extends Component {
<Compose
handleSubmitOnClick={this.handleSubmitOnClick}
handleKeyDown={this.handleKeyDown}
handleSubmitOnClickEdit={this.handleSubmitOnClickEdit}
handleKeyDownEdit={this.handleKeyDownEdit}
activeChannelId={state.activeChannelId}
startEditing={state.startEditing}
editMessageHtml={state.activeEditMessage.message}
editMessageMarkdown={state.activeEditMessage.markdown}
handleEditMessageClose={this.handleEditMessageClose}
/>
</div>
</div>
@ -1024,6 +1108,13 @@ export default class Chat extends Component {
);
};
handleEditMessageClose = () => {
this.setState({
startEditing: false,
activeEditMessage: { message: '', markdown: '' },
});
};
renderDeleteModal = () => {
const { showDeleteModal } = this.state;
return (

View file

@ -4,35 +4,103 @@ import PropTypes from 'prop-types';
export default class Chat extends Component {
static propTypes = {
handleKeyDown: PropTypes.func.isRequired,
handleKeyDownEdit: PropTypes.func.isRequired,
handleSubmitOnClick: PropTypes.func.isRequired,
activeChannelId: PropTypes.number.isRequired,
handleSubmitOnClickEdit: PropTypes.func.isRequired,
startEditing: PropTypes.bool.isRequired,
editMessageHtml: PropTypes.string.isRequired,
editMessageMarkdown: PropTypes.string.isRequired,
handleEditMessageClose: PropTypes.func.isRequired,
};
shouldComponentUpdate(nextProps) {
const { activeChannelId } = this.props;
return activeChannelId !== nextProps.activeChannelId;
constructor(props) {
super(props);
this.state = {
editMessageMarkdown: null,
};
}
render() {
const { handleSubmitOnClick, handleKeyDown } = this.props;
componentWillReceiveProps(props) {
this.setState({
editMessageMarkdown: props.editMessageMarkdown,
editMessageHtml: props.editMessageHtml,
});
}
messageCompose = () => {
const {
handleSubmitOnClickEdit,
handleKeyDownEdit,
handleEditMessageClose,
} = this.props;
const { editMessageHtml, editMessageMarkdown } = this.state;
return (
<div className="messagecomposer">
<div className="messageToBeEdited">
<div className="message">
<span className="editHead">Edit Message</span>
<div
dangerouslySetInnerHTML={{
__html: editMessageHtml,
}}
/>
</div>
<div
className="closeEdit"
role="button"
onClick={handleEditMessageClose}
tabIndex="0"
onKeyUp={e => {
if (e.keyCode === 13) handleEditMessageClose();
}}
>
x
</div>
</div>
<textarea
className="messagecomposer__input"
id="messageform"
placeholder="Message goes here"
onKeyDown={handleKeyDown}
onKeyDown={handleKeyDownEdit}
maxLength="1000"
value={editMessageMarkdown}
/>
<button
type="button"
className="messagecomposer__submit"
onClick={handleSubmitOnClick}
onClick={handleSubmitOnClickEdit}
>
SEND
Save
</button>
</div>
);
};
render() {
const { handleSubmitOnClick, handleKeyDown, startEditing } = this.props;
return (
<div>
{!startEditing ? (
<div className="messagecomposer">
<textarea
className="messagecomposer__input"
id="messageform"
placeholder="Message goes here"
onKeyDown={handleKeyDown}
maxLength="1000"
/>
<button
type="button"
className="messagecomposer__submit"
onClick={handleSubmitOnClick}
>
SEND
</button>
</div>
) : (
this.messageCompose()
)}
</div>
);
}
}

View file

@ -11,10 +11,12 @@ const Message = ({
message,
color,
type,
editedAt,
timestamp,
profileImageUrl,
onContentTrigger,
onDeleteMessageTrigger,
onEditMessageTrigger,
}) => {
const spanStyle = { color };
@ -69,12 +71,21 @@ const Message = ({
{user}
</a>
</span>
{timestamp ? (
{editedAt ? (
<span className="chatmessage__timestamp edited_message">
{`${adjustTimestamp(editedAt)}`}
<i> (edited)</i>
</span>
) : (
' '
)}
{timestamp && !editedAt ? (
<span className="chatmessage__timestamp">
{`${adjustTimestamp(timestamp)}`}
</span>
) : (
<span />
' '
)}
</div>
{userID === currentUserId ? (
@ -90,6 +101,17 @@ const Message = ({
>
Delete
</span>
<span
role="button"
data-content={id}
onClick={onEditMessageTrigger}
tabIndex="0"
onKeyUp={e => {
if (e.keyCode === 13) onEditMessageTrigger();
}}
>
Edit
</span>
</div>
) : (
' '
@ -110,9 +132,11 @@ Message.propTypes = {
message: PropTypes.string.isRequired,
type: PropTypes.string,
timestamp: PropTypes.string,
editedAt: PropTypes.number.isRequired,
profileImageUrl: PropTypes.string,
onContentTrigger: PropTypes.func.isRequired,
onDeleteMessageTrigger: PropTypes.func.isRequired,
onEditMessageTrigger: PropTypes.func.isRequired,
};
Message.defaultProps = {

View file

@ -29,6 +29,7 @@ class UnopenedChannelNotice extends Component {
channelId: `private-message-notifications-${window.currentUser.id}`,
messageCreated: this.receiveNewMessage,
messageDeleted: this.removeMessage,
messageEdited: this.updateMessage,
});
const component = this;
document.getElementById('connect-link').onclick = () => {
@ -40,6 +41,8 @@ class UnopenedChannelNotice extends Component {
removeMessage = () => {};
updateMessage = () => {};
receiveNewMessage = e => {
if (window.location.pathname.startsWith('/connect')) {
return;

View file

@ -20,6 +20,7 @@ export default function setupPusher(key, callbackObjects) {
const channel = pusher.subscribe(callbackObjects.channelId.toString());
channel.bind('message-created', callbackObjects.messageCreated);
channel.bind('message-deleted', callbackObjects.messageDeleted);
channel.bind('message-edited', callbackObjects.messageEdited);
channel.bind('channel-cleared', callbackObjects.channelCleared);
channel.bind('user-banned', callbackObjects.redactUserMessages);
channel.bind('client-livecode', callbackObjects.liveCoding);

View file

@ -7,6 +7,14 @@ class MessagePolicy < ApplicationPolicy
user_is_sender?
end
def update?
destroy?
end
def permitted_attributes_for_update
%i[message_markdown]
end
private
def user_is_sender?

View file

@ -4,6 +4,8 @@ json.messages @chat_messages.reverse do |message|
json.username message.user.username
json.profile_image_url ProfileImage.new(message.user).get(90)
json.message message.message_html
json.markdown message.message_markdown
json.edited_at message.edited_at
json.timestamp message.created_at
json.color message.preferred_user_color
end

View file

@ -224,6 +224,7 @@ Rails.application.routes.draw do
post "/chat_channels/create_chat" => "chat_channels#create_chat"
post "/chat_channels/block_chat" => "chat_channels#block_chat"
delete "/messages/:id" => "messages#destroy"
patch "/messages/:id" => "messages#update"
get "/live/:username" => "twitch_live_streams#show"
post "/pusher/auth" => "pusher#auth"

View file

@ -0,0 +1,5 @@
class AddEditedAtToMessages < ActiveRecord::Migration[5.2]
def change
add_column :messages, :edited_at, :datetime
end
end

View file

@ -12,7 +12,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2019_12_10_144342) do
ActiveRecord::Schema.define(version: 2019_12_15_145706) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -529,6 +529,7 @@ ActiveRecord::Schema.define(version: 2019_12_10_144342) do
create_table "messages", force: :cascade do |t|
t.bigint "chat_channel_id", null: false
t.datetime "created_at", null: false
t.datetime "edited_at"
t.string "message_html", null: false
t.string "message_markdown", null: false
t.datetime "updated_at", null: false

View file

@ -72,4 +72,36 @@ RSpec.describe "Messages", type: :request do
end
end
end
describe "UPDATE /messages/:id" do
let(:old_message) { create(:message, user_id: user.id) }
let(:new_message) do
{
message_markdown: "hi",
user_id: user.id,
chat_channel_id: chat_channel.id
}
end
it "requires user to be signed in" do
expect { patch "/messages/#{old_message.id}" }.to raise_error(Pundit::NotAuthorizedError)
end
context "when user is signed in" do
before do
allow(Pusher).to receive(:trigger).and_return(true)
sign_in user
patch "/messages/#{old_message.id}", params: { message: new_message }
end
it "returns message updated" do
expect(response.body).to include "edited"
end
it "returns in json" do
expect(response.content_type).to eq("application/json")
end
end
end
end