feat: Ability to reply to specific tweets (#1117)
Ability to choose a specific tweet to reply to Fixes #982 Co-authored-by: Pranav Raj S <pranav@thoughtwoot.com>
This commit is contained in:
parent
a6a62d92bf
commit
4216d63311
23 changed files with 290 additions and 38 deletions
|
@ -46,5 +46,6 @@ module.exports = {
|
||||||
},
|
},
|
||||||
globals: {
|
globals: {
|
||||||
__WEBPACK_ENV__: true,
|
__WEBPACK_ENV__: true,
|
||||||
|
bus: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -11,6 +11,7 @@ class Messages::MessageBuilder
|
||||||
@content_type = params[:content_type]
|
@content_type = params[:content_type]
|
||||||
@items = params.to_unsafe_h&.dig(:content_attributes, :items)
|
@items = params.to_unsafe_h&.dig(:content_attributes, :items)
|
||||||
@attachments = params[:attachments]
|
@attachments = params[:attachments]
|
||||||
|
@in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to)
|
||||||
end
|
end
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
|
@ -51,7 +52,8 @@ class Messages::MessageBuilder
|
||||||
private: @private,
|
private: @private,
|
||||||
sender: sender,
|
sender: sender,
|
||||||
content_type: @content_type,
|
content_type: @content_type,
|
||||||
items: @items
|
items: @items,
|
||||||
|
in_reply_to: @in_reply_to
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,10 +7,11 @@ class MessageApi extends ApiClient {
|
||||||
super('conversations', { accountScoped: true });
|
super('conversations', { accountScoped: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
create({ conversationId, message, private: isPrivate }) {
|
create({ conversationId, message, private: isPrivate, contentAttributes }) {
|
||||||
return axios.post(`${this.url}/${conversationId}/messages`, {
|
return axios.post(`${this.url}/${conversationId}/messages`, {
|
||||||
content: message,
|
content: message,
|
||||||
private: isPrivate,
|
private: isPrivate,
|
||||||
|
content_attributes: contentAttributes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,11 +23,27 @@
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<bubble-actions
|
<bubble-actions
|
||||||
|
:id="data.id"
|
||||||
|
:sender="data.sender"
|
||||||
|
:is-a-tweet="isATweet"
|
||||||
:is-email="isEmailContentType"
|
:is-email="isEmailContentType"
|
||||||
:readable-time="readableTime"
|
|
||||||
:is-private="data.private"
|
:is-private="data.private"
|
||||||
|
:message-type="data.message_type"
|
||||||
|
:readable-time="readableTime"
|
||||||
|
:source-id="data.source_id"
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div v-if="isATweet && isIncoming && sender" class="sender--info">
|
||||||
|
<woot-thumbnail
|
||||||
|
:src="sender.thumbnail"
|
||||||
|
:username="sender.name"
|
||||||
|
size="16px"
|
||||||
|
/>
|
||||||
|
<div class="sender--available-name">
|
||||||
|
{{ sender.available_name || sender.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
@ -40,6 +56,8 @@ import BubbleImage from './bubble/Image';
|
||||||
import BubbleFile from './bubble/File';
|
import BubbleFile from './bubble/File';
|
||||||
import contentTypeMixin from 'shared/mixins/contentTypeMixin';
|
import contentTypeMixin from 'shared/mixins/contentTypeMixin';
|
||||||
import BubbleActions from './bubble/Actions';
|
import BubbleActions from './bubble/Actions';
|
||||||
|
import { MESSAGE_TYPE } from 'shared/constants/messageTypes';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
BubbleActions,
|
BubbleActions,
|
||||||
|
@ -53,6 +71,10 @@ export default {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
isATweet: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -61,7 +83,10 @@ export default {
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
message() {
|
message() {
|
||||||
return this.formatMessage(this.data.content);
|
return this.formatMessage(this.data.content, this.isATweet);
|
||||||
|
},
|
||||||
|
sender() {
|
||||||
|
return this.data.sender || {};
|
||||||
},
|
},
|
||||||
contentType() {
|
contentType() {
|
||||||
const {
|
const {
|
||||||
|
@ -78,6 +103,9 @@ export default {
|
||||||
isBubble() {
|
isBubble() {
|
||||||
return [0, 1, 3].includes(this.data.message_type);
|
return [0, 1, 3].includes(this.data.message_type);
|
||||||
},
|
},
|
||||||
|
isIncoming() {
|
||||||
|
return this.data.message_type === MESSAGE_TYPE.INCOMING;
|
||||||
|
},
|
||||||
hasAttachments() {
|
hasAttachments() {
|
||||||
return !!(this.data.attachments && this.data.attachments.length > 0);
|
return !!(this.data.attachments && this.data.attachments.length > 0);
|
||||||
},
|
},
|
||||||
|
@ -90,7 +118,7 @@ export default {
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
sentByMessage() {
|
sentByMessage() {
|
||||||
const { sender } = this.data;
|
const { sender } = this;
|
||||||
|
|
||||||
return this.data.message_type === 1 && !this.isHovered && sender
|
return this.data.message_type === 1 && !this.isHovered && sender
|
||||||
? {
|
? {
|
||||||
|
@ -128,4 +156,15 @@ export default {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sender--info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-smaller) 0;
|
||||||
|
|
||||||
|
.sender--available-name {
|
||||||
|
font-size: var(--font-size-mini);
|
||||||
|
margin-left: var(--space-smaller);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
:is-contact-panel-open="isContactPanelOpen"
|
:is-contact-panel-open="isContactPanelOpen"
|
||||||
@contactPanelToggle="onToggleContactPanel"
|
@contactPanelToggle="onToggleContactPanel"
|
||||||
/>
|
/>
|
||||||
<div v-if="!currentChat.can_reply" class="messenger-policy--banner">
|
<div v-if="!currentChat.can_reply" class="banner messenger-policy--banner">
|
||||||
<span>
|
<span>
|
||||||
{{ $t('CONVERSATION.CANNOT_REPLY') }}
|
{{ $t('CONVERSATION.CANNOT_REPLY') }}
|
||||||
<a
|
<a
|
||||||
|
@ -17,6 +17,23 @@
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isATweet" class="banner">
|
||||||
|
<span v-if="!selectedTweetId">
|
||||||
|
{{ $t('CONVERSATION.LAST_INCOMING_TWEET') }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ $t('CONVERSATION.REPLYING_TO') }}
|
||||||
|
{{ selectedTweet }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="selectedTweetId"
|
||||||
|
class="banner-close-button"
|
||||||
|
@click="removeTweetSelection"
|
||||||
|
>
|
||||||
|
<i v-tooltip="$t('CONVERSATION.REMOVE_SELECTION')" class="ion-close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<ul class="conversation-panel">
|
<ul class="conversation-panel">
|
||||||
<transition name="slide-up">
|
<transition name="slide-up">
|
||||||
<li class="spinner--container">
|
<li class="spinner--container">
|
||||||
|
@ -27,6 +44,7 @@
|
||||||
v-for="message in getReadMessages"
|
v-for="message in getReadMessages"
|
||||||
:key="message.id"
|
:key="message.id"
|
||||||
:data="message"
|
:data="message"
|
||||||
|
:is-a-tweet="isATweet"
|
||||||
/>
|
/>
|
||||||
<li v-show="getUnreadCount != 0" class="unread--toast">
|
<li v-show="getUnreadCount != 0" class="unread--toast">
|
||||||
<span>
|
<span>
|
||||||
|
@ -37,6 +55,7 @@
|
||||||
v-for="message in getUnReadMessages"
|
v-for="message in getUnReadMessages"
|
||||||
:key="message.id"
|
:key="message.id"
|
||||||
:data="message"
|
:data="message"
|
||||||
|
:is-a-tweet="isATweet"
|
||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="conversation-footer">
|
<div class="conversation-footer">
|
||||||
|
@ -52,6 +71,7 @@
|
||||||
</div>
|
</div>
|
||||||
<ReplyBox
|
<ReplyBox
|
||||||
:conversation-id="currentChat.id"
|
:conversation-id="currentChat.id"
|
||||||
|
:in-reply-to="selectedTweetId"
|
||||||
@scrollToMessage="scrollToBottom"
|
@scrollToMessage="scrollToBottom"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -59,7 +79,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
/* global bus */
|
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
|
|
||||||
import ConversationHeader from './ConversationHeader';
|
import ConversationHeader from './ConversationHeader';
|
||||||
|
@ -67,6 +86,7 @@ import ReplyBox from './ReplyBox';
|
||||||
import Message from './Message';
|
import Message from './Message';
|
||||||
import conversationMixin from '../../../mixins/conversations';
|
import conversationMixin from '../../../mixins/conversations';
|
||||||
import { getTypingUsersText } from '../../../helper/commons';
|
import { getTypingUsersText } from '../../../helper/commons';
|
||||||
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
|
@ -93,6 +113,7 @@ export default {
|
||||||
isLoadingPrevious: true,
|
isLoadingPrevious: true,
|
||||||
heightBeforeLoad: null,
|
heightBeforeLoad: null,
|
||||||
conversationPanel: null,
|
conversationPanel: null,
|
||||||
|
selectedTweetId: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -151,6 +172,36 @@ export default {
|
||||||
shouldLoadMoreChats() {
|
shouldLoadMoreChats() {
|
||||||
return !this.listLoadingStatus && !this.isLoadingPrevious;
|
return !this.listLoadingStatus && !this.isLoadingPrevious;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
conversationType() {
|
||||||
|
const { additional_attributes: additionalAttributes } = this.currentChat;
|
||||||
|
const type = additionalAttributes ? additionalAttributes.type : '';
|
||||||
|
return type || '';
|
||||||
|
},
|
||||||
|
|
||||||
|
isATweet() {
|
||||||
|
return this.conversationType === 'tweet';
|
||||||
|
},
|
||||||
|
|
||||||
|
selectedTweet() {
|
||||||
|
if (this.selectedTweetId) {
|
||||||
|
const { messages = [] } = this.getMessages;
|
||||||
|
const [selectedMessage = {}] = messages.filter(
|
||||||
|
message => message.id === this.selectedTweetId
|
||||||
|
);
|
||||||
|
return selectedMessage.content || '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
currentChat(newChat, oldChat) {
|
||||||
|
if (newChat.id === oldChat.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.selectedTweetId = null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
created() {
|
created() {
|
||||||
|
@ -158,6 +209,10 @@ export default {
|
||||||
setTimeout(() => this.scrollToBottom(), 0);
|
setTimeout(() => this.scrollToBottom(), 0);
|
||||||
this.makeMessagesRead();
|
this.makeMessagesRead();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bus.$on(BUS_EVENTS.SET_TWEET_REPLY, selectedTweetId => {
|
||||||
|
this.selectedTweetId = selectedTweetId;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
|
@ -220,23 +275,37 @@ export default {
|
||||||
makeMessagesRead() {
|
makeMessagesRead() {
|
||||||
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
|
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
|
||||||
},
|
},
|
||||||
|
removeTweetSelection() {
|
||||||
|
this.selectedTweetId = null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.messenger-policy--banner {
|
.banner {
|
||||||
background: var(--r-400);
|
background: var(--b-500);
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
font-size: var(--font-size-mini);
|
font-size: var(--font-size-mini);
|
||||||
padding: var(--space-slab) var(--space-normal);
|
padding: var(--space-slab) var(--space-normal);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
font-size: var(--font-size-mini);
|
font-size: var(--font-size-mini);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.messenger-policy--banner {
|
||||||
|
background: var(--r-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-close-button {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: var(--space--two);
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner--container {
|
.spinner--container {
|
||||||
|
|
|
@ -46,14 +46,14 @@
|
||||||
}}</a>
|
}}</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="tabs-title is-private" :class="{ 'is-active': isPrivate }">
|
<li class="tabs-title is-private" :class="{ 'is-active': isPrivate }">
|
||||||
<a href="#" @click="setPrivateReplyMode">{{
|
<a href="#" @click="setPrivateReplyMode">
|
||||||
$t('CONVERSATION.REPLYBOX.PRIVATE_NOTE')
|
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
|
||||||
}}</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="message.length" class="tabs-title message-length">
|
<li v-if="message.length" class="tabs-title message-length">
|
||||||
<a :class="{ 'message-error': isMessageLengthReachingThreshold }">{{
|
<a :class="{ 'message-error': isMessageLengthReachingThreshold }">
|
||||||
characterCountIndicator
|
{{ characterCountIndicator }}
|
||||||
}}</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<button
|
<button
|
||||||
|
@ -106,6 +106,12 @@ export default {
|
||||||
ResizableTextArea,
|
ResizableTextArea,
|
||||||
},
|
},
|
||||||
mixins: [clickaway, inboxMixin],
|
mixins: [clickaway, inboxMixin],
|
||||||
|
props: {
|
||||||
|
inReplyTo: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
message: '',
|
message: '',
|
||||||
|
@ -248,11 +254,15 @@ export default {
|
||||||
if (!this.showCannedResponsesList) {
|
if (!this.showCannedResponsesList) {
|
||||||
this.clearMessage();
|
this.clearMessage();
|
||||||
try {
|
try {
|
||||||
await this.$store.dispatch('sendMessage', {
|
const messagePayload = {
|
||||||
conversationId: this.currentChat.id,
|
conversationId: this.currentChat.id,
|
||||||
message: newMessage,
|
message: newMessage,
|
||||||
private: this.isPrivate,
|
private: this.isPrivate,
|
||||||
});
|
};
|
||||||
|
if (this.inReplyTo) {
|
||||||
|
messagePayload.contentAttributes = { in_reply_to: this.inReplyTo };
|
||||||
|
}
|
||||||
|
await this.$store.dispatch('sendMessage', messagePayload);
|
||||||
this.$emit('scrollToMessage');
|
this.$emit('scrollToMessage');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Error
|
// Error
|
||||||
|
|
|
@ -13,12 +13,32 @@
|
||||||
@mouseenter="isHovered = true"
|
@mouseenter="isHovered = true"
|
||||||
@mouseleave="isHovered = false"
|
@mouseleave="isHovered = false"
|
||||||
/>
|
/>
|
||||||
|
<i
|
||||||
|
v-if="isATweet && isIncoming"
|
||||||
|
v-tooltip.top-start="$t('CHAT_LIST.REPLY_TO_TWEET')"
|
||||||
|
class="icon ion-reply cursor-pointer"
|
||||||
|
@click="onTweetReply"
|
||||||
|
/>
|
||||||
|
<a :href="linkToTweet" target="_blank" rel="noopener noreferrer nofollow">
|
||||||
|
<i
|
||||||
|
v-if="isATweet && isIncoming"
|
||||||
|
v-tooltip.top-start="$t('CHAT_LIST.VIEW_TWEET_IN_TWITTER')"
|
||||||
|
class="icon ion-android-open cursor-pointer"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { MESSAGE_TYPE } from 'shared/constants/messageTypes';
|
||||||
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
sender: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
readableTime: {
|
readableTime: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
|
@ -31,6 +51,41 @@ export default {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
isATweet: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
messageType: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
sourceId: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isIncoming() {
|
||||||
|
return MESSAGE_TYPE.INCOMING === this.messageType;
|
||||||
|
},
|
||||||
|
screenName() {
|
||||||
|
const { additional_attributes: additionalAttributes = {} } =
|
||||||
|
this.sender || {};
|
||||||
|
return additionalAttributes?.screen_name || '';
|
||||||
|
},
|
||||||
|
linkToTweet() {
|
||||||
|
const { screenName, sourceId } = this;
|
||||||
|
return `https://twitter.com/${screenName}/status/${sourceId}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onTweetReply() {
|
||||||
|
bus.$emit(BUS_EVENTS.SET_TWEET_REPLY, this.id);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -65,6 +120,13 @@ export default {
|
||||||
|
|
||||||
i {
|
i {
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
padding-right: var(--space-small);
|
||||||
|
padding-left: var(--space-small);
|
||||||
|
color: var(--s-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--s-900);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import AuthAPI from '../api/auth';
|
import AuthAPI from '../api/auth';
|
||||||
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
|
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
|
||||||
/* global bus */
|
|
||||||
|
|
||||||
class ActionCableConnector extends BaseActionCableConnector {
|
class ActionCableConnector extends BaseActionCableConnector {
|
||||||
constructor(app, pubsubToken) {
|
constructor(app, pubsubToken) {
|
||||||
|
|
|
@ -10,7 +10,8 @@
|
||||||
"SEARCH": {
|
"SEARCH": {
|
||||||
"INPUT": "Search for People, Chats, Saved Replies .."
|
"INPUT": "Search for People, Chats, Saved Replies .."
|
||||||
},
|
},
|
||||||
"STATUS_TABS": [{
|
"STATUS_TABS": [
|
||||||
|
{
|
||||||
"NAME": "Open",
|
"NAME": "Open",
|
||||||
"KEY": "openCount"
|
"KEY": "openCount"
|
||||||
},
|
},
|
||||||
|
@ -19,8 +20,8 @@
|
||||||
"KEY": "allConvCount"
|
"KEY": "allConvCount"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"ASSIGNEE_TYPE_TABS": [
|
||||||
"ASSIGNEE_TYPE_TABS": [{
|
{
|
||||||
"NAME": "Mine",
|
"NAME": "Mine",
|
||||||
"KEY": "me",
|
"KEY": "me",
|
||||||
"COUNT_KEY": "mineCount"
|
"COUNT_KEY": "mineCount"
|
||||||
|
@ -36,8 +37,8 @@
|
||||||
"COUNT_KEY": "allCount"
|
"COUNT_KEY": "allCount"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"CHAT_STATUS_ITEMS": [
|
||||||
"CHAT_STATUS_ITEMS": [{
|
{
|
||||||
"TEXT": "Open",
|
"TEXT": "Open",
|
||||||
"VALUE": "open"
|
"VALUE": "open"
|
||||||
},
|
},
|
||||||
|
@ -50,7 +51,6 @@
|
||||||
"VALUE": "bot"
|
"VALUE": "bot"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
"ATTACHMENTS": {
|
"ATTACHMENTS": {
|
||||||
"image": {
|
"image": {
|
||||||
"ICON": "ion-image",
|
"ICON": "ion-image",
|
||||||
|
@ -77,6 +77,8 @@
|
||||||
"CONTENT": "has shared a url"
|
"CONTENT": "has shared a url"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"RECEIVED_VIA_EMAIL": "Received via email"
|
"RECEIVED_VIA_EMAIL": "Received via email",
|
||||||
|
"VIEW_TWEET_IN_TWITTER": "View tweet in Twitter",
|
||||||
|
"REPLY_TO_TWEET": "Reply to this tweet"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,9 @@
|
||||||
"LOADING_CONVERSATIONS": "Loading Conversations",
|
"LOADING_CONVERSATIONS": "Loading Conversations",
|
||||||
"CANNOT_REPLY": "You cannot reply due to",
|
"CANNOT_REPLY": "You cannot reply due to",
|
||||||
"24_HOURS_WINDOW": "24 hour message window restriction",
|
"24_HOURS_WINDOW": "24 hour message window restriction",
|
||||||
|
"LAST_INCOMING_TWEET": "You are replying to the last incoming tweet",
|
||||||
|
"REPLYING_TO": "You are replying to:",
|
||||||
|
"REMOVE_SELECTION": "Remove Selection",
|
||||||
"DOWNLOAD": "Download",
|
"DOWNLOAD": "Download",
|
||||||
"HEADER": {
|
"HEADER": {
|
||||||
"RESOLVE_ACTION": "Resolve",
|
"RESOLVE_ACTION": "Resolve",
|
||||||
|
|
3
app/javascript/shared/constants/busEvents.js
Normal file
3
app/javascript/shared/constants/busEvents.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export const BUS_EVENTS = {
|
||||||
|
SET_TWEET_REPLY: 'SET_TWEET_REPLY',
|
||||||
|
};
|
6
app/javascript/shared/constants/messageTypes.js
Normal file
6
app/javascript/shared/constants/messageTypes.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export const MESSAGE_TYPE = {
|
||||||
|
INCOMING: 0,
|
||||||
|
OUTGOING: 1,
|
||||||
|
ACTIVITY: 2,
|
||||||
|
TEMPLATE: 3,
|
||||||
|
};
|
|
@ -1,13 +1,32 @@
|
||||||
import { escapeHtml } from './HTMLSanitizer';
|
import { escapeHtml } from './HTMLSanitizer';
|
||||||
|
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
|
||||||
|
const TWITTER_USERNAME_REPLACEMENT =
|
||||||
|
'$1<a href="http://twitter.com/$2" target="_blank" rel="noreferrer nofollow noopener">@$2</a>';
|
||||||
|
|
||||||
|
const TWITTER_HASH_REGEX = /(^|\s)#(\w+)/g;
|
||||||
|
const TWITTER_HASH_REPLACEMENT =
|
||||||
|
'$1<a href="https://twitter.com/hashtag/$2" target="_blank" rel="noreferrer nofollow noopener">#$2</a>';
|
||||||
|
|
||||||
class MessageFormatter {
|
class MessageFormatter {
|
||||||
constructor(message) {
|
constructor(message, isATweet = false) {
|
||||||
this.message = escapeHtml(message || '') || '';
|
this.message = escapeHtml(message || '') || '';
|
||||||
|
this.isATweet = isATweet;
|
||||||
}
|
}
|
||||||
|
|
||||||
formatMessage() {
|
formatMessage() {
|
||||||
const linkifiedMessage = this.linkify();
|
const linkifiedMessage = this.linkify();
|
||||||
return linkifiedMessage.replace(/\n/g, '<br>');
|
const messageWithNextLines = linkifiedMessage.replace(/\n/g, '<br>');
|
||||||
|
if (this.isATweet) {
|
||||||
|
const messageWithUserName = messageWithNextLines.replace(
|
||||||
|
TWITTER_USERNAME_REGEX,
|
||||||
|
TWITTER_USERNAME_REPLACEMENT
|
||||||
|
);
|
||||||
|
return messageWithUserName.replace(
|
||||||
|
TWITTER_HASH_REGEX,
|
||||||
|
TWITTER_HASH_REPLACEMENT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return messageWithNextLines;
|
||||||
}
|
}
|
||||||
|
|
||||||
linkify() {
|
linkify() {
|
||||||
|
|
|
@ -10,4 +10,26 @@ describe('#MessageFormatter', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('tweets', () => {
|
||||||
|
it('should return the same string if not tags or @mentions', () => {
|
||||||
|
const message = 'Chatwoot is an opensource tool';
|
||||||
|
expect(new MessageFormatter(message).formattedMessage).toEqual(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add links to @mentions', () => {
|
||||||
|
const message =
|
||||||
|
'@chatwootapp is an opensource tool thanks @longnonexistenttwitterusername';
|
||||||
|
expect(new MessageFormatter(message, true).formattedMessage).toEqual(
|
||||||
|
'<a href="http://twitter.com/chatwootapp" target="_blank" rel="noreferrer nofollow noopener">@chatwootapp</a> is an opensource tool thanks @longnonexistenttwitterusername'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add links to #tags', () => {
|
||||||
|
const message = '#chatwootapp is an opensource tool';
|
||||||
|
expect(new MessageFormatter(message, true).formattedMessage).toEqual(
|
||||||
|
'<a href="https://twitter.com/hashtag/chatwootapp" target="_blank" rel="noreferrer nofollow noopener">#chatwootapp</a> is an opensource tool'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
/* global bus */
|
|
||||||
export default {
|
export default {
|
||||||
methods: {
|
methods: {
|
||||||
showAlert(message) {
|
showAlert(message) {
|
||||||
|
|
|
@ -2,8 +2,8 @@ import MessageFormatter from '../helpers/MessageFormatter';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
methods: {
|
methods: {
|
||||||
formatMessage(message) {
|
formatMessage(message, isATweet) {
|
||||||
const messageFormatter = new MessageFormatter(message);
|
const messageFormatter = new MessageFormatter(message, isATweet);
|
||||||
return messageFormatter.formattedMessage;
|
return messageFormatter.formattedMessage;
|
||||||
},
|
},
|
||||||
truncateMessage(description = '') {
|
truncateMessage(description = '') {
|
||||||
|
|
|
@ -50,3 +50,7 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
v-if="!isCards && !isOptions && !isForm && !isArticle"
|
v-if="!isCards && !isOptions && !isForm && !isArticle"
|
||||||
class="chat-bubble agent"
|
class="chat-bubble agent"
|
||||||
>
|
>
|
||||||
<span v-html="formatMessage(message)"></span>
|
<span v-html="formatMessage(message, false)"></span>
|
||||||
<email-input
|
<email-input
|
||||||
v-if="isTemplateEmail"
|
v-if="isTemplateEmail"
|
||||||
:message-id="messageId"
|
:message-id="messageId"
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<div
|
<div
|
||||||
class="chat-bubble user"
|
class="chat-bubble user"
|
||||||
:style="{ background: widgetColor }"
|
:style="{ background: widgetColor }"
|
||||||
v-html="formatMessage(message)"
|
v-html="formatMessage(message, false)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -50,7 +50,10 @@ class Message < ApplicationRecord
|
||||||
incoming_email: 8
|
incoming_email: 8
|
||||||
}
|
}
|
||||||
enum status: { sent: 0, delivered: 1, read: 2, failed: 3 }
|
enum status: { sent: 0, delivered: 1, read: 2, failed: 3 }
|
||||||
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email], coder: JSON
|
# [:submitted_email, :items, :submitted_values] : Used for bot message types
|
||||||
|
# [:email] : Used by conversation_continuity incoming email messages
|
||||||
|
# [:in_reply_to] : Used to reply to a particular tweet in threads
|
||||||
|
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to], coder: JSON
|
||||||
|
|
||||||
# .succ is a hack to avoid https://makandracards.com/makandra/1057-why-two-ruby-time-objects-are-not-equal-although-they-appear-to-be
|
# .succ is a hack to avoid https://makandracards.com/makandra/1057-why-two-ruby-time-objects-are-not-equal-although-they-appear-to-be
|
||||||
scope :unread_since, ->(datetime) { where('EXTRACT(EPOCH FROM created_at) > (?)', datetime.to_i.succ) }
|
scope :unread_since, ->(datetime) { where('EXTRACT(EPOCH FROM created_at) > (?)', datetime.to_i.succ) }
|
||||||
|
|
|
@ -29,7 +29,7 @@ class Twitter::SendOnTwitterService < Base::SendOnChannelService
|
||||||
end
|
end
|
||||||
|
|
||||||
def screen_name
|
def screen_name
|
||||||
"@#{additional_attributes ? additional_attributes['screen_name'] : ''} "
|
"@#{reply_to_message.sender&.additional_attributes.try(:[], 'screen_name') || ''}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_direct_message
|
def send_direct_message
|
||||||
|
@ -39,10 +39,18 @@ class Twitter::SendOnTwitterService < Base::SendOnChannelService
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def reply_to_message
|
||||||
|
@reply_to_message ||= if message.in_reply_to
|
||||||
|
conversation.messages.find(message.in_reply_to)
|
||||||
|
else
|
||||||
|
conversation.messages.incoming.last
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def send_tweet_reply
|
def send_tweet_reply
|
||||||
response = twitter_client.send_tweet_reply(
|
response = twitter_client.send_tweet_reply(
|
||||||
reply_to_tweet_id: conversation.additional_attributes['tweet_id'],
|
reply_to_tweet_id: reply_to_message.source_id,
|
||||||
tweet: screen_name + message.content
|
tweet: "#{screen_name} #{message.content}"
|
||||||
)
|
)
|
||||||
if response.status == '200'
|
if response.status == '200'
|
||||||
tweet_data = response.body
|
tweet_data = response.body
|
||||||
|
|
|
@ -9,7 +9,7 @@ FactoryBot.define do
|
||||||
account { create(:account) }
|
account { create(:account) }
|
||||||
|
|
||||||
after(:build) do |message|
|
after(:build) do |message|
|
||||||
message.sender ||= create(:user, account: message.account)
|
message.sender ||= message.outgoing? ? create(:user, account: message.account) : create(:contact, account: message.account)
|
||||||
message.inbox ||= create(:inbox, account: message.account)
|
message.inbox ||= create(:inbox, account: message.account)
|
||||||
message.conversation ||= create(:conversation, account: message.account, inbox: message.inbox)
|
message.conversation ||= create(:conversation, account: message.account, inbox: message.inbox)
|
||||||
end
|
end
|
||||||
|
|
|
@ -20,7 +20,7 @@ describe Integrations::Slack::SendOnSlackService do
|
||||||
expect(slack_client).to receive(:chat_postMessage).with(
|
expect(slack_client).to receive(:chat_postMessage).with(
|
||||||
channel: hook.reference_id,
|
channel: hook.reference_id,
|
||||||
text: message.content,
|
text: message.content,
|
||||||
username: "Agent: #{message.sender.name}",
|
username: "Contact: #{message.sender.name}",
|
||||||
thread_ts: conversation.identifier,
|
thread_ts: conversation.identifier,
|
||||||
icon_url: anything
|
icon_url: anything
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in a new issue