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

Improve behavior of sending messages with attachments #1877

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e45f322
add 'gi.actions/chatroom/upload-chat-attachments' chatroom action for…
SebinSong Feb 27, 2024
9b6a666
implement file-upload and add loader ani to indicate the status
SebinSong Feb 29, 2024
44a167e
disable 'Send' while attachment is being uploaded
SebinSong Feb 29, 2024
aaff9fa
add attachment preview to MessageBase.vue
SebinSong Feb 29, 2024
4df4aeb
implement size-limit for upload
SebinSong Mar 1, 2024
56340c2
implement file-download from the chat - partially working
SebinSong Mar 3, 2024
1b0dc0f
feat: contract for attachments & UI for non-image attachments
Silver-IT Mar 4, 2024
1f56fab
feat: added event listeners for the attachments
Silver-IT Mar 4, 2024
298d8ef
feat: improved attachment actions style
Silver-IT Mar 4, 2024
33b77ed
fix: styling error in mobile view
Silver-IT Mar 4, 2024
fffcefb
feat: styles for attachment in mobile and of pending messages
Silver-IT Mar 5, 2024
ad77af1
feat: append pending message temporarily until the uploading attachme…
Silver-IT Mar 5, 2024
ea12274
Merge branch 'master' into 1845-improve-behavior-of-sending-messages-…
Silver-IT Mar 5, 2024
a4d2795
fix: resolved conflicts
Silver-IT Mar 5, 2024
2321903
fix: error when param is undefined
Silver-IT Mar 6, 2024
a6a797e
feat: attachment style updated
Silver-IT Mar 6, 2024
b27727b
feat: implemented uploading attachments
Silver-IT Mar 6, 2024
d6c17f2
fix: type incorrection
Silver-IT Mar 6, 2024
30ef55c
feat: file attachments for images
Silver-IT Mar 6, 2024
446fb42
fix: error in dnd files which are already attached
Silver-IT Mar 7, 2024
8c73a94
chore: removed unnecessary codes
Silver-IT Mar 7, 2024
5297216
feat: resolved feedback
Silver-IT Mar 8, 2024
d5c07d4
feat: resolved feedback
Silver-IT Mar 10, 2024
ed6d3a7
Merge branch 'master' into 1845-improve-behavior-of-sending-messages-…
Silver-IT Mar 10, 2024
22bad6e
chore: simplified code
Silver-IT Mar 11, 2024
0be8ceb
fix: error while dragging the images inside the messages
Silver-IT Mar 11, 2024
f582012
Merge branch 'master' into 1845-improve-behavior-of-sending-messages-…
Silver-IT Mar 12, 2024
a71a83f
Update @exact-realty/rfc8188 to the latest version
corrideat Mar 13, 2024
35d18be
Close test stream
corrideat Mar 14, 2024
d8aabbb
Documentation
corrideat Mar 14, 2024
828e9e4
feat: handled exception when uploading failed
Silver-IT Mar 14, 2024
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
20 changes: 16 additions & 4 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 @@ -66,7 +72,13 @@ export function createMessage ({ meta, data, hash, height, state, pending, inner
}

if (type === MESSAGE_TYPES.TEXT) {
newMessage = !replyingMessage ? { ...newMessage, text } : { ...newMessage, text, replyingMessage }
newMessage = { ...newMessage, text }
if (replyingMessage) {
newMessage = { ...newMessage, replyingMessage }
}
if (attachments) {
newMessage = { ...newMessage, attachments }
}
} else if (type === MESSAGE_TYPES.POLL) {
const pollData = data.pollData

Expand Down
10 changes: 9 additions & 1 deletion frontend/model/contracts/shared/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import {
objectOf, objectMaybeOf, arrayOf, unionOf,
string, optional, number, mapOf, literalOf
object, string, optional, number, mapOf, literalOf
} from '~/frontend/model/contracts/misc/flowTyper.js'
import {
CHATROOM_TYPES, CHATROOM_PRIVACY_LEVEL,
Expand Down Expand Up @@ -44,6 +44,14 @@ export const messageType: any = objectMaybeOf({
type: unionOf(...Object.values(MESSAGE_NOTIFICATIONS).map(v => literalOf(v))),
params: mapOf(string, string) // { username }
}),
attachments: arrayOf(objectOf({
name: string,
mimeType: string,
downloadData: objectOf({
manifestCid: string,
downloadParams: optional(object)
})
})),
replyingMessage: objectOf({
hash: string, // scroll to the original message and highlight
text: string // display text(if too long, truncate)
Expand Down
21 changes: 13 additions & 8 deletions frontend/utils/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@

export const CHAT_ATTACHMENT_SUPPORTED_EXTENSIONS = [
// reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
'.avif', '.bmp', '.gif', '.ico', '.jpeg', '.jpg', '.png', '.svg', '.tif', '.tiff', '.webp', // image
'.aac', '.cda', '.mid', '.midi', '.mp3', '.oga', '.opus', '.wav', '.weba', // audio
'.avi', '.mov', '.mp4', '.mpeg', '.ogv', '.ogx', '.ts', '.webm', '.3gp', '.3g2', // video
'.arc', '.bz', '.bz2', '.epub', '.gz', '.jar', '.rar', '.tar', '.zip', '.7z', // archive
'.csh', '.css', '.csv', '.doc', '.docx', '.htm', '.html', '.ics', '.js', '.json', '.jsonld', '.md', '.mjs',
'.odp', '.ods', '.odt', '.pdf', '.php', '.ppt', '.pptx', '.sh', '.txt', '.xhtml', '.xls', '.xlsx', '.xml', // common text/document
'.eot', '.otf', '.rtf', '.ttf', '.woff', '.woff2', // font
'.abw', '.azw', '.bin', '.csh', '.db', '.mpkg', '.vsd', '.xul' // miscellaneous
'avif', 'bmp', 'gif', 'ico', 'jpeg', 'jpg', 'png', 'svg', 'tif', 'tiff', 'webp', // image
'aac', 'cda', 'mid', 'midi', 'mp3', 'oga', 'opus', 'wav', 'weba', // audio
'avi', 'mov', 'mp4', 'mpeg', 'ogv', 'ogx', 'ts', 'webm', '3gp', '3g2', // video
'arc', 'bz', 'bz2', 'epub', 'gz', 'jar', 'rar', 'tar', 'zip', '7z', // archive
'csh', 'css', 'csv', 'doc', 'docx', 'htm', 'html', 'ics', 'js', 'json', 'jsonld', 'md', 'mjs', 'vue', 'ts', 'scss',
'odp', 'ods', 'odt', 'pdf', 'php', 'ppt', 'pptx', 'sh', 'txt', 'xhtml', 'xls', 'xlsx', 'xml', // common text/document
'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.
// TODO: fetch this value from a server API
export const CHAT_ATTACHMENT_SIZE_LIMIT = 6291456 // in byte.
5 changes: 5 additions & 0 deletions frontend/utils/image.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

import sbp from '@sbp/sbp'

// Copied from https://stackoverflow.com/questions/11876175/how-to-get-a-file-or-blob-from-an-object-url
export function objectURLtoBlob (url: string): Promise<Blob> {
return fetch(url).then(r => r.blob())
}

// Copied from https://stackoverflow.com/a/27980815/4737729
export function imageDataURItoBlob (dataURI: string): Blob {
const [prefix, data] = dataURI.split(',')
Expand Down
174 changes: 124 additions & 50 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 All @@ -77,7 +78,7 @@
:avatar='avatar(message.from)'
:variant='variant(message)'
:isSameSender='isSameSender(index)'
:isCurrentUser='isCurrentUser(message.from)'
:isMsgSender='isMsgSender(message.from)'
:class='{removed: message.delete}'
@retry='retryMessage(index)'
@reply='replyMessage(message)'
Expand Down Expand Up @@ -117,7 +118,6 @@ import { mapGetters } from 'vuex'
import { Vue } from '@common/common.js'
import Avatar from '@components/Avatar.vue'
import InfiniteLoading from 'vue-infinite-loading'
import Loading from '@components/Loading.vue'
import Message from './Message.vue'
import MessageInteractive from './MessageInteractive.vue'
import MessageNotification from './MessageNotification.vue'
Expand All @@ -133,10 +133,11 @@ import {
CHATROOM_ACTIONS_PER_PAGE,
CHATROOM_MAX_ARCHIVE_ACTION_PAGES
} from '@model/contracts/shared/constants.js'
import { findMessageIdx } from '@model/contracts/shared/functions.js'
import { findMessageIdx, createMessage } from '@model/contracts/shared/functions.js'
import { proximityDate, MINS_MILLIS } from '@model/contracts/shared/time.js'
import { cloneDeep, debounce, throttle } from '@model/contracts/shared/giLodash.js'
import { EVENT_HANDLED } from '~/shared/domains/chelonia/events.js'
import { objectURLtoBlob } from '@utils/image.js'

const ignorableScrollDistanceInPixel = 500

Expand Down Expand Up @@ -210,7 +211,6 @@ export default ({
ConversationGreetings,
Emoticons,
InfiniteLoading,
Loading,
Message,
MessageInteractive,
MessageNotification,
Expand Down Expand Up @@ -344,11 +344,11 @@ export default ({
[MESSAGE_TYPES.POLL]: 'message-poll'
}[message.type]
},
isCurrentUser (from) {
isMsgSender (from) {
return this.currentUserAttr.id === from
},
who (message) {
const user = this.isCurrentUser(message.from) ? this.currentUserAttr : this.summary.participants[message.from]
const user = this.isMsgSender(message.from) ? this.currentUserAttr : this.summary.participants[message.from]
return user?.displayName || user?.username || message.from
},
variant (message) {
Expand All @@ -357,7 +357,7 @@ export default ({
} else if (message.hasFailed) {
return MESSAGE_VARIANTS.FAILED
} else {
return this.isCurrentUser(message.from) ? MESSAGE_VARIANTS.SENT : MESSAGE_VARIANTS.RECEIVED
return this.isMsgSender(message.from) ? MESSAGE_VARIANTS.SENT : MESSAGE_VARIANTS.RECEIVED
}
},
replyingMessage (message) {
Expand Down Expand Up @@ -386,59 +386,133 @@ export default ({
this.ephemeral.replyingMessageHash = null
this.ephemeral.replyingTo = null
},
handleSendMessage (message) {
handleSendMessage (text, attachments) {
const hasAttachments = attachments?.length > 0
const contractID = this.currentChatRoomId
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 contractID = this.currentChatRoomId
// Call 'gi.actions/chatroom/addMessage' action with necessary data
// to send the message
sbp('gi.actions/chatroom/addMessage', {
contractID,
let data = { type: MESSAGE_TYPES.TEXT, text }
if (replyingMessage) {
// If not replying to a message, use original data; otherwise, append
// replyingMessage to data.
data: !replyingMessage ? data : { ...data, replyingMessage },
hooks: {
// Define a 'beforeRequest' hook for additional processing before the
// request is made.
// IMPORTANT: This will call 'chelonia/in/processMessage' *BEFORE* the
// message has been received. This is intentional to mark yet-unsent
// messages as pending in the UI
prepublish: (message) => {
if (!this.checkEventSourceConsistency(contractID)) return
data = { ...data, replyingMessage }
}

// IMPORTANT: This is executed *BEFORE* the message is received over
// the network
sbp('okTurtles.eventQueue/queueEvent', 'chatroom-events', async () => {
if (!this.checkEventSourceConsistency(contractID)) return
Vue.set(this.messageState, 'contract', await sbp('chelonia/in/processMessage', message, this.messageState.contract))
}).catch((e) => {
console.error('Error sending message during pre-publish: ' + e.message)
})
const sendMessage = (beforePrePublish) => {
const prepublish = (message) => {
if (!this.checkEventSourceConsistency(contractID)) return

beforePrePublish?.()

this.stopReplying()
this.updateScroll()
},
beforeRequest: (message, oldMessage) => {
// IMPORTANT: This is executed *BEFORE* the message is received over
// the network
sbp('okTurtles.eventQueue/queueEvent', 'chatroom-events', async () => {
if (!this.checkEventSourceConsistency(contractID)) return
sbp('okTurtles.eventQueue/queueEvent', 'chatroom-events', () => {
if (!this.checkEventSourceConsistency(contractID)) return
const messageStateContract = this.messageState.contract
const msg = messageStateContract.messages.find(m => (m.hash === oldMessage.hash()))
if (!msg) return
msg.hash = message.hash()
msg.height = message.height()
})
Vue.set(this.messageState, 'contract', await sbp('chelonia/in/processMessage', message, this.messageState.contract))
}).catch((e) => {
console.error('Error sending message during pre-publish: ' + e.message)
})

this.stopReplying()
this.updateScroll()
}
const beforeRequest = (message, oldMessage) => {
if (!this.checkEventSourceConsistency(contractID)) return
sbp('okTurtles.eventQueue/queueEvent', 'chatroom-events', () => {
if (!this.checkEventSourceConsistency(contractID)) return
const messageStateContract = this.messageState.contract
const msg = messageStateContract.messages.find(m => (m.hash === oldMessage.hash()))
if (!msg) return
msg.hash = message.hash()
msg.height = message.height()
})
}
// Call 'gi.actions/chatroom/addMessage' action with necessary data to send the message
sbp('gi.actions/chatroom/addMessage', {
contractID,
data,
hooks: {
// Define a 'beforeRequest' hook for additional processing before the
// request is made.
// IMPORTANT: This will call 'chelonia/in/processMessage' *BEFORE* the
// message has been received. This is intentional to mark yet-unsent
// messages as pending in the UI
prepublish,
beforeRequest
}
}).catch((e) => {
console.error(`Error while publishing message for ${contractID}`, e)
alert(e?.message || e)
})
}
const uploadAttachments = async () => {
try {
const attachmentsToSend = await Promise.all(attachments.map(async (attachment) => {
const { mimeType, url, name } = 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'
})
return { name, mimeType, downloadData }
}))
data = { ...data, attachments: attachmentsToSend }

return true
} catch (e) {
console.log('[ChatMain.vue]: something went wrong while uploading attachments ', e)
return false
}
}).catch((e) => {
console.error(`Error while publishing message for ${contractID}`, e)
alert(e?.message || e)
})
}

if (!hasAttachments) {
sendMessage()
} else {
let temporaryMessage = null
sbp('gi.actions/chatroom/addMessage', {
contractID,
data,
hooks: {
preSendCheck: (message, state) => {
// NOTE: this preSendCheck does nothing except appending pending message
// temporarily until the uploading attachments is finished
// it always returns false, so it doesn't affect the contract state
const [, opV] = message.op()
const { meta } = opV.valueOf().valueOf()

temporaryMessage = createMessage({
meta,
data,
hash: message.hash(),
height: message.height(),
state: this.messageState.contract,
pending: true,
innerSigningContractID: this.ourIdentityContractId
})
this.messageState.contract.messages.push(temporaryMessage)
this.updateScroll()

return false
}
}
})
uploadAttachments().then((isUploaded) => {
const removeTemporaryMessage = () => {
// NOTE: remove temporary message which is created before uploading attachments
if (temporaryMessage) {
const msgIndex = findMessageIdx(temporaryMessage.hash, this.messageState.contract.messages)
this.messageState.contract.messages.splice(msgIndex, 1)
}
}
if (isUploaded) {
sendMessage(removeTemporaryMessage)
} else {
removeTemporaryMessage()
}
})
}
},
async scrollToMessage (messageHash, effect = true) {
if (!messageHash || !this.messages.length) {
Expand Down Expand Up @@ -841,7 +915,7 @@ export default ({
if (addedOrDeleted === 'ADDED' && this.messages.length) {
const isScrollable = this.$refs.conversation &&
this.$refs.conversation.scrollHeight !== this.$refs.conversation.clientHeight
const fromOurselves = this.isCurrentUser(this.messages[this.messages.length - 1].from)
const fromOurselves = this.isMsgSender(this.messages[this.messages.length - 1].from)
if (!fromOurselves && isScrollable) {
this.updateScroll()
} else if (!isScrollable && this.messages.length) {
Expand Down
3 changes: 2 additions & 1 deletion 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 All @@ -44,7 +45,7 @@ export default ({
default: null
},
isSameSender: Boolean,
isCurrentUser: Boolean,
isMsgSender: Boolean,
replyingMessage: null
},
constants: Object.freeze({
Expand Down
4 changes: 2 additions & 2 deletions frontend/views/containers/chatroom/MessageActions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export default ({
props: {
variant: String,
type: String,
isCurrentUser: Boolean
isMsgSender: Boolean
},
computed: {
isText () {
Expand All @@ -126,7 +126,7 @@ export default ({
return this.type === MESSAGE_TYPES.POLL
},
isEditable (): Boolean {
return this.isCurrentUser && (this.isText || this.isPoll)
return this.isMsgSender && (this.isText || this.isPoll)
}
},
methods: {
Expand Down
Loading
Loading