diff --git a/.vscode/settings.json b/.vscode/settings.json
index 7de1b8db0..226bd2872 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -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"
}
diff --git a/app/assets/stylesheets/chat.scss b/app/assets/stylesheets/chat.scss
index a8e15353c..2d24038f5 100644
--- a/app/assets/stylesheets/chat.scss
+++ b/app/assets/stylesheets/chat.scss
@@ -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;
-}
\ No newline at end of file
+}
+
+.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;
+}
diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb
index 5771fe511..4421b7b47 100644
--- a/app/controllers/messages_controller.rb
+++ b/app/controllers/messages_controller.rb
@@ -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"
diff --git a/app/javascript/chat/__tests__/__snapshots__/chat.test.jsx.snap b/app/javascript/chat/__tests__/__snapshots__/chat.test.jsx.snap
index 22ee18eec..3ee97f2fa 100644
--- a/app/javascript/chat/__tests__/__snapshots__/chat.test.jsx.snap
+++ b/app/javascript/chat/__tests__/__snapshots__/chat.test.jsx.snap
@@ -244,23 +244,25 @@ exports[` should load chat 1`] = `
diff --git a/app/javascript/chat/__tests__/__snapshots__/compose.test.jsx.snap b/app/javascript/chat/__tests__/__snapshots__/compose.test.jsx.snap
index 1cb93ca6f..ff544fdd4 100644
--- a/app/javascript/chat/__tests__/__snapshots__/compose.test.jsx.snap
+++ b/app/javascript/chat/__tests__/__snapshots__/compose.test.jsx.snap
@@ -1,22 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[` behavior with message should render and test snapshot 1`] = `
-
-
-
`;
diff --git a/app/javascript/chat/__tests__/__snapshots__/message.test.jsx.snap b/app/javascript/chat/__tests__/__snapshots__/message.test.jsx.snap
index 6d2b717e6..db8d9b527 100644
--- a/app/javascript/chat/__tests__/__snapshots__/message.test.jsx.snap
+++ b/app/javascript/chat/__tests__/__snapshots__/message.test.jsx.snap
@@ -50,7 +50,7 @@ exports[` should render and test snapshot 1`] = `
asdf
-
+
should render and test snapshot 1`] = `
>
Delete
+
+ Edit
+
response.json())
+ .then(successCb)
+ .catch(failureCb);
+}
+
export function sendOpen(activeChannelId, successCb, failureCb) {
fetch(`/chat_channels/${activeChannelId}/open`, {
method: 'POST',
diff --git a/app/javascript/chat/chat.jsx b/app/javascript/chat/chat.jsx
index 3b0b9875c..0e2bd1b2f 100644
--- a/app/javascript/chat/chat.jsx
+++ b/app/javascript/chat/chat.jsx
@@ -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 => (
));
};
@@ -1008,7 +1086,13 @@ export default class Chat extends Component {
@@ -1024,6 +1108,13 @@ export default class Chat extends Component {
);
};
+ handleEditMessageClose = () => {
+ this.setState({
+ startEditing: false,
+ activeEditMessage: { message: '', markdown: '' },
+ });
+ };
+
renderDeleteModal = () => {
const { showDeleteModal } = this.state;
return (
diff --git a/app/javascript/chat/compose.jsx b/app/javascript/chat/compose.jsx
index 00adc7121..452e5d5a9 100644
--- a/app/javascript/chat/compose.jsx
+++ b/app/javascript/chat/compose.jsx
@@ -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 (
+
+
+
{
+ if (e.keyCode === 13) handleEditMessageClose();
+ }}
+ >
+ x
+
+
- SEND
+ Save
);
+ };
+
+ render() {
+ const { handleSubmitOnClick, handleKeyDown, startEditing } = this.props;
+ return (
+
+ {!startEditing ? (
+
+
+
+ SEND
+
+
+ ) : (
+ this.messageCompose()
+ )}
+
+ );
}
}
diff --git a/app/javascript/chat/message.jsx b/app/javascript/chat/message.jsx
index 7529c4176..0813ad722 100644
--- a/app/javascript/chat/message.jsx
+++ b/app/javascript/chat/message.jsx
@@ -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}
- {timestamp ? (
+ {editedAt ? (
+
+ {`${adjustTimestamp(editedAt)}`}
+ (edited)
+
+ ) : (
+ ' '
+ )}
+
+ {timestamp && !editedAt ? (
{`${adjustTimestamp(timestamp)}`}
) : (
-
+ ' '
)}
{userID === currentUserId ? (
@@ -90,6 +101,17 @@ const Message = ({
>
Delete
+ {
+ if (e.keyCode === 13) onEditMessageTrigger();
+ }}
+ >
+ Edit
+
) : (
' '
@@ -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 = {
diff --git a/app/javascript/src/utils/getUnopenedChannels.jsx b/app/javascript/src/utils/getUnopenedChannels.jsx
index bca546e7b..edd0ab573 100644
--- a/app/javascript/src/utils/getUnopenedChannels.jsx
+++ b/app/javascript/src/utils/getUnopenedChannels.jsx
@@ -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;
diff --git a/app/javascript/src/utils/pusher.js b/app/javascript/src/utils/pusher.js
index d1ad57e47..7e5f0fd6d 100644
--- a/app/javascript/src/utils/pusher.js
+++ b/app/javascript/src/utils/pusher.js
@@ -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);
diff --git a/app/policies/message_policy.rb b/app/policies/message_policy.rb
index ae954e69c..243ac959c 100644
--- a/app/policies/message_policy.rb
+++ b/app/policies/message_policy.rb
@@ -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?
diff --git a/app/views/chat_channels/show.json.jbuilder b/app/views/chat_channels/show.json.jbuilder
index 2a22cc35e..25a9543f2 100644
--- a/app/views/chat_channels/show.json.jbuilder
+++ b/app/views/chat_channels/show.json.jbuilder
@@ -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
diff --git a/config/routes.rb b/config/routes.rb
index b39667d9a..ed2540d58 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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"
diff --git a/db/migrate/20191215145706_add_edited_at_to_messages.rb b/db/migrate/20191215145706_add_edited_at_to_messages.rb
new file mode 100644
index 000000000..e7162e7dc
--- /dev/null
+++ b/db/migrate/20191215145706_add_edited_at_to_messages.rb
@@ -0,0 +1,5 @@
+class AddEditedAtToMessages < ActiveRecord::Migration[5.2]
+ def change
+ add_column :messages, :edited_at, :datetime
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index acaffd763..795cf6956 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -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
diff --git a/spec/requests/messages_spec.rb b/spec/requests/messages_spec.rb
index fab47c959..893c62bd9 100644
--- a/spec/requests/messages_spec.rb
+++ b/spec/requests/messages_spec.rb
@@ -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