import { h, Component } from 'preact'; import PropTypes from 'prop-types'; import { conductModeration, getAllMessages, sendMessage, sendOpen, getChannels, getContent, getChannelInvites, sendChannelInviteAction, } from './actions'; import { hideMessages, scrollToBottom, setupObserver, setupNotifications, getNotificationState, } from './util'; import Alert from './alert'; import Channels from './channels'; import Compose from './compose'; import Message from './message'; import Content from './content'; import Video from './video'; import View from './view'; import setupPusher from '../src/utils/pusher'; export default class Chat extends Component { static propTypes = { pusherKey: PropTypes.number.isRequired, chatChannels: PropTypes.array.isRequired, chatOptions: PropTypes.object.isRequired, }; constructor(props) { super(props); const chatChannels = JSON.parse(this.props.chatChannels); const chatOptions = JSON.parse(this.props.chatOptions); this.state = { messages: [], scrolled: false, showAlert: false, chatChannels, filterQuery: '', channelTypeFilter: 'all', channelsLoaded: false, channelPaginationNum: 0, fetchingPaginatedChannels: false, activeChannelId: chatOptions.activeChannelId, activeChannel: null, showChannelsList: chatOptions.showChannelsList, showTimestamp: chatOptions.showTimestamp, notificationsPermission: null, activeContent: {}, expanded: window.innerWidth > 600, isMobileDevice: typeof window.orientation !== 'undefined', subscribedPusherChannels: [], activeVideoChannelId: null, incomingVideoCallChannelIds: [], nonChatView: null, inviteChannels: [], }; } componentDidMount() { this.state.chatChannels.forEach((channel, index) => { if (index < 3) { this.setupChannel(channel.id); } if (channel.channel_type === 'open') { this.subscribePusher(`open-channel-${channel.id}`); } }); setupObserver(this.observerCallback); this.subscribePusher( `private-message-notifications-${window.currentUser.id}`, ); if (this.state.activeChannelId) { sendOpen(this.state.activeChannelId, this.handleChannelOpenSuccess, null); } this.setState({ notificationsPermission: getNotificationState(), }); if (this.state.showChannelsList) { const filters = this.state.channelTypeFilter === 'all' ? {} : { filters: `channel_type:${ this.state.channelTypeFilter}` }; getChannels( '', this.state.activeChannelId, this.props, this.state.channelPaginationNum, filters, this.loadChannels, ); } if (!this.state.isMobileDevice) { document.getElementById('messageform').focus(); } if (document.getElementById('chatchannels__channelslist')) { document .getElementById('chatchannels__channelslist') .addEventListener('scroll', this.handleChannelScroll); } getChannelInvites(this.handleChannelInvites, null); } componentDidUpdate() { if (document.getElementById('messagelist')) { if (!this.state.scrolled) { scrollToBottom(); } } } liveCoding = e => { if (this.state.activeContent != { type_of: 'code_editor' }) { const newActiveContent = this.state.activeContent; newActiveContent[this.state.activeChannelId] = { type_of: 'code_editor' }; this.setState({ activeContent: newActiveContent }); } if (document.querySelector('.CodeMirror')) { const cm = document.querySelector('.CodeMirror').CodeMirror; if (cm && e.context === 'initializing-live-code-channel') { window.pusher.channel(e.channel).trigger('client-livecode', { value: cm.getValue(), cursorPos: cm.getCursor(), }); } else if ((cm && e.keyPressed === true) || e.value.length > 0) { const cursorCoords = e.cursorPos; const cursorElement = document.createElement('span'); cursorElement.classList.add('cursorelement'); cursorElement.style.height = `${cursorCoords.bottom - cursorCoords.top}px`; cm.setValue(e.value); cm.setBookmark(e.cursorPos, { widget: cursorElement }); } } }; filterForActiveChannel = (channels, id) => channels.filter(channel => channel.id === parseInt(id))[0]; subscribePusher = channelName => { if (this.state.subscribedPusherChannels.includes(channelName)) { } else { setupPusher(this.props.pusherKey, { channelId: channelName, messageCreated: this.receiveNewMessage, channelCleared: this.clearChannel, redactUserMessages: this.redactUserMessages, channelError: this.channelError, liveCoding: this.liveCoding, videoCallInitiated: this.receiveVideoCall, videoCallEnded: this.receiveVideoCallHangup, }); const subscriptions = this.state.subscribedPusherChannels; subscriptions.push(channelName); this.setState({ subscribedPusherChannels: subscriptions }); } }; loadChannels = (channels, query) => { if (this.state.activeChannelId && query.length === 0) { this.setupChannel(this.state.activeChannelId); this.setState({ chatChannels: channels, scrolled: false, channelsLoaded: true, channelPaginationNum: 0, filterQuery: '', activeChannel: this.state.activeChannel || this.filterForActiveChannel(channels, this.state.activeChannelId), }); } else if (this.state.activeChannelId) { this.setupChannel(this.state.activeChannelId); this.setState({ scrolled: false, chatChannels: channels, channelsLoaded: true, channelPaginationNum: 0, filterQuery: query, }); } else if (channels.length > 0) { this.setState({ chatChannels: channels, channelsLoaded: true, channelPaginationNum: 0, filterQuery: query || '', scrolled: false, }); const channel = channels[0]; const channelSlug = channel.channel_type === 'direct' ? `@${ channel.slug .replace(`${window.currentUser.username}/`, '') .replace(`/${window.currentUser.username}`, '')}` : channel.slug; this.triggerSwitchChannel(channel.id, channelSlug); } else { this.setState({ channelsLoaded: true }); } channels.forEach((channel, index) => { if (index < 3) { this.setupChannel(channel.id); } if (channel.channel_type === 'invite_only') { this.subscribePusher(`presence-channel-${channel.id}`); } }); document.getElementById('chatchannels__channelslist').scrollTop = 0; }; loadPaginatedChannels = channels => { const currentChannels = this.state.chatChannels; const currentChannelIds = currentChannels.map((channel, index) => channel.id); const newChannels = currentChannels; channels.forEach((channel, index) => { if (!currentChannelIds.includes(channel.id)) { newChannels.push(channel); } }); if ( currentChannels.length === newChannels.length && this.state.channelPaginationNum > 3 ) { return; } this.setState({ chatChannels: newChannels, fetchingPaginatedChannels: false, channelPaginationNum: this.state.channelPaginationNum + 1, }); }; setupChannel = channelId => { if ( !this.state.messages[channelId] || this.state.messages[channelId].length === 0 || this.state.messages[channelId][0].reception_method === 'pushed' ) { getAllMessages(channelId, this.receiveAllMessages); } this.subscribePusher(`presence-channel-${channelId}`); }; observerCallback = entries => { entries.forEach(entry => { if (entry.isIntersecting) { this.setState({ scrolled: false, showAlert: false }); } else { this.setState({ scrolled: true }); } }); }; channelError = error => { this.setState({ subscribedPusherChannels: [], }); }; receiveAllMessages = res => { const { chatChannelId, messages } = res; const newMessages = { ...this.state.messages, [chatChannelId]: messages }; this.setState({ messages: newMessages, scrolled: false }); }; receiveNewMessage = message => { const receivedChatChannelId = message.chat_channel_id; if (!this.state.messages[receivedChatChannelId]) { return; } const newMessages = this.state.messages[receivedChatChannelId].slice(); newMessages.push(message); if (newMessages.length > 150) { newMessages.shift(); } const newShowAlert = this.state.activeChannelId === receivedChatChannelId ? { showAlert: this.state.scrolled } : {}; let newMessageChannelIndex = 0; let newMessageChannel = null; const newChannelsObj = this.state.chatChannels.map((channel, index) => { if (receivedChatChannelId === channel.id) { channel.last_message_at = new Date(); newMessageChannelIndex = index; newMessageChannel = channel; } return channel; }); if (newMessageChannelIndex > 0) { newChannelsObj.splice(newMessageChannelIndex, 1); newChannelsObj.unshift(newMessageChannel); } if (receivedChatChannelId === this.state.activeChannelId) { sendOpen(receivedChatChannelId, this.handleChannelOpenSuccess, null); } this.setState({ ...newShowAlert, chatChannels: newChannelsObj, messages: { ...this.state.messages, [receivedChatChannelId]: newMessages, }, }); }; receiveVideoCall = callObj => { const incomingCalls = this.state.incomingVideoCallChannelIds; incomingCalls.push(callObj.channelId); this.setState({ incomingVideoCallChannelIds: incomingCalls }); }; receiveVideoCallHangup = () => { this.setState({ activeVideoChannelId: null }); }; redactUserMessages = res => { // This is shallow clone. This might cause a problem const clonedMessages = Object.assign({}, this.state.messages); const newMessages = hideMessages(clonedMessages, res.userId); this.setState({ messages: newMessages }); }; clearChannel = res => { const newMessages = { ...this.state.messages, [res.chat_channel_id]: [] }; this.setState({ messages: newMessages }); }; handleChannelScroll = e => { if ( this.state.fetchingPaginatedChannels || this.state.chatChannels.length < 30 ) { return; } const target = e.target; if (target.scrollTop + target.offsetHeight + 1800 > target.scrollHeight) { this.setState({ fetchingPaginatedChannels: true }); const filters = this.state.channelTypeFilter === 'all' ? {} : { filters: `channel_type:${ this.state.channelTypeFilter}` }; getChannels( this.state.filterQuery, this.state.activeChannelId, this.props, this.state.channelPaginationNum, filters, this.loadPaginatedChannels, ); } }; handleChannelInvites = response => { this.setState({ inviteChannels: response }); }; handleKeyDown = 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.handleMessageSubmit(e.target.value); e.target.value = ''; } } }; handleMessageSubmit = message => { // should check if user has the priviledge if (message.startsWith('/code')) { const newActiveContent = this.state.activeContent; newActiveContent[this.state.activeChannelId] = { type_of: 'code_editor' }; this.setState({ activeContent: newActiveContent }); } else if (message.startsWith('/call')) { this.setState({ activeVideoChannelId: this.state.activeChannelId }); window.pusher .channel(`presence-channel-${this.state.activeChannelId}`) .trigger('client-initiatevideocall', { channelId: this.state.activeChannelId, }); } else if (message.startsWith('/github')) { const args = message.split('/github ')[1].trim(); const newActiveContent = this.state.activeContent; newActiveContent[this.state.activeChannelId] = { type_of: 'github', args, }; this.setState({ activeContent: newActiveContent }); } else if (message[0] === '/') { conductModeration( this.state.activeChannelId, message, this.handleSuccess, this.handleFailure, ); } else { sendMessage( this.state.activeChannelId, message, this.handleSuccess, this.handleFailure, ); } }; handleSwitchChannel = e => { e.preventDefault(); let target = e.target; if (!target.dataset.channelId) { target = target.parentElement; } this.triggerSwitchChannel( target.dataset.channelId, target.dataset.channelSlug, ); }; answerVideoCall = () => { this.setState({ activeVideoChannelId: this.state.activeChannelId, incomingVideoCallChannelIds: [], }); }; hangupVideoCall = () => { window.pusher .channel(`presence-channel-${this.state.activeVideoChannelId}`) .trigger('client-endvideocall', {}); this.setState({ activeVideoChannelId: null, }); }; triggerSwitchChannel = (id, slug) => { this.setState({ activeChannel: this.filterForActiveChannel(this.state.chatChannels, id), activeChannelId: parseInt(id), scrolled: false, showAlert: false, }); this.setupChannel(id); window.history.replaceState(null, null, `/connect/${ slug}`); if (!this.state.isMobileDevice) { document.getElementById('messageform').focus(); } if (window.ga && ga.create) { ga('send', 'pageview', location.pathname + location.search); } sendOpen(id, this.handleChannelOpenSuccess, null); }; handleSubmitOnClick = e => { e.preventDefault(); const message = document.getElementById('messageform').value; if (message.length > 0) { this.handleMessageSubmit(message); document.getElementById('messageform').value = ''; } }; handleSuccess = response => { if (response.status === 'error') { this.receiveNewMessage(response.message); } }; triggerNotificationRequest = e => { const context = this; Notification.requestPermission((permission) => { if (permission === 'granted') { context.setState({ notificationsPermission: 'granted' }); setupNotifications(); } }); }; triggerActiveContent = e => { if ( // Trying to open in new tab e.ctrlKey || e.shiftKey || e.metaKey || // apple (e.button && e.button == 1) // middle click, >IE9 + everyone else ) { return; } const target = e.target; if (target.dataset.content) { e.preventDefault(); e.stopPropagation(); const newActiveContent = this.state.activeContent; if (target.dataset.content === 'channel-details') { newActiveContent[this.state.activeChannelId] = { type_of: 'channel-details', channel: this.state.activeChannel, }; this.setState({ activeContent: newActiveContent }); } else if (target.dataset.content.startsWith('users/')) { newActiveContent[this.state.activeChannelId] = { type_of: 'loading-user', }; getContent( `/api/${ target.dataset.content}`, this.setActiveContent, null, ); } else if (target.dataset.content.startsWith('articles/')) { newActiveContent[this.state.activeChannelId] = { type_of: 'loading-post', }; getContent( `/api/${ target.dataset.content}`, this.setActiveContent, null, ); } else if (target.dataset.content === 'exit') { newActiveContent[this.state.activeChannelId] = null; this.setState({ activeContent: newActiveContent }); } } return false; }; setActiveContent = response => { const newActiveContent = this.state.activeContent; newActiveContent[this.state.activeChannelId] = response; this.setState({ activeContent: newActiveContent }); setTimeout(() => { document.getElementById('chat_activecontent').scrollTop = 0; document.getElementById('chat').scrollLeft = 1000; }, 3); setTimeout(() => { document.getElementById('chat_activecontent').scrollTop = 0; document.getElementById('chat').scrollLeft = 1000; }, 10); }; handleChannelOpenSuccess = response => { const newChannelsObj = this.state.chatChannels.map(channel => { if (parseInt(response.channel) === channel.id) { channel.last_opened_at = new Date(); } return channel; }); this.setState({ chatChannels: newChannelsObj }); }; handleInvitationAccept = e => { const id = e.target.dataset.content; sendChannelInviteAction(id, 'accept', this.handleChannelInviteResult, null); }; handleInvitationDecline = e => { const id = e.target.dataset.content; sendChannelInviteAction( id, 'decline', this.handleChannelInviteResult, null, ); }; handleChannelInviteResult = response => { this.setState({ inviteChannels: response }); }; triggerChannelTypeFilter = e => { const type = e.target.dataset.channelType; this.setState({ channelTypeFilter: type, fetchingPaginatedChannels: false, }); const filters = type === 'all' ? {} : { filters: `channel_type:${ type}` }; getChannels( this.state.filterQuery, null, this.props, 0, filters, this.loadChannels, ); }; triggerNonChatView = e => { this.setState({ nonChatView: e.target.dataset.content }); }; triggerExitView = () => { this.setState({ nonChatView: null }); }; handleFailure = err => { console.error(err); }; renderMessages = () => { const { activeChannelId, messages, showTimestamp, activeChannel, } = this.state; if (!messages[activeChannelId]) { return; } if (messages[activeChannelId].length === 0 && activeChannel) { if (activeChannel.channel_type === 'direct') { const channelUsername = activeChannel.slug .replace(`${window.currentUser.username}/`, '') .replace(`/${window.currentUser.username}`, ''); return (