Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] #1846 implement file attachment in chat #1878

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions frontend/controller/actions/chatroom.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { findKeyIdByName } from '~/shared/domains/chelonia/utils.js'
import { encryptedOutgoingData, encryptedOutgoingDataWithRawKey } from '~/shared/domains/chelonia/encryptedData.js'
// Using relative path to crypto.js instead of ~-path to workaround some esbuild bug
import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deserializeKey, keyId, keygen, serializeKey } from '../../../shared/domains/chelonia/crypto.js'
import { CHATROOM_ATTACHMENT_UPLOADED } from '@utils/events.js'
import type { GIRegParams } from './types.js'
import { encryptedAction, encryptedNotification } from './utils.js'

Expand Down Expand Up @@ -187,6 +188,22 @@ export default (sbp('sbp/selectors/register', {
}
}))
},
'gi.actions/chatroom/upload-chat-attachments': function (attachments: Array<any>) {
const objectURLtoBlob = url => {
// reference: https://stackoverflow.com/questions/11876175/how-to-get-a-file-or-blob-from-an-object-url
return fetch(url).then(r => r.blob())
}

attachments.forEach(async attachment => {
const { mimeType, url, attachmentId } = attachment // url here is an instance of URL.createObjectURL(), which needs to be converted to a 'Blob'

const attachmentBlob = await objectURLtoBlob(url)
const downloadData = await sbp('chelonia/fileUpload', attachmentBlob, {
type: mimeType, cipher: 'aes256gcm'
})
sbp('okTurtles.events/emit', CHATROOM_ATTACHMENT_UPLOADED, { attachmentId, downloadData })
})
},
...encryptedNotification('gi.actions/chatroom/user-typing-event', L('Failed to send typing notification')),
...encryptedNotification('gi.actions/chatroom/user-stop-typing-event', L('Failed to send stopped typing notification')),
...encryptedAction('gi.actions/chatroom/addMessage', L('Failed to add message.')),
Expand Down
21 changes: 18 additions & 3 deletions frontend/model/contracts/shared/functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,18 @@ export function createPaymentInfo (paymentHash: string, payment: Object): {
// chatroom.js related

export function createMessage ({ meta, data, hash, height, state, pending, innerSigningContractID }: {
meta: Object, data: Object, hash: string, height: number, state?: Object, pending?: boolean, innerSigningContractID?: String
meta: Object,
data: Object,
hash: string,
height: number,
state?: Object,
pending?: boolean,
innerSigningContractID?: String
}): Object {
const { type, text, replyingMessage } = data
const { type, text, replyingMessage, attachments } = data
const { createdDate } = meta

let newMessage = {
let newMessage: any = {
type,
hash,
height,
Expand All @@ -67,6 +73,15 @@ export function createMessage ({ meta, data, hash, height, state, pending, inner

if (type === MESSAGE_TYPES.TEXT) {
newMessage = !replyingMessage ? { ...newMessage, text } : { ...newMessage, text, replyingMessage }

if (attachments?.length) {
attachments.forEach(attachment => {
// once file-upload is completed, the objectURL is no longer in use.
// !!!TODO!!! : remove/update this logic while working on graceful chat-attachment handling.
delete attachment.url
})
newMessage.attachments = attachments
}
} else if (type === MESSAGE_TYPES.POLL) {
const pollData = data.pollData

Expand Down
4 changes: 4 additions & 0 deletions frontend/utils/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ export const CHAT_ATTACHMENT_SUPPORTED_EXTENSIONS = [
'.eot', '.otf', '.rtf', '.ttf', '.woff', '.woff2', // font
'.abw', '.azw', '.bin', '.csh', '.db', '.mpkg', '.vsd', '.xul' // miscellaneous
]

// NOTE: Below value was obtained from the '413 Payload Too Large' server error
// meaning if this limit is updated on the server-side, an update is required here too.
export const CHAT_ATTACHMENT_SIZE_LIMIT = 6291456 // in byte.
1 change: 1 addition & 0 deletions frontend/utils/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,6 @@ export const AVATAR_EDITED = 'avatar-edited'

export const THEME_CHANGE = 'theme-change'

export const CHATROOM_ATTACHMENT_UPLOADED = 'chatroom-attachment-uploaded'
export const CHATROOM_USER_TYPING = 'chatroom-user-typing'
export const CHATROOM_USER_STOP_TYPING = 'chatroom-user-stop-typing'
5 changes: 3 additions & 2 deletions frontend/views/containers/chatroom/ChatMain.vue
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
:messageId='message.id'
:messageHash='message.hash'
:text='message.text'
:attachments='message.attachments'
:type='message.type'
:notification='message.notification'
:proposal='message.proposal'
Expand Down Expand Up @@ -386,13 +387,13 @@ export default ({
this.ephemeral.replyingMessageHash = null
this.ephemeral.replyingTo = null
},
handleSendMessage (message) {
handleSendMessage (message, attachments) {
const replyingMessage = this.ephemeral.replyingMessageHash
? { hash: this.ephemeral.replyingMessageHash, text: this.ephemeral.replyingMessage }
: null
// Consider only simple TEXT now
// TODO: implement other types of messages later
const data = { type: MESSAGE_TYPES.TEXT, text: message }
const data = { type: MESSAGE_TYPES.TEXT, text: message, attachments }

const contractID = this.currentChatRoomId
// Call 'gi.actions/chatroom/addMessage' action with necessary data
Expand Down
1 change: 1 addition & 0 deletions frontend/views/containers/chatroom/Message.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export default ({
messageHash: String,
type: String,
text: String,
attachments: Array,
who: String,
currentUserID: String,
avatar: [Object, String],
Expand Down
18 changes: 17 additions & 1 deletion frontend/views/containers/chatroom/MessageBase.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@
) {{ objText.text }}
i18n.c-edited(v-if='edited') (edited)

.c-attachments-wrapper(v-if='hasAttachments')
chat-attachment-preview(
:attachmentList='attachments'
:isForDownload='true'
)
.c-full-width-body
slot(name='full-width-body')

Expand Down Expand Up @@ -86,6 +91,7 @@ import emoticonsMixins from './EmoticonsMixins.js'
import MessageActions from './MessageActions.vue'
import MessageReactions from './MessageReactions.vue'
import SendArea from './SendArea.vue'
import ChatAttachmentPreview from './file-attachment/ChatAttachmentPreview.vue'
import { humanDate } from '@model/contracts/shared/time.js'
import { makeMentionFromUserID } from '@model/contracts/shared/functions.js'
import { MESSAGE_TYPES } from '@model/contracts/shared/constants.js'
Expand All @@ -99,7 +105,8 @@ export default ({
Avatar,
MessageActions,
MessageReactions,
SendArea
SendArea,
ChatAttachmentPreview
},
data () {
return {
Expand All @@ -109,6 +116,7 @@ export default ({
props: {
height: Number,
text: String,
attachments: Array,
messageHash: String,
replyingMessage: String,
who: String,
Expand Down Expand Up @@ -137,6 +145,9 @@ export default ({
},
replyMessageObjects () {
return this.generateTextObjectsFromText(this.replyingMessage)
},
hasAttachments () {
return Boolean(this.attachments?.length)
}
},
methods: {
Expand Down Expand Up @@ -309,6 +320,11 @@ export default ({
}
}

.c-attachments-wrapper {
position: relative;
margin-top: 0.25rem;
}

.c-focused {
animation: focused 1s linear 0.5s;
}
Expand Down
94 changes: 59 additions & 35 deletions frontend/views/containers/chatroom/SendArea.vue
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@
)

chat-attachment-preview(
v-if='ephemeral.attachment.length'
:attachmentList='ephemeral.attachment'
v-if='ephemeral.attachments.length'
:attachmentList='ephemeral.attachments'
@remove='removeAttachment'
)

Expand Down Expand Up @@ -246,9 +246,9 @@ import Tooltip from '@components/Tooltip.vue'
import ChatAttachmentPreview from './file-attachment/ChatAttachmentPreview.vue'
import { makeMentionFromUsername } from '@model/contracts/shared/functions.js'
import { CHATROOM_PRIVACY_LEVEL } from '@model/contracts/shared/constants.js'
import { CHAT_ATTACHMENT_SUPPORTED_EXTENSIONS } from '~/frontend/utils/constants.js'
import { OPEN_MODAL, CHATROOM_USER_TYPING, CHATROOM_USER_STOP_TYPING } from '@utils/events.js'
import { uniq, throttle } from '@model/contracts/shared/giLodash.js'
import { CHAT_ATTACHMENT_SUPPORTED_EXTENSIONS, CHAT_ATTACHMENT_SIZE_LIMIT } from '~/frontend/utils/constants.js'
import { OPEN_MODAL, CHATROOM_USER_TYPING, CHATROOM_USER_STOP_TYPING, CHATROOM_ATTACHMENT_UPLOADED } from '@utils/events.js'
import { uniq, throttle, cloneDeep, randomHexString } from '@model/contracts/shared/giLodash.js'
import { injectOrStripSpecialChar, injectOrStripLink } from '@view-utils/convert-to-markdown.js'

const caretKeyCodes = {
Expand Down Expand Up @@ -306,7 +306,7 @@ export default ({
options: [],
index: -1
},
attachment: [], // [ { url: instace of URL.createObjectURL , name: string }, ... ]
attachments: [], // [ { url: instace of URL.createObjectURL , name: string }, ... ]
typingUsers: []
},
typingUserTimeoutIds: {},
Expand Down Expand Up @@ -370,7 +370,9 @@ export default ({
})
},
isActive () {
return this.ephemeral.textWithLines
return this.hasAttachments
? this.ephemeral.attachments.every(attachment => Boolean(attachment.downloadData))
: this.ephemeral.textWithLines
},
textareaStyles () {
return {
Expand Down Expand Up @@ -400,6 +402,9 @@ export default ({
} else {
return null
}
},
hasAttachments () {
return this.ephemeral.attachments.length > 0
}
},
methods: {
Expand Down Expand Up @@ -548,22 +553,11 @@ export default ({
this.$emit('stop-replying')
},
sendMessage () {
const hasAttachments = this.ephemeral.attachment.length > 0
const getName = entry => entry.name

if (!this.$refs.textarea.value && !hasAttachments) { // nothing to send
if (!this.isActive) { // nothing to send
return false
}

let msgToSend = this.$refs.textarea.value || ''
if (hasAttachments) {
// TODO: remove this block and implement file-attachment properly once it's implemented in the back-end.
msgToSend = msgToSend +
(msgToSend ? '\r\n' : '') +
`{ Attached: ${this.ephemeral.attachment.map(getName).join(', ')} } - Feature coming soon!`

this.clearAllAttachments()
}

/* Process mentions in the form @username => @userID */
const mentionStart = makeMentionFromUsername('').all[0]
Expand All @@ -577,10 +571,15 @@ export default ({
}
)

this.$emit('send', msgToSend) // TODO remove first / last empty lines
this.$emit(
'send',
msgToSend,
this.hasAttachments ? cloneDeep(this.ephemeral.attachments) : undefined
) // TODO remove first / last empty lines
this.$refs.textarea.value = ''
this.updateTextArea()
this.endMention()
if (this.hasAttachments) { this.clearAllAttachments() }
},
openCreatePollModal () {
const bbox = this.$el.getBoundingClientRect()
Expand All @@ -600,9 +599,9 @@ export default ({
const lastDotIndex = name.lastIndexOf('.')
return lastDotIndex === -1 ? '' : name.substring(lastDotIndex).toLowerCase()
}
const attachmentsExist = Boolean(this.ephemeral.attachment.length)
const attachmentsExist = Boolean(this.ephemeral.attachments.length)
const list = appendItems && attachmentsExist
? [...this.ephemeral.attachment]
? [...this.ephemeral.attachments]
: []

if (attachmentsExist) {
Expand All @@ -615,38 +614,63 @@ export default ({
const fileUrl = URL.createObjectURL(file)
const fileSize = file.size

if (fileSize > Math.pow(10, 9)) {
// TODO: update Math.pow(10, 9) above with the value delivered from the server once it's implemented there.
if (fileSize > CHAT_ATTACHMENT_SIZE_LIMIT) {
return sbp('okTurtles.events/emit', OPEN_MODAL, 'ChatFileAttachmentWarningModal', { type: 'large' })
} else if (!fileExt || !CHAT_ATTACHMENT_SUPPORTED_EXTENSIONS.includes(fileExt)) {
// Give users a warning about unsupported file types
return sbp('okTurtles.events/emit', OPEN_MODAL, 'ChatFileAttachmentWarningModal', { type: 'unsupported' })
}

// !@#
list.push({
url: fileUrl,
name: file.name,
extension: fileExt,
attachType: file.type.match('image/') ? 'image' : 'non-image'
mimeType: file.type || '',
attachmentId: randomHexString(15),
attachType: file.type.match('image/') ? 'image' : 'non-image',
downloadData: null // NOTE: we can tell if the attachment has been uploaded by seeing if this field is non-null.
})
}

this.ephemeral.attachment = list
this.ephemeral.attachments = list

// Start uploading the attached files to the server.
sbp('gi.actions/chatroom/upload-chat-attachments', this.ephemeral.attachments)

// Set up an event listener for the completion of upload.
const uplooadCompleteHandler = ({ attachmentId, downloadData }) => {
// update ephemeral.attachments with the passed download data.
this.ephemeral.attachments = this.ephemeral.attachments.map(entry => {
if (entry.attachmentId === attachmentId) {
return {
...entry,
downloadData
}
} else return entry
})

if (this.ephemeral.attachments.every(entry => Boolean(entry.downloadData))) {
// if all attachments have been uploaded, destory the event listener.
sbp('okTurtles.events/off', CHATROOM_ATTACHMENT_UPLOADED)
}
}
sbp('okTurtles.events/on', CHATROOM_ATTACHMENT_UPLOADED, uplooadCompleteHandler)
},
clearAllAttachments () {
this.ephemeral.attachment.forEach(attachment => {
this.ephemeral.attachments.forEach(attachment => {
URL.revokeObjectURL(attachment.url)
})
this.ephemeral.attachment = []
this.ephemeral.attachments = []
},
removeAttachment (targetUrl) {
// when a URL is no longer needed, it needs to be released from the memory.
// (reference: https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static#memory_management)
const targetIndex = this.ephemeral.attachment.findIndex(entry => targetUrl === entry.url)
const targetIndex = this.ephemeral.attachments.findIndex(entry => targetUrl === entry.url)

if (targetIndex >= 0) {
URL.revokeObjectURL(targetUrl)
this.ephemeral.attachment.splice(targetIndex, 1)
this.ephemeral.attachments.splice(targetIndex, 1)
}
},
selectEmoticon (emoticon) {
Expand Down Expand Up @@ -1026,13 +1050,13 @@ export default ({
align-items: center;
border-radius: 0.25rem;

&:hover {
color: var(--primary_0);
cursor: pointer;
}

&.isActive {
background: $primary_0;

&:hover {
color: $general_1;
cursor: pointer;
}
}
}

Expand Down
Loading
Loading