Feature🚀: Ability to Delete Messages realtime (#5056)

* 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
This commit is contained in:
Sarthak Sharma 2019-12-11 00:28:34 +05:30 committed by Ben Halpern
parent 3c97bc5487
commit 4db5399ee8
13 changed files with 784 additions and 405 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,43 @@
class MessagesController < ApplicationController
before_action :authenticate_user!
before_action :set_message, only: %i[destroy]
before_action :authenticate_user!, only: %i[create]
def create
@message = Message.new(message_params)
@message.user_id = session_current_user_id
@temp_message_id = (0...20).map { ("a".."z").to_a[rand(8)] }.join
authorize @message
# sending temp message only to sender
pusher_message_created(true)
if @message.save
pusher_message_created(false)
render json: { status: "success", message: { temp_id: @temp_message_id, id: @message.id } }, status: :created
else
render json: {
status: "error",
message: {
chat_channel_id: @message.chat_channel_id,
message: @message.errors.full_messages,
type: "error"
}
}, status: :unauthorized
end
end
def destroy
authorize @message
if @message.valid?
begin
message_json = create_pusher_payload(@message)
Pusher.trigger(@message.chat_channel.pusher_channels, "message-created", message_json)
Pusher.trigger(@message.chat_channel.pusher_channels, "message-deleted", @message.to_json)
rescue Pusher::Error => e
logger.info "PUSHER ERROR: #{e.message}"
end
end
if @message.save
render json: { status: "success", message: "Message created" }, status: :created
if @message.destroy
render json: { status: "success", message: "Message was deleted" }
else
render json: {
status: "error",
@ -31,8 +52,10 @@ class MessagesController < ApplicationController
private
def create_pusher_payload(new_message)
def create_pusher_payload(new_message, temp_id)
{
temp_id: temp_id,
id: new_message.id,
user_id: new_message.user.id,
chat_channel_id: new_message.chat_channel.id,
chat_channel_adjusted_slug: new_message.chat_channel.adjusted_slug(current_user, "sender"),
@ -49,6 +72,10 @@ class MessagesController < ApplicationController
params.require(:message).permit(:message_markdown, :user_id, :chat_channel_id)
end
def set_message
@message = Message.find(params[:id])
end
def user_not_authorized
respond_to do |format|
format.json do
@ -63,4 +90,19 @@ class MessagesController < ApplicationController
end
end
end
def pusher_message_created(is_single)
return unless @message.valid?
begin
message_json = create_pusher_payload(@message, @temp_message_id)
if is_single
Pusher.trigger("private-message-notifications-#{@message.user_id}", "message-created", message_json)
else
Pusher.trigger(@message.chat_channel.pusher_channels, "message-created", message_json)
end
rescue Pusher::Error => e
logger.info "PUSHER ERROR: #{e.message}"
end
end
end

View file

@ -198,6 +198,40 @@ exports[`<Chat /> should load chat 1`] = `
Scroll to Bottom
</div>
</div>
<div
class="message__delete__modal message__delete__modal__hide"
id="message"
>
<div
class="modal__content"
>
<h3>
Are you sure, you want to delete this message ?
</h3>
<div
class="delete__action__buttons"
>
<div
class="message__cancel__button"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex="0"
>
Cancel
</div>
<div
class="message__delete__button"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex="0"
>
Delete
</div>
</div>
</div>
</div>
<div
class="activechatchannel__alerts"
>

View file

@ -26,25 +26,47 @@ exports[`<Message /> should render and test snapshot 1`] = `
class="chatmessage__body"
role="presentation"
>
<span
class="chatmessagebody__username"
style={
Object {
"color": "#00FFFF",
}
}
<div
class="message__info__actions"
>
<a
class="chatmessagebody__username--link"
data-content="users/undefined"
href="/asdf"
rel="noopener noreferrer"
target="_blank"
<div
class="message__info"
>
asdf
</a>
</span>
<span />
<span
class="chatmessagebody__username"
style={
Object {
"color": "#00FFFF",
}
}
>
<a
class="chatmessagebody__username--link"
data-content="users/undefined"
href="/asdf"
rel="noopener noreferrer"
target="_blank"
>
asdf
</a>
</span>
<span />
</div>
<div
class="message__actions"
>
<span
onKeyUp={[Function]}
role="button"
tabIndex="0"
>
Delete
</span>
<span>
Edit
</span>
</div>
</div>
<div
class="chatmessage__bodytext"
>

View file

@ -178,3 +178,23 @@ export function sendChannelInviteAction(id, action, successCb, failureCb) {
.then(successCb)
.catch(failureCb);
}
export function deleteMessage(messageId, successCb, failureCb) {
fetch(`/messages/${messageId}`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'X-CSRF-Token': window.csrfToken,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: {
user_id: window.currentUser.id,
},
}),
credentials: 'same-origin',
})
.then(response => response.json())
.then(successCb)
.catch(failureCb);
}

View file

@ -10,6 +10,7 @@ import {
getContent,
getChannelInvites,
sendChannelInviteAction,
deleteMessage,
} from './actions';
import { hideMessages, scrollToBottom, setupObserver } from './util';
import Alert from './alert';
@ -61,6 +62,8 @@ export default class Chat extends Component {
soundOn: true,
videoOn: true,
messageOffset: 0,
showDeleteModal: false,
messageDeleteId: null,
allMessagesLoaded: false,
currentMessageLocation: 0,
};
@ -166,6 +169,7 @@ export default class Chat extends Component {
setupPusher(pusherKey, {
channelId: channelName,
messageCreated: this.receiveNewMessage,
messageDeleted: this.removeMessage,
channelCleared: this.clearChannel,
redactUserMessages: this.redactUserMessages,
channelError: this.channelError,
@ -302,10 +306,33 @@ export default class Chat extends Component {
}));
};
removeMessage = message => {
const { activeChannelId } = this.state;
this.setState(prevState => ({
messages: {
[activeChannelId]: [
...prevState.messages[activeChannelId].filter(
oldmessage => oldmessage.id !== message.id,
),
],
},
}));
};
receiveNewMessage = message => {
const { messages, activeChannelId, scrolled, chatChannels } = this.state;
const receivedChatChannelId = message.chat_channel_id;
let newMessages = [];
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);
@ -524,8 +551,25 @@ export default class Chat extends Component {
}
};
triggerDeleteMessage = e => {
this.setState({ messageDeleteId: e.target.dataset.content });
this.setState({ showDeleteModal: true });
};
handleSuccess = response => {
if (response.status === 'error') {
const { activeChannelId } = this.state;
if (response.status === 'success') {
if (response.message.temp_id) {
this.setState(({ messages }) => {
const newMessages = messages;
const foundIndex = messages[activeChannelId].findIndex(
message => message.temp_id === response.message.temp_id,
);
newMessages[activeChannelId][foundIndex].id = response.message.id;
return { messages: newMessages };
});
}
} else if (response.status === 'error') {
this.receiveNewMessage(response.message);
}
};
@ -717,6 +761,8 @@ export default class Chat extends Component {
}
return messages[activeChannelId].map(message => (
<Message
currentUserId={window.currentUser.id}
id={message.id}
user={message.username}
userID={message.user_id}
profileImageUrl={message.profile_image_url}
@ -725,6 +771,7 @@ export default class Chat extends Component {
color={message.color}
type={message.type}
onContentTrigger={this.triggerActiveContent}
onDeleteMessageTrigger={this.triggerDeleteMessage}
/>
));
};
@ -951,6 +998,7 @@ export default class Chat extends Component {
Scroll to Bottom
</div>
</div>
{this.renderDeleteModal()}
<div className="activechatchannel__alerts">
<Alert showAlert={state.showAlert} />
</div>
@ -974,6 +1022,61 @@ export default class Chat extends Component {
);
};
renderDeleteModal = () => {
const { showDeleteModal } = this.state;
return (
<div
id="message"
className={
showDeleteModal
? 'message__delete__modal'
: 'message__delete__modal message__delete__modal__hide'
}
>
<div className="modal__content">
<h3> Are you sure, you want to delete this message ?</h3>
<div className="delete__action__buttons">
<div
role="button"
className="message__cancel__button"
onClick={this.handleCloseDeleteModal}
tabIndex="0"
onKeyUp={e => {
if (e.keyCode === 13) this.handleCloseDeleteModal();
}}
>
{' '}
Cancel
</div>
<div
role="button"
className="message__delete__button"
onClick={this.handleMessageDelete}
tabIndex="0"
onKeyUp={e => {
if (e.keyCode === 13) this.handleMessageDelete();
}}
>
{' '}
Delete
</div>
</div>
</div>
</div>
);
};
handleCloseDeleteModal = () => {
this.setState({ showDeleteModal: false, messageDeleteId: null });
};
handleMessageDelete = () => {
const { messageDeleteId } = this.state;
deleteMessage(messageDeleteId);
this.setState({ showDeleteModal: false });
};
renderChannelHeaderInner = () => {
const { activeChannel, activeChannelId } = this.state;
if (activeChannel.channel_type === 'direct') {

View file

@ -4,6 +4,8 @@ import { adjustTimestamp } from './util';
import ErrorMessage from './messages/errorMessage';
const Message = ({
currentUserId,
id,
user,
userID,
message,
@ -12,6 +14,7 @@ const Message = ({
timestamp,
profileImageUrl,
onContentTrigger,
onDeleteMessageTrigger,
}) => {
const spanStyle = { color };
@ -52,25 +55,47 @@ const Message = ({
className="chatmessage__body"
onClick={onContentTrigger}
>
<span className="chatmessagebody__username" style={spanStyle}>
<a
className="chatmessagebody__username--link"
href={`/${user}`}
target="_blank"
rel="noopener noreferrer"
data-content={`users/${userID}`}
onClick={onContentTrigger}
>
{user}
</a>
</span>
{timestamp ? (
<span className="chatmessage__timestamp">
{`${adjustTimestamp(timestamp)}`}
</span>
) : (
<span />
)}
<div className="message__info__actions">
<div className="message__info">
<span className="chatmessagebody__username" style={spanStyle}>
<a
className="chatmessagebody__username--link"
href={`/${user}`}
target="_blank"
rel="noopener noreferrer"
data-content={`users/${userID}`}
onClick={onContentTrigger}
>
{user}
</a>
</span>
{timestamp ? (
<span className="chatmessage__timestamp">
{`${adjustTimestamp(timestamp)}`}
</span>
) : (
<span />
)}
</div>
{userID === currentUserId ? (
<div className="message__actions">
<span
role="button"
data-content={id}
onClick={onDeleteMessageTrigger}
tabIndex="0"
onKeyUp={e => {
if (e.keyCode === 13) onDeleteMessageTrigger();
}}
>
Delete
</span>
<span>Edit</span>
</div>
) : (
' '
)}
</div>
<div className="chatmessage__bodytext">{messageArea}</div>
</div>
</div>
@ -78,6 +103,8 @@ const Message = ({
};
Message.propTypes = {
currentUserId: PropTypes.number.isRequired,
id: PropTypes.number.isRequired,
user: PropTypes.string.isRequired,
userID: PropTypes.number.isRequired,
color: PropTypes.string.isRequired,
@ -86,6 +113,7 @@ Message.propTypes = {
timestamp: PropTypes.string,
profileImageUrl: PropTypes.string,
onContentTrigger: PropTypes.func.isRequired,
onDeleteMessageTrigger: PropTypes.func.isRequired,
};
Message.defaultProps = {

View file

@ -3,16 +3,16 @@ import PropTypes from 'prop-types';
import setupPusher from './pusher';
class UnopenedChannelNotice extends Component {
static defaultProps = {
unopenedChannels: undefined,
pusherKey: undefined,
};
propTypes = {
unopenedChannels: PropTypes.Object,
pusherKey: PropTypes.Object,
};
static defaultProps = {
unopenedChannels: undefined,
pusherKey: undefined,
};
constructor(props) {
super(props);
const { unopenedChannels } = this.props;
@ -28,6 +28,7 @@ class UnopenedChannelNotice extends Component {
setupPusher(pusherKey, {
channelId: `private-message-notifications-${window.currentUser.id}`,
messageCreated: this.receiveNewMessage,
messageDeleted: this.removeMessage,
});
const component = this;
document.getElementById('connect-link').onclick = () => {
@ -37,6 +38,8 @@ class UnopenedChannelNotice extends Component {
};
}
removeMessage = () => {};
receiveNewMessage = e => {
if (window.location.pathname.startsWith('/connect')) {
return;
@ -118,7 +121,7 @@ class UnopenedChannelNotice extends Component {
padding: '19px 5px 14px',
}}
>
New Message from
New Message from
{' '}
{channels}
</a>

View file

@ -19,6 +19,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('channel-cleared', callbackObjects.channelCleared);
channel.bind('user-banned', callbackObjects.redactUserMessages);
channel.bind('client-livecode', callbackObjects.liveCoding);

View file

@ -2,4 +2,14 @@ class MessagePolicy < ApplicationPolicy
def create?
!user_is_banned?
end
def destroy?
user_is_sender?
end
private
def user_is_sender?
record.user_id == user.id
end
end

View file

@ -1,4 +1,5 @@
json.messages @chat_messages.reverse do |message|
json.id message.id
json.user_id message.user_id
json.username message.user.username
json.profile_image_url ProfileImage.new(message.user).get(90)

View file

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

View file

@ -9,6 +9,7 @@ RSpec.describe "Messages", type: :request do
{
message_markdown: "hi",
user_id: user.id,
temp_id: "sd78jdssd",
chat_channel_id: chat_channel.id
}
end
@ -26,6 +27,7 @@ RSpec.describe "Messages", type: :request do
end
it "returns 201 upon success" do
allow(Pusher).to receive(:trigger).and_return(true)
expect(response.status).to eq(201)
end
@ -46,4 +48,28 @@ RSpec.describe "Messages", type: :request do
end
end
end
describe "DELETE /messages/:id" do
let(:old_message) { create(:message, user_id: user.id) }
it "requires user to be signed in" do
expect { delete "/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
delete "/messages/#{old_message.id}", params: { message: old_message }
end
it "returns message deleted" do
expect(response.body).to include "deleted"
end
it "returns in json" do
expect(response.content_type).to eq("application/json")
end
end
end
end