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:
parent
3c97bc5487
commit
4db5399ee8
13 changed files with 784 additions and 405 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue