feat: stream comment attachments

This commit is contained in:
Fabians
2022-06-16 12:41:49 +03:00
parent a54fa8c28f
commit a10c49e731
46 changed files with 3586 additions and 2496 deletions
+1 -1
View File
@@ -23,4 +23,4 @@ utils/helm/speckle-server/templates
venv
.*.{ts,js,vue,tsx,jsx}
generated/**/*
**/generated/**/*
-1
View File
@@ -36,7 +36,6 @@
"@vuejs-community/vue-filter-date-format": "^1.6.3",
"@vuejs-community/vue-filter-date-parse": "^1.1.6",
"apexcharts": "^3.33.1",
"crypto-random-string": "^3.3.0",
"dompurify": "^2.3.6",
"lodash": "^4.17.21",
"numeral": "^2.0.6",
+29
View File
@@ -0,0 +1,29 @@
import gql from 'graphql-tag'
export const COMMENT_FULL_INFO_FRAGMENT = gql`
fragment CommentFullInfo on Comment {
id
archived
authorId
text {
doc
attachments {
id
fileName
streamId
}
}
data
screenshot
replies {
totalCount
}
resources {
resourceId
resourceType
}
createdAt
updatedAt
viewedAt
}
`
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
/**
* Base application error
*/
export abstract class BaseError extends Error {
/**
* Default message if none is passed
*/
static defaultMessage = 'Unexpected error occurred!'
constructor(message?: string, options?: ErrorOptions) {
message ||= new.target.defaultMessage
super(message, options)
}
}
@@ -1,27 +1,70 @@
<template>
<smart-text-editor
v-model="realValue"
autofocus
min-width
:placeholder="placeholder"
:schema-options="editorSchemaOptions"
:disabled="disabled"
:hide-toolbar="addingComment"
@submit="onSubmit"
/>
<div class="comment-editor">
<file-upload-zone
ref="uploadZone"
v-slot="{ isFileDrag }"
:size-limit="fileSizeLimit"
:count-limit="countLimit"
:accept="acceptValue"
:disabled="disabled"
multiple
@files-selected="onFilesSelected"
>
<smart-text-editor
v-model="doc"
:class="['elevation-5 rounded-xl', isFileDrag ? 'dragging-files' : '']"
autofocus
min-width
:placeholder="placeholder"
:schema-options="editorSchemaOptions"
:disabled="disabled"
:hide-toolbar="addingComment"
@submit="onSubmit"
/>
</file-upload-zone>
<file-upload-progress
v-if="currentFiles.length"
:items="currentFiles"
class="mt-2"
:disabled="disabled"
@delete="onUploadDelete"
/>
</div>
</template>
<script>
<script lang="ts">
import SmartTextEditor from '@/main/components/common/text-editor/SmartTextEditor.vue'
import { SMART_EDITOR_SCHEMA } from '@/main/lib/viewer/comments/commentsHelper'
import {
CommentEditorValue,
SMART_EDITOR_SCHEMA
} from '@/main/lib/viewer/comments/commentsHelper'
import Vue, { PropType } from 'vue'
import FileUploadZone from '@/main/components/common/file-upload/FileUploadZone.vue'
import {
FilesSelectedEvent,
FileUploadDeleteEvent,
isUploadProcessed,
UniqueFileTypeSpecifier
} from '@/main/lib/common/file-upload/fileUploadHelper'
import FileUploadProgress from '@/main/components/common/file-upload/FileUploadProgress.vue'
import { UploadFileItem } from '@/main/lib/common/file-upload/fileUploadHelper'
import { differenceBy } from 'lodash'
import { deleteBlob, uploadFiles } from '@/main/lib/common/file-upload/blobStorageApi'
import { JSONContent } from '@tiptap/core'
export default {
// TODO: Styling for adding attachments & rendering them
type FileUploadZoneInstance = InstanceType<typeof FileUploadZone>
export default Vue.extend({
name: 'CommentEditor',
components: {
SmartTextEditor
SmartTextEditor,
FileUploadZone,
FileUploadProgress
},
props: {
value: {
type: Object,
type: Object as PropType<CommentEditorValue>,
default: null
},
disabled: {
@@ -31,32 +74,136 @@ export default {
addingComment: {
type: Boolean,
default: false
},
streamId: {
type: String,
required: true
}
},
data() {
return {
editorSchemaOptions: SMART_EDITOR_SCHEMA
editorSchemaOptions: SMART_EDITOR_SCHEMA,
fileSizeLimit: 1024 * 1024 * 25, // 25MB
countLimit: 5, // if it's more than 5, just zip it up
acceptValue: [UniqueFileTypeSpecifier.AnyImage, '.pdf', '.zip'].join(',')
}
},
computed: {
realValue: {
get() {
get(): CommentEditorValue {
return this.value
},
set(newVal) {
set(newVal: CommentEditorValue) {
this.$emit('input', newVal)
}
},
placeholder() {
doc: {
get(): JSONContent {
return this.value.doc
},
set(newVal: JSONContent) {
this.realValue = {
...this.realValue,
doc: newVal
}
}
},
currentFiles: {
get(): UploadFileItem[] {
return this.value.attachments
},
set(newVal: UploadFileItem[]) {
this.realValue = {
...this.realValue,
attachments: newVal
}
}
},
placeholder(): string {
return this.addingComment
? 'Your comment... (press enter to send)'
: 'Reply... (press enter to send)'
},
anyAttachmentsProcessing(): boolean {
return this.currentFiles.some((a) => !isUploadProcessed(a))
}
},
watch: {
anyAttachmentsProcessing(newVal: boolean, oldVal: boolean) {
if (newVal !== oldVal) {
this.$emit('attachments-processing', newVal)
}
}
},
beforeDestroy() {
// Delete attachments that weren't posted
for (const currentFile of this.currentFiles.slice()) {
if (currentFile.inUse) continue
this.popUpload(currentFile.id)
}
},
methods: {
onSubmit(e) {
addAttachments(): void {
;(this.$refs.uploadZone as FileUploadZoneInstance).triggerPicker()
},
onSubmit(e: unknown) {
this.$emit('submit', e)
},
onFilesSelected(e: FilesSelectedEvent) {
const remainingCount = Math.max(0, this.countLimit - this.currentFiles.length)
if (!remainingCount) return
const incomingFiles = e.files
const currentFiles = this.currentFiles
const newFiles = differenceBy(incomingFiles, currentFiles, (f) => f.id)
if (!newFiles.length) return
const limitedFiles = newFiles.slice(0, remainingCount)
const newUploads = Object.values(
uploadFiles(limitedFiles, { streamId: this.streamId }, (uploadedFiles) => {
// Delete files that were uploaded, but already removed from attachments
for (const [id, file] of Object.entries(uploadedFiles)) {
if (
file.result?.blobId &&
this.currentFiles.findIndex((f) => f.id === id) === -1 &&
!file.inUse
) {
this.deleteBlobInBg(file.result?.blobId)
}
}
})
)
this.currentFiles = [...this.currentFiles, ...newUploads]
},
popUpload(fileId: string) {
const fileIdx = this.currentFiles.findIndex((f) => f.id === fileId)
if (fileIdx === -1) return
// Remove from array
const [removedFile] = this.currentFiles.splice(fileIdx, 1) || []
// Delete from blob storage
if (removedFile.result?.blobId) {
this.deleteBlobInBg(removedFile.result.blobId)
}
},
deleteBlobInBg(blobId: string): void {
deleteBlob(blobId, { streamId: this.streamId }).catch(console.error)
},
onUploadDelete(e: FileUploadDeleteEvent) {
const { id } = e
this.popUpload(id)
}
}
}
})
</script>
<style lang="scss" scoped>
::v-deep .smart-text-editor {
// transparent border, so we don't get a layout shift
border: 2px solid transparent;
&.dragging-files {
border: 2px solid rgb(0, 193, 0);
}
}
</style>
@@ -117,6 +117,9 @@
<script>
import gql from 'graphql-tag'
import { documentToBasicString } from '@/main/lib/common/text-editor/documentHelper'
import { COMMENT_FULL_INFO_FRAGMENT } from '@/graphql/comments'
// TODO: Stop polling each comment separately
export default {
components: {
@@ -136,26 +139,10 @@ export default {
query: gql`
query ($streamId: String!, $id: String!) {
comment(streamId: $streamId, id: $id) {
id
text {
doc
}
authorId
screenshot
createdAt
updatedAt
viewedAt
archived
resources {
resourceType
resourceId
}
data
replies {
totalCount
}
...CommentFullInfo
}
}
${COMMENT_FULL_INFO_FRAGMENT}
`,
fetchPolicy: 'no-cache',
variables() {
@@ -175,7 +162,9 @@ export default {
commentThreadActivity: {
query: gql`
subscription ($streamId: String!, $commentId: String!) {
commentThreadActivity(streamId: $streamId, commentId: $commentId)
commentThreadActivity(streamId: $streamId, commentId: $commentId) {
type
}
}
`,
variables() {
@@ -189,12 +178,12 @@ export default {
},
result({ data }) {
if (!data || !data.commentThreadActivity) return
if (data.commentThreadActivity.eventType === 'reply-added') {
if (data.commentThreadActivity.type === 'reply-added') {
this.commentDetails.replies.totalCount++
this.commentDetails.updatedAt = Date.now()
return
}
if (data.commentThreadActivity.eventType === 'comment-archived') {
if (data.commentThreadActivity.type === 'comment-archived') {
this.$emit('deleted', this.comment)
}
}
@@ -6,26 +6,34 @@
@mouseleave="hover = false"
>
<div
:class="`flex-grow-1 d-flex px-2 py-1 mb-2 align-center rounded-xl elevation-2 ${
:class="`flex-grow-1 d-flex flex-column px-2 py-1 mb-2 rounded-xl elevation-2 ${
$userId() === reply.authorId ? 'primary white--text' : 'background'
}`"
style="width: 290px"
>
<div
:class="`d-inline-block ${
$userId() === reply.authorId ? 'xxx-order-last' : ''
}`"
>
<user-avatar :id="reply.authorId" :size="30" />
</div>
<div :class="`reply-box d-inline-block mx-2 py-2 flex-grow-1 float-left caption`">
<smart-text-editor
min-width
read-only
:schema-options="richTextSchema"
:value="reply.text.doc"
/>
<div class="d-flex">
<div
:class="`d-inline-block ${
$userId() === reply.authorId ? 'xxx-order-last' : ''
}`"
>
<user-avatar :id="reply.authorId" :size="30" />
</div>
<div
:class="`reply-box d-inline-block mx-2 py-2 flex-grow-1 float-left caption`"
>
<smart-text-editor
min-width
read-only
:schema-options="richTextSchema"
:value="reply.text.doc"
/>
</div>
</div>
<comment-thread-reply-attachments
v-if="reply.text.attachments && reply.text.attachments.length"
:attachments="reply.text.attachments"
/>
</div>
<div style="width: 20px; overflow: hidden">
<v-scroll-x-transition>
@@ -72,11 +80,13 @@
import gql from 'graphql-tag'
import SmartTextEditor from '@/main/components/common/text-editor/SmartTextEditor.vue'
import { SMART_EDITOR_SCHEMA } from '@/main/lib/viewer/comments/commentsHelper'
import CommentThreadReplyAttachments from '@/main/components/comments/CommentThreadReplyAttachments.vue'
export default {
components: {
UserAvatar: () => import('@/main/components/common/UserAvatar'),
SmartTextEditor
SmartTextEditor,
CommentThreadReplyAttachments
},
props: {
reply: { type: Object, default: () => null },
@@ -128,16 +138,19 @@ export default {
}
</script>
<style scoped lang="scss">
::v-deep .smart-text-editor {
::v-deep .smart-text-editor,
::v-deep .comment-attachments {
a {
font-weight: bold;
color: white;
text-decoration: none;
word-break: break-all;
}
}
&:after {
content: ' ↗ ';
}
::v-deep .smart-text-editor {
a:after {
content: ' ↗ ';
}
}
</style>
@@ -0,0 +1,36 @@
<template>
<div class="comment-attachments d-flex my-2">
<v-icon small color="white">mdi-paperclip</v-icon>
<div class="ml-2 text-caption d-flex flex-column">
<a
v-for="attachment in attachments"
:key="attachment.url"
href="javascript:;"
@click="onAttachmentClick(attachment)"
>
{{ attachment.fileName }}
</a>
</div>
</div>
</template>
<script lang="ts">
import { BlobMetadata } from '@/graphql/generated/graphql'
import { downloadBlobWithUrl } from '@/main/lib/common/file-upload/blobStorageApi'
import Vue, { PropType } from 'vue'
export default Vue.extend({
name: 'CommentThreadReplyAttachments',
props: {
attachments: {
type: Array as PropType<Array<BlobMetadata>>,
required: true
}
},
methods: {
onAttachmentClick(a: BlobMetadata) {
const { id, fileName, streamId } = a
downloadBlobWithUrl(id, fileName, { streamId })
}
}
})
</script>
@@ -44,7 +44,7 @@
This comment is targeting other resources.
<v-btn x-small @click="addMissingResources()">View in full context</v-btn>
</div>
<div v-show="$apollo.loading" class="px-2">
<div v-show="$apollo.loading" class="px-2 mb-2">
<v-progress-linear indeterminate />
</div>
<template v-for="(reply, index) in thread">
@@ -79,23 +79,29 @@
</v-slide-y-transition>
<div v-if="canReply" class="d-flex">
<comment-editor
v-model="replyText"
ref="commentEditor"
v-model="replyValue"
:stream-id="$route.params.streamId"
adding-comment
max-height="300px"
class="mb-2 elevation-5 rounded-xl"
class="mb-2"
:style="{ width: $vuetify.breakpoint.xs ? '100%' : '290px' }"
:disabled="loadingReply"
@input="debTypingUpdate"
@attachments-processing="anyAttachmentsProcessing = $event"
@submit="addReply()"
/>
</div>
<div v-else class="caption background rounded-xl py-2 px-4 elevation-2">
You do not have sufficient permissions to reply to comments in this stream.
</div>
<div v-show="loadingReply" class="px-2">
<div v-show="loadingReply" class="px-2 mb-2">
<v-progress-linear indeterminate />
</div>
<div ref="replyinput" class="d-flex justify-space-between align-center">
<div
ref="replyinput"
class="d-flex justify-space-between align-center comment-actions"
>
<v-btn
v-show="canArchiveThread"
v-tooltip="'Marks this thread as archived.'"
@@ -109,6 +115,16 @@
<v-icon small>mdi-delete-outline</v-icon>
</v-btn>
<div class="pr-5">
<v-btn
v-tooltip="'Add attachments'"
:disabled="loadingReply"
icon
large
class="mouse elevation-5 background mr-3"
@click="addAttachments()"
>
<v-icon small>mdi-paperclip</v-icon>
</v-btn>
<v-btn
v-tooltip="'Copy comment url to clipboard'"
:disabled="loadingReply"
@@ -122,7 +138,7 @@
<v-btn
v-tooltip="'Send comment (press enter)'"
:disabled="loadingReply"
:disabled="isSubmitDisabled"
class="mouse elevation-5 primary"
icon
dark
@@ -178,6 +194,8 @@ import CommentThreadReply from '@/main/components/comments/CommentThreadReply.vu
import CommentEditor from '@/main/components/comments/CommentEditor.vue'
import { isDocEmpty } from '@/main/lib/common/text-editor/documentHelper'
import { SMART_EDITOR_SCHEMA } from '@/main/lib/viewer/comments/commentsHelper'
import { isSuccessfullyUploaded } from '@/main/lib/common/file-upload/fileUploadHelper'
import { COMMENT_FULL_INFO_FRAGMENT } from '@/graphql/comments'
export default {
components: {
@@ -226,16 +244,12 @@ export default {
totalCount
cursor
items {
id
text {
doc
}
authorId
createdAt
...CommentFullInfo
}
}
}
}
${COMMENT_FULL_INFO_FRAGMENT}
`,
fetchPolicy: 'cache-and-network',
variables() {
@@ -258,8 +272,15 @@ export default {
commentThreadActivity: {
query: gql`
subscription ($streamId: String!, $commentId: String!) {
commentThreadActivity(streamId: $streamId, commentId: $commentId)
commentThreadActivity(streamId: $streamId, commentId: $commentId) {
type
data
reply {
...CommentFullInfo
}
}
}
${COMMENT_FULL_INFO_FRAGMENT}
`,
variables() {
return {
@@ -272,21 +293,21 @@ export default {
},
result({ data }) {
if (!data || !data.commentThreadActivity) return
if (data.commentThreadActivity.eventType === 'reply-added') {
if (data.commentThreadActivity.type === 'reply-added') {
if (!this.comment.expanded) return this.$emit('bounce', this.comment.id)
else {
setTimeout(() => {
this.$emit('refresh-layout') // needed for layout reshuffle in parent
}, 100)
}
this.localReplies.push({ ...data.commentThreadActivity })
this.localReplies.push(data.commentThreadActivity.reply)
this.$refs.replyinput.scrollIntoView({ behaviour: 'smooth', block: 'end' })
return
}
if (data.commentThreadActivity.eventType === 'comment-archived') {
if (data.commentThreadActivity.type === 'comment-archived') {
this.$emit('deleted', this.comment)
}
if (data.commentThreadActivity.eventType === 'reply-typing-status') {
if (data.commentThreadActivity.type === 'reply-typing-status') {
const state = data.commentThreadActivity.data
if (state.userId === this.$userId()) return
const existingUser = this.whoIsTyping.find((u) => u.userId === state.userId)
@@ -311,17 +332,21 @@ export default {
data() {
return {
hovered: true,
replyText: null,
replyValue: { doc: null, attachments: [] },
localReplies: [],
minimize: false,
showArchiveDialog: false,
loadingReply: false,
whoIsTyping: [],
isTyping: true,
editorSchemaOptions: SMART_EDITOR_SCHEMA
editorSchemaOptions: SMART_EDITOR_SCHEMA,
anyAttachmentsProcessing: false
}
},
computed: {
isSubmitDisabled() {
return this.loadingReply || this.anyAttachmentsProcessing
},
canReply() {
return !!this.stream?.role || this.stream?.allowPublicComments
},
@@ -421,7 +446,9 @@ export default {
7000,
{ leading: true }
),
addAttachments() {
this.$refs.commentEditor.addAttachments()
},
async sendTypingUpdate(state = true) {
if (!this.$loggedIn()) return
await this.$apollo.mutate({
@@ -478,25 +505,31 @@ export default {
const delta = Math.abs(prev - curr)
return delta > 450000
},
isReplyEmpty() {
return isDocEmpty(this.replyValue.doc) && !this.replyValue.attachments.length
},
async addReply() {
if (isDocEmpty(this.replyText)) {
if (this.isReplyEmpty()) {
this.$eventHub.$emit('notification', {
text: `Cannot post an empty reply.`
})
return
}
const blobIds = this.replyValue.attachments
.filter(isSuccessfullyUploaded)
.map((a) => a.result.blobId)
const replyInput = {
streamId: this.$route.params.streamId,
parentComment: this.comment.id,
text: this.replyText
text: this.replyValue.doc,
blobIds
}
let success = false
this.loadingReply = true
try {
this.replyText = null
await this.$apollo.mutate({
const { data } = await this.$apollo.mutate({
mutation: gql`
mutation commentReply($input: ReplyCreateInput!) {
commentReply(input: $input)
@@ -504,6 +537,7 @@ export default {
`,
variables: { input: replyInput }
})
success = !!data.commentReply
await this.sendTypingUpdate(false)
this.$mixpanel.track('Comment Action', { type: 'action', name: 'reply' })
} catch (e) {
@@ -512,7 +546,15 @@ export default {
})
}
// On success, mark uploads as in use, to prevent cleanup
if (success) {
this.replyValue.attachments.forEach((a) => {
a.inUse = true
})
}
this.loadingReply = false
this.replyValue = { doc: null, attachments: [] }
setTimeout(() => {
// Shhh.
@@ -544,7 +586,7 @@ export default {
commentId: this.comment.id
}
})
this.replyText = null
this.replyValue = { doc: null, attachments: [] }
this.showArchiveDialog = false
this.$emit('deleted', this.comment)
this.$mixpanel.track('Comment Action', { type: 'action', name: 'archive' })
@@ -0,0 +1,45 @@
<template>
<div class="file-upload-progress d-flex flex-column">
<file-upload-progress-row
v-for="item in items"
:key="item.id"
:item="item"
:disabled="disabled"
class="mb-1"
@delete="$emit('delete', $event)"
/>
</div>
</template>
<script lang="ts">
import FileUploadProgressRow from '@/main/components/common/file-upload/FileUploadProgressRow.vue'
import { UploadFileItem } from '@/main/lib/common/file-upload/fileUploadHelper'
import Vue, { PropType } from 'vue'
/**
* Render UploadFileItem progress
*
* Events:
* @delete: FileUploadDeleteEvent
*/
export default Vue.extend({
name: 'FileUploadProgress',
components: {
FileUploadProgressRow
},
props: {
items: {
type: Array as PropType<UploadFileItem[]>,
required: true
},
disabled: {
type: Boolean,
default: false
}
}
})
</script>
<style lang="scss" scoped>
::v-deep .file-upload-progress-row:last-of-type {
margin-bottom: 0 !important;
}
</style>
@@ -0,0 +1,112 @@
<template>
<div class="file-upload-progress-row relative">
<v-btn
v-if="!disabled"
class="file-upload-progress-row__delete"
icon
small
color="red"
@click="onDelete"
>
<v-icon small>mdi-close</v-icon>
</v-btn>
<v-card class="file-upload-progress-row__card px-2 py-1">
<div class="d-flex align-end">
<div v-tooltip="item.file.name" class="mr-1 text-subtitle-2 ellipsis-text">
{{ item.file.name }}
</div>
<div class="text-caption grey--text">
{{ prettyFileSize(item.file.size) }}
</div>
</div>
<div
v-if="errorMessage"
v-tooltip="errorMessage"
class="text-caption red--text ellipsis-text"
>
{{ errorMessage }}
</div>
<v-progress-linear
v-if="item.progress > 0"
:color="progressBarColor"
height="4"
:value="item.progress"
stream
class="my-1"
/>
</v-card>
</div>
</template>
<script lang="ts">
import { Nullable } from '@/helpers/typeHelpers'
import {
prettyFileSize,
UploadFileItem
} from '@/main/lib/common/file-upload/fileUploadHelper'
import Vue, { PropType } from 'vue'
export default Vue.extend({
name: 'FileUploadProgressRow',
props: {
item: {
type: Object as PropType<UploadFileItem>,
required: true
},
disabled: {
type: Boolean,
default: false
}
},
data: () => ({
prettyFileSize
}),
computed: {
errorMessage(): Nullable<string> {
if (this.item.error) return this.item.error.message
if (this.item.result?.uploadError) return this.item.result.uploadError
return null
},
progressBarColor(): string {
if (this.errorMessage) return 'red'
if (this.item.progress >= 100) return 'green'
return 'primary'
}
},
methods: {
onDelete() {
if (this.disabled) return
this.$emit('delete', { id: this.item.id })
}
}
})
</script>
<style lang="scss" scoped>
.ellipsis-text {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.v-progress-linear {
border-bottom-left-radius: unset !important;
border-bottom-right-radius: unset !important;
}
.relative {
position: relative;
}
.file-upload-progress-row {
&__delete {
position: absolute;
right: 0;
top: 50%;
transform: translate(100%, -50%);
z-index: 30;
}
& > .v-card {
overflow: auto;
}
}
</style>
@@ -0,0 +1,198 @@
<template>
<div
class="file-upload-zone"
@mouseover="isHover = true"
@mouseleave="isHover = false"
@dragenter.prevent="isFileDrag = true"
@dragover.prevent="isFileDrag = true"
@dragleave.prevent="isFileDrag = false"
@drop.prevent="onDrop"
>
<slot
:is-hover="isHover"
:is-file-drag="isFileDrag"
:activator-on="activatorOn"
:open-file-picker="openFilePicker"
/>
<input
ref="fileInput"
type="file"
class="d-none"
:accept="accept"
:multiple="multiple"
@change="onFileSelect"
/>
</div>
</template>
<script lang="ts">
import { Optional } from '@/helpers/typeHelpers'
import {
FileTypeSpecifier,
validateFileType,
isFileTypeSpecifier,
UploadableFileItem,
FileTooLargeError,
prettyFileSize,
generateFileId
} from '@/main/lib/common/file-upload/fileUploadHelper'
import Vue, { PropType } from 'vue'
/**
* Generic/re-usable file upload zone. Files can be dragged and dropped onto it.
* If you want to trigger the file picker dialog, invoke the openFilePicker function available
* in the default template's scoped slots.
* Similarly you can attach the default template's `activatorOn` prop to any element
* that can be clicked with 'v-on' and on click it will trigger
* the file picker dialog.
*
* If you want the dialog outside of the template, you can also do:
* this.$refs.uploadZone.triggerPicker() // triggered from the parent
*
* Templates:
* #default: { isFileDrag, isHover, activatorOn, openFilePicker }
*
* Events:
* @files-selected: FilesSelectedEvent
*/
/**
* TODO: <input type=file> unhidden w/ label, for accessibility. Currently it's not needed
* for comment attachments
*/
export default Vue.extend({
name: 'FileUploadZone',
props: {
/**
* https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept
*/
accept: {
type: String as PropType<Optional<string>>,
default: undefined
},
/**
* https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/multiple
*/
multiple: {
type: Boolean,
default: false
},
/**
* Max file size in bytes
*/
sizeLimit: {
type: Number,
default: 1024 * 1024 * 100 // 100mb
},
disabled: {
type: Boolean,
default: false
}
},
data: () => ({
isHover: false,
isFileDrag: false
}),
computed: {
fileTypeSpecifiers(): Optional<FileTypeSpecifier[]> {
if (!this.accept) return undefined
const specifiers = this.accept
.split(',')
.map((s) => (isFileTypeSpecifier(s) ? s : null))
.filter((s): s is FileTypeSpecifier => s !== null)
return specifiers.length ? specifiers : undefined
},
activatorOn(): Record<string, () => void> {
return {
click: () => {
this.triggerPicker()
}
}
},
openFilePicker(): () => void {
return () => this.triggerPicker()
}
},
methods: {
/**
* Trigger the file picker dialog
*/
triggerPicker() {
;(this.$refs.fileInput as HTMLElement).click()
},
onDrop(e: DragEvent) {
if (!e.dataTransfer?.files) return
this.handleIncomingFiles([...e.dataTransfer.files])
},
onFileSelect(e: Event) {
const typedTarget = e.target as HTMLInputElement
const files = [...(typedTarget.files || [])]
typedTarget.value = '' // Resetting value
if (!files || !files.length) return
this.handleIncomingFiles(files)
},
/**
* Validate and process newly selected files
*/
handleIncomingFiles(files: File[]) {
this.isFileDrag = false
if (this.disabled) return
const processedFiles = this.buildUploadableFiles(files)
if (processedFiles.length) {
this.$emit('files-selected', { files: processedFiles })
}
},
/**
* Validate files and convert them to UploadableFileItem
*/
buildUploadableFiles(files: File[]): UploadableFileItem[] {
const results: UploadableFileItem[] = []
const allowedTypes = this.fileTypeSpecifiers
for (const file of files) {
const id = generateFileId(file)
const countLimit = !this.multiple ? 1 : undefined
// skip file, if it's selected twice somehow
if (results.find((r) => r.id === id)) continue
// Only allow a single file if !multiple
if (countLimit && results.length >= countLimit) {
break
}
if (allowedTypes) {
const validationResult = validateFileType(file, allowedTypes)
if (validationResult instanceof Error) {
results.push({
file,
id,
error: validationResult
})
continue
}
}
if (file.size > this.sizeLimit) {
results.push({
file,
id,
error: new FileTooLargeError(
`The selected file's size (${prettyFileSize(
file.size
)}) is too big (over ${prettyFileSize(this.sizeLimit)})`
)
})
continue
}
results.push({ file, id, error: null })
}
return results
}
}
})
</script>
@@ -13,7 +13,7 @@
overflow: hidden;
z-index: 25;
"
class="no-mouse"
class="comment-add-overlay no-mouse"
>
<v-slide-x-transition>
<div
@@ -42,11 +42,14 @@
>
<div v-if="$loggedIn() && canComment" class="d-flex mouse">
<comment-editor
v-model="commentText"
ref="desktopEditor"
v-model="commentValue"
:stream-id="$route.params.streamId"
adding-comment
style="width: 300px"
class="elevation-5 rounded-xl"
max-height="300px"
:disabled="isSubmitDisabled"
@attachments-processing="anyAttachmentsProcessing = $event"
@submit="addComment()"
/>
</div>
@@ -54,23 +57,36 @@
v-if="$loggedIn() && canComment"
class="d-flex mt-2 mouse justify-end"
>
<template v-for="reaction in $store.state.commentReactions">
<v-btn
v-show="commentIsEmptyOrNull"
:key="reaction"
class="mr-2"
fab
small
@click="addCommentDirect(reaction)"
>
<span
class="text-h5"
style="position: relative; top: 1px; left: -1px"
>
{{ reaction }}
</span>
</v-btn>
</template>
<v-fade-transition group>
<template v-if="isCommentEmpty">
<template v-for="reaction in $store.state.commentReactions">
<v-btn
:key="reaction"
class="mr-2"
fab
small
@click="addCommentDirect(reaction)"
>
<span
class="text-h5"
style="position: relative; top: 1px; left: -1px"
>
{{ reaction }}
</span>
</v-btn>
</template>
</template>
</v-fade-transition>
<v-btn
v-tooltip="'Add attachments'"
:disabled="loading"
fab
small
class="mx-2 elevation-10"
@click="addAttachments()"
>
<v-icon small>mdi-paperclip</v-icon>
</v-btn>
<v-btn
v-tooltip="'Send comment (press enter)'"
:disabled="loading"
@@ -104,10 +120,11 @@
</div>
</v-slide-x-transition>
</div>
<v-dialog
v-if="$vuetify.breakpoint.xs"
v-model="expand"
class="elevation-0 flat"
content-class="elevation-0 flat px-2"
@click:outside="toggleExpand()"
>
<div
@@ -122,18 +139,20 @@
style="position: relative"
>
<comment-editor
v-model="commentText"
ref="mobileEditor"
v-model="commentValue"
:stream-id="$route.params.streamId"
adding-comment
style="width: 100%"
class="elevation-5 rounded-xl"
max-height="60vh"
:disabled="loading"
:disabled="isSubmitDisabled"
@submit="addComment()"
@attachments-processing="anyAttachmentsProcessing = $event"
/>
</div>
<div
v-if="$loggedIn() && canComment"
class="my-2 d-flex justify-center"
class="my-2 d-flex justify-end"
style="position: relative"
>
<v-btn
@@ -147,20 +166,33 @@
<v-icon small class="mr-1">mdi-account</v-icon>
Sign in to comment
</v-btn>
<template v-for="reaction in $store.state.commentReactions">
<v-btn
v-show="commentIsEmptyOrNull"
:key="reaction"
class="mr-2"
fab
small
@click="addCommentDirect(reaction)"
>
<span class="text-h5">
{{ reaction }}
</span>
</v-btn>
</template>
<v-fade-transition group>
<template v-if="isCommentEmpty">
<template v-for="reaction in $store.state.commentReactions">
<v-btn
:key="reaction"
class="mr-2 elevation-4"
fab
small
@click="addCommentDirect(reaction)"
>
<span class="text-h5">
{{ reaction }}
</span>
</v-btn>
</template>
</template>
</v-fade-transition>
<v-btn
v-tooltip="'Add attachments'"
:disabled="loading"
fab
small
class="mx-2 elevation-4"
@click="addAttachments()"
>
<v-icon small>mdi-paperclip</v-icon>
</v-btn>
<v-btn
v-tooltip="'Send comment (press enter)'"
:disabled="loading"
@@ -168,7 +200,7 @@
dark
fab
small
class="primary mr-2 elevation-4"
class="primary elevation-4"
@click="addComment()"
>
<v-icon dark small>mdi-send</v-icon>
@@ -203,7 +235,6 @@ import { getCamArray } from './viewerFrontendHelpers'
import CommentEditor from '@/main/components/comments/CommentEditor.vue'
import {
basicStringToDocument,
documentToBasicString,
isDocEmpty
} from '@/main/lib/common/text-editor/documentHelper'
import {
@@ -211,6 +242,11 @@ import {
SMART_EDITOR_SCHEMA
} from '@/main/lib/viewer/comments/commentsHelper'
import { buildResizeHandlerMixin } from '@/main/lib/common/web-apis/mixins/windowResizeHandler'
import { isSuccessfullyUploaded } from '@/main/lib/common/file-upload/fileUploadHelper'
/**
* TODO: Would be nice to get rid of duplicate templates for mobile & large screens
*/
export default {
components: { CommentEditor },
@@ -252,18 +288,20 @@ export default {
expand: false,
visible: true,
loading: false,
commentText: null,
editorSchemaOptions: SMART_EDITOR_SCHEMA
commentValue: { doc: null, attachments: [] },
editorSchemaOptions: SMART_EDITOR_SCHEMA,
anyAttachmentsProcessing: false
}
},
computed: {
canComment() {
return !!this.stream?.role || this.stream?.allowPublicComments
},
commentIsEmptyOrNull() {
if (!this.commentText) return true
const res = documentToBasicString(this.commentText)
return res === ''
isCommentEmpty() {
return isDocEmpty(this.commentValue.doc) && !this.commentValue.attachments.length
},
isSubmitDisabled() {
return this.loading || this.anyAttachmentsProcessing
}
},
mounted() {
@@ -298,12 +336,16 @@ export default {
this.updateCommentBubble()
},
async addCommentDirect(emoji) {
this.commentText = basicStringToDocument(emoji, this.editorSchemaOptions)
this.commentValue.doc = basicStringToDocument(emoji, this.editorSchemaOptions)
await this.addComment()
},
addAttachments() {
const editor = this.$refs.desktopEditor || this.$refs.mobileEditor
editor.addAttachments()
},
async addComment() {
if (this.loading) return
if (isDocEmpty(this.commentText)) {
if (this.isCommentEmpty) {
this.$eventHub.$emit('notification', {
text: `Comment cannot be empty.`
})
@@ -313,6 +355,10 @@ export default {
this.$mixpanel.track('Comment Action', { type: 'action', name: 'create' })
const camTarget = window.__viewer.cameraHandler.activeCam.controls.getTarget()
const blobIds = this.commentValue.attachments
.filter(isSuccessfullyUploaded)
.map((a) => a.result.blobId)
const commentInput = {
streamId: this.$route.params.streamId,
resources: [
@@ -321,7 +367,8 @@ export default {
resourceId: this.$route.params.resourceId
}
],
text: this.commentText,
text: this.commentValue.doc,
blobIds,
data: {
location: this.location
? this.location
@@ -340,9 +387,11 @@ export default {
.map((res) => ({ resourceId: res, resourceType: this.$resourceType(res) }))
)
}
let success = false
this.loading = true
try {
await this.$apollo.mutate({
const { data } = await this.$apollo.mutate({
mutation: gql`
mutation commentCreate($input: CommentCreateInput!) {
commentCreate(input: $input)
@@ -350,15 +399,24 @@ export default {
`,
variables: { input: commentInput }
})
success = !!data.commentCreate
} catch (e) {
this.$eventHub.$emit('notification', {
text: e.message
})
}
// On success, mark uploads as in use, to prevent cleanup
if (success) {
this.commentValue.attachments.forEach((a) => {
a.inUse = true
})
}
this.loading = false
this.expand = false
this.visible = false
this.commentText = null
this.commentValue = { doc: null, attachments: [] }
this.$store.commit('setAddingCommentState', { addingCommentState: false })
window.__viewer.interactions.deselectObjects()
},
@@ -108,6 +108,7 @@
}`"
:style="{
zIndex: comment.expanded ? 20 : 10,
opacity: comment.expanded ? '1' : '0',
visibility: comment.expanded ? 'visible' : 'hidden'
}"
@mouseenter="comment.hovered = true"
@@ -173,6 +174,7 @@ import gql from 'graphql-tag'
import { VIEWER_UPDATE_THROTTLE_TIME } from '@/main/lib/viewer/comments/commentsHelper'
import { buildResizeHandlerMixin } from '@/main/lib/common/web-apis/mixins/windowResizeHandler'
import { documentToBasicString } from '@/main/lib/common/text-editor/documentHelper'
import { COMMENT_FULL_INFO_FRAGMENT } from '@/graphql/comments'
export default {
components: {
@@ -191,26 +193,12 @@ export default {
totalCount
cursor
items {
id
authorId
text {
doc
}
createdAt
updatedAt
viewedAt
archived
data
resources {
resourceId
resourceType
}
replies {
totalCount
}
...CommentFullInfo
}
}
}
${COMMENT_FULL_INFO_FRAGMENT}
`,
fetchPolicy: 'no-cache',
variables() {
@@ -253,8 +241,14 @@ export default {
subscribeToMore: {
document: gql`
subscription ($streamId: String!, $resourceIds: [String]) {
commentActivity(streamId: $streamId, resourceIds: $resourceIds)
commentActivity(streamId: $streamId, resourceIds: $resourceIds) {
type
comment {
...CommentFullInfo
}
}
}
${COMMENT_FULL_INFO_FRAGMENT}
`,
variables() {
let resIds = [this.$route.params.resourceId]
@@ -269,13 +263,9 @@ export default {
return !this.$loggedIn()
},
updateQuery(prevResult, { subscriptionData }) {
if (
!subscriptionData ||
!subscriptionData.data ||
!subscriptionData.data.commentActivity
)
return
const newComment = subscriptionData.data.commentActivity
if (!subscriptionData.data?.commentActivity) return
const { comment: newComment, type } = subscriptionData.data.commentActivity
newComment.expanded = false
newComment.hovered = false
@@ -286,7 +276,7 @@ export default {
newComment.archived = false
if (subscriptionData.data.commentActivity.eventType === 'comment-added') {
if (type === 'comment-added') {
if (prevResult.comments.items.find((c) => c.id === newComment.id)) {
return
}
@@ -648,7 +638,10 @@ export default {
.comment-bubble,
.comment-thread {
$timing: 0.1s;
$visibilityTiming: 0.2s;
transition: left $timing linear, right $timing linear, top $timing linear,
bottom $timing linear, opacity 0.2s ease;
bottom $timing linear, opacity $visibilityTiming ease,
visibility $visibilityTiming ease;
}
</style>
@@ -294,6 +294,7 @@ export default {
status: 'viewing'
}
if (!this.$route.params.streamId) return
await this.$apollo.mutate({
mutation: gql`
mutation userViewerActivityBroadcast(
@@ -317,6 +318,8 @@ export default {
},
async sendDisconnect() {
if (!this.$loggedIn()) return
if (!this.$route.params.streamId) return
await this.$apollo.mutate({
mutation: gql`
mutation userViewerActivityBroadcast(
@@ -0,0 +1,192 @@
import Vue from 'vue'
import {
UploadableFileItem,
UploadFileItem
} from '@/main/lib/common/file-upload/fileUploadHelper'
import { getAuthToken } from '@/plugins/authHelpers'
import { clamp } from 'lodash'
import { BaseError } from '@/helpers/errorHelper'
export type BlobPostResultItem = {
blobId?: string
fileName?: string
fileSize?: number
formKey: string
uploadStatus: number
uploadError: string
}
type BlobUploadPrincipal = {
streamId: string
}
export class BlobRetrievalError extends BaseError {
static defaultMessage = 'An error occurred while trying to retrieve the blob'
}
/**
* Initiate a browser file download of the specified blob
* @param blobId
* @param fileName Download filename
* @param principal Owner of the blob
*/
export async function downloadBlobWithUrl(
blobId: string,
fileName: string,
principal: BlobUploadPrincipal
) {
const token = getAuthToken()
const res = await fetch(`/api/stream/${principal.streamId}/blob/${blobId}`, {
headers: token
? {
Authorization: token
}
: undefined
})
if (res.status !== 200) {
throw new BlobRetrievalError()
}
const blob = await res.blob()
const fileUrl = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.setAttribute('style', 'display: none;')
a.setAttribute('href', fileUrl)
a.setAttribute('download', fileName)
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(fileUrl)
}
/**
* Upload a single file and return an UploadFileItem
* @param file File emitted from FileUploadZone
* @param principal What entity should the file be attached to on the server
* @returns A Vue Observable UploadFileItem, it's reactive so you can watch it etc. inside
* your Vue components
*/
export function uploadFile(
file: UploadFileItem,
principal: BlobUploadPrincipal,
callback?: (file: UploadFileItem) => void
): UploadFileItem {
const cbWrapper = callback
? (files: Record<string, UploadFileItem>) => callback(Object.values(files)[0])
: undefined
const results = uploadFiles([file], principal, cbWrapper)
return Object.values(results)[0]
}
/**
* Upload files and return an UploadFileItem for each file (keyed by file ID)
* @param files Files emitted from FileUploadZone
* @param principal What entity should the file be attached to on the server
* @returns A map of Vue observable UploadFileItems (they're reactive), keyed by
* file.id
*/
export function uploadFiles(
files: UploadableFileItem[],
principal: BlobUploadPrincipal,
callback?: (files: Record<string, UploadFileItem>) => void
): Record<string, UploadFileItem> {
const authToken = getAuthToken()
const formData = new FormData()
const uploadFiles: Record<string, UploadFileItem> = {}
for (const file of files) {
// Actually upload the file only if it doesn't have an attached error
// & if it isn't already added
const hasError = file.error
if (!hasError && !uploadFiles[file.id]) {
formData.append(file.id, file.file)
}
uploadFiles[file.id] = Vue.observable({
...file,
result: undefined,
progress: 0
})
}
// Nothing to upload, return
if (![...formData.keys()].length) return uploadFiles
// Init req
const req = new XMLHttpRequest()
req.open('POST', `/api/stream/${principal.streamId}/blob`)
req.responseType = 'json'
if (authToken) {
req.setRequestHeader('Authorization', `Bearer ${authToken}`)
}
req.upload.addEventListener('progress', (e) => {
const newProgress = clamp(Math.floor((e.loaded / e.total) * 100), 0, 100)
for (const resultItem of Object.values(uploadFiles)) {
if (resultItem.error) continue
resultItem.progress = newProgress
}
})
req.addEventListener('load', () => {
const uploadResults: BlobPostResultItem[] = req.response?.uploadResults || []
for (const uploadFile of Object.values(uploadFiles)) {
if (uploadFile.error) continue
uploadFile.progress = 100
uploadFile.result = uploadResults.find((r) => r.formKey === uploadFile.id) || {
uploadError: 'Unable to resolve upload results',
uploadStatus: 2,
formKey: uploadFile.id
}
}
if (callback) callback(uploadFiles)
})
req.addEventListener('error', () => {
const uploadResults: BlobPostResultItem[] = req.response?.uploadResults || []
for (const uploadFile of Object.values(uploadFiles)) {
if (uploadFile.error) continue
uploadFile.progress = 100
uploadFile.result = uploadResults.find((r) => r.formKey === uploadFile.id) || {
uploadError: 'Upload request failed unexpectedly',
uploadStatus: 2,
formKey: uploadFile.id
}
}
if (callback) callback(uploadFiles)
})
req.send(formData)
return uploadFiles
}
export class BlobDeleteFailedError extends BaseError {
static defaultMessage = 'Unable to delete the file'
}
export async function deleteBlob(blobId: string, principal: BlobUploadPrincipal) {
const { streamId } = principal
const authToken = getAuthToken()
const res = await fetch(`/api/stream/${streamId}/blob/${blobId}`, {
method: 'DELETE',
headers: {
...(authToken ? { Authorization: `Bearer ${authToken}` } : {})
}
})
if (res.status !== 204) {
throw new BlobDeleteFailedError()
}
}
@@ -0,0 +1,185 @@
import { BaseError } from '@/helpers/errorHelper'
import md5 from '@/helpers/md5'
import { Nullable, Optional } from '@/helpers/typeHelpers'
import { BlobPostResultItem } from '@/main/lib/common/file-upload/blobStorageApi'
import { difference, has, intersection } from 'lodash'
/**
* A file, as emitted out from FileUploadZone
*/
export interface UploadableFileItem {
file: File
error: Nullable<Error>
/**
* You can use this ID to check for File equality
*/
id: string
}
/**
* A file once it's upload has started
*/
export interface UploadFileItem extends UploadableFileItem {
/**
* Progress between 0 and 100
*/
progress: number
/**
* When upload has finished this contains a BlobPostResultItem
*/
result: Optional<BlobPostResultItem>
/**
* When a blob gets assigned to a resource, it should count as in use, and this will
* prevent it from being deleted as junk
*/
inUse?: boolean
}
export type FilesSelectedEvent = { files: UploadableFileItem[] }
export type FileUploadDeleteEvent = { id: string }
export type FileTypeSpecifier = UniqueFileTypeSpecifier | `.${string}`
export enum UniqueFileTypeSpecifier {
AnyAudio = 'audio/*',
AnyVideo = 'video/*',
AnyImage = 'image/*'
}
function isUploadFileItem(
uploadable: UploadableFileItem
): uploadable is UploadFileItem {
return has(uploadable, 'progress') || has(uploadable, 'result')
}
export function isSuccessfullyUploaded(upload: UploadFileItem): boolean {
return !!upload.result?.blobId
}
/**
* Validate if the upload is fully processed (successfully or not)
*/
export function isUploadProcessed(
upload: UploadableFileItem | UploadFileItem
): boolean {
if (upload.error) return true
if (isUploadFileItem(upload)) {
return upload.progress >= 100
}
return false
}
/**
* Validate if file has the allowed type. While we could also test for MIME types
* not in UniqueFileTypeSpecifier, this function is meant to be equivalent to the
* 'accept' attribute, which only allows for extensions or UniqueFileTypeSpecifier
* values.
* @param file
* @param allowedTypes The file must have one of these types
* @returns True if valid, Error object if not
*/
export function validateFileType(
file: File,
allowedTypes: FileTypeSpecifier[]
): true | Error {
// Check one of the unique file type specifiers first
const allowedUniqueTypes = intersection(
Object.values(UniqueFileTypeSpecifier),
allowedTypes
)
for (const allowedUniqueType of allowedUniqueTypes) {
switch (allowedUniqueType) {
case UniqueFileTypeSpecifier.AnyAudio:
if (file.type.startsWith('audio')) return true
break
case UniqueFileTypeSpecifier.AnyImage:
if (file.type.startsWith('image')) return true
break
case UniqueFileTypeSpecifier.AnyVideo:
if (file.type.startsWith('video')) return true
break
}
}
// Check file extensions
const allowedExtensions = difference(allowedTypes, allowedUniqueTypes)
const fileExt = resolveFileExtension(file.name)
if (!fileExt) return new MissingFileExtensionError()
for (const allowedExtension of allowedExtensions) {
if (allowedExtension === fileExt) return true
}
return new ForbiddenFileTypeError()
}
/**
* Resolve file extension (with leading dot)
*/
export function resolveFileExtension(fileName: string): Nullable<FileTypeSpecifier> {
const ext = fileName.split('.').pop() || null
return ext ? `.${ext}` : null
}
/**
* Check if string is a FileTypeSpecifier
*/
export function isFileTypeSpecifier(type: string): type is FileTypeSpecifier {
return (
type.startsWith('.') ||
Object.values(UniqueFileTypeSpecifier as Record<string, string>).includes(type)
)
}
/**
* Create a human readable file size string from the numeric size in bytes
*/
export function prettyFileSize(sizeInBytes: number): string {
if (sizeInBytes < 1024) {
return `${sizeInBytes}bytes`
}
const kbSize = sizeInBytes / 1024
if (kbSize < 1024) {
return `${kbSize.toFixed(2)}kb`
}
const mbSize = kbSize / 1024
if (mbSize < 1024) {
return `${mbSize.toFixed(2)}mb`
}
const gbSize = mbSize / 1024
return `${gbSize.toFixed(2)}gb`
}
/**
* Generate an ID that uniquely identifies a specific file. The same file
* will always have the same ID.
*/
export function generateFileId(file: File): string {
const importantData = {
name: file.name,
lastModified: file.lastModified,
size: file.size,
type: file.type
}
return md5(JSON.stringify(importantData))
}
export class ForbiddenFileTypeError extends BaseError {
static defaultMessage = 'The selected file type is forbidden'
}
export class MissingFileExtensionError extends BaseError {
static defaultMessage = 'The selected file has a missing extension'
}
export class FileTooLargeError extends BaseError {
static defaultMessage = "The selected file's size is too large"
}
@@ -1,85 +0,0 @@
import { trim, isNumber } from 'lodash'
/**
* @typedef {Object} SmartTextEditorSchemaOptions
* @property {boolean} [multiLine] Whether the document supports multi-line input
*/
/**
* @typedef {Object} SmartTextEditorOptions
* @property {string} [placeholder] Placeholder to show, if any
*/
/**
* Create a TipTap document from basic text
* @param {string} text
* @returns {import("@tiptap/core").JSONContent}
*/
export function basicStringToDocument(text) {
const textNode = { type: 'text', text }
return {
type: 'doc',
content: [{ type: 'paragraph', content: [textNode] }]
}
}
/**
* Check whether a doc is empty
* @param {import("@tiptap/core").JSONContent} doc
* @returns
*/
export function isDocEmpty(doc) {
if (!doc?.content?.length) return true
for (const content of doc.content) {
if (content.text) return false
if (!content.content?.length) continue
for (const subContent of content.content) {
if (subContent.text && trim(subContent.text)) return false
if (subContent.content?.length) return false
}
}
return true
}
/**
* Create an empty TipTap document
* @returns {import("@tiptap/core").JSONContent}
*/
export function buildEmptyDocument() {
return {
type: 'doc',
content: [{ type: 'paragraph', content: [] }]
}
}
/**
* Convert document to a basic string without all of the formatting, HTML tags, attributes etc.
* Useful for previews or text analysis.
* @param {import("@tiptap/core").JSONContent} doc
* @param {number | undefined} stopAtLength If set, will stop further parsing when the resulting string length
* reaches this length. Useful when you're only interested in the first few characters of the document.
* @returns {string}
*/
export function documentToBasicString(doc, stopAtLength = undefined) {
const recursiveStringBuilder = (doc, currentString) => {
if (isNumber(stopAtLength) && currentString.length >= stopAtLength) {
return currentString
}
if (doc.text) {
currentString += doc.text
}
for (const contentDoc of doc.content || []) {
currentString = recursiveStringBuilder(contentDoc, currentString)
}
return currentString
}
const result = ''
return recursiveStringBuilder(doc, result)
}
@@ -0,0 +1,68 @@
import { trim, isNumber } from 'lodash'
import { JSONContent } from '@tiptap/core'
import { Optional } from '@/helpers/typeHelpers'
export type SmartTextEditorSchemaOptions = {
/**
* Whether the document supports multi-line input
*/
multiLine?: boolean
}
export type SmartTextEditorOptions = {
/**
* Placeholder to show, if any
*/
placeholder?: string
}
/**
* Create a TipTap document from basic text
*/
export function basicStringToDocument(text: string): JSONContent {
const textNode = { type: 'text', text }
return {
type: 'doc',
content: [{ type: 'paragraph', content: [textNode] }]
}
}
/**
* Check whether a doc is empty
*/
export function isDocEmpty(doc: JSONContent | null | undefined): boolean {
if (!doc) return true
return trim(documentToBasicString(doc, 1)).length < 1
}
/**
* Convert document to a basic string without all of the formatting, HTML tags, attributes etc.
* Useful for previews or text analysis.
* @param doc
* @param stopAtLength If set, will stop further parsing when the resulting string length
* reaches this length. Useful when you're only interested in the first few characters of the document.
*/
export function documentToBasicString(
doc: JSONContent | null | undefined,
stopAtLength: Optional<number> = undefined
): string {
if (!doc) return ''
const recursiveStringBuilder = (doc: JSONContent, currentString: string) => {
if (isNumber(stopAtLength) && currentString.length >= stopAtLength) {
return currentString
}
if (doc.text) {
currentString += doc.text
}
for (const contentDoc of doc.content || []) {
currentString = recursiveStringBuilder(contentDoc, currentString)
}
return currentString
}
return recursiveStringBuilder(doc, '')
}
@@ -18,11 +18,14 @@ export function buildResizeHandlerMixin({ shouldThrottle, wait } = {}) {
window.removeEventListener('resize', this.resizeHandler)
},
watch: {
'$vuetify.breakpoint.name'() {
// Vuetify breakpoint service sometimes kicks in late, triggering
// a final update handler on next tick
clearTimeout(this.breakpointTimeout)
this.breakpointTimeout = setTimeout(() => this.onWindowResize, 0)
'$vuetify.breakpoint': {
handler() {
// Vuetify breakpoint service sometimes kicks in late, so we're triggering
// a final update handler on next tick to make sure any code that depends on $vuetify.breakpoint
// can be updated as well
this.resizeHandler()
},
deep: true
}
},
methods: {
@@ -1,11 +0,0 @@
/**
* Time used for throttling viewer/window resize/etc. updates that trigger comment bubble/thread absolute repositioning
*/
export const VIEWER_UPDATE_THROTTLE_TIME = 50
/**
* @type {import('@/main/lib/common/text-editor/documentHelper').SmartTextEditorSchemaOptions}
*/
export const SMART_EDITOR_SCHEMA = {
multiLine: false
}
@@ -0,0 +1,17 @@
import { UploadFileItem } from '@/main/lib/common/file-upload/fileUploadHelper'
import { SmartTextEditorSchemaOptions } from '@/main/lib/common/text-editor/documentHelper'
import { JSONContent } from '@tiptap/core'
/**
* Time used for throttling viewer/window resize/etc. updates that trigger comment bubble/thread absolute repositioning
*/
export const VIEWER_UPDATE_THROTTLE_TIME = 50
export const SMART_EDITOR_SCHEMA: SmartTextEditorSchemaOptions = {
multiLine: false
}
export type CommentEditorValue = {
doc: JSONContent
attachments: UploadFileItem[]
}
@@ -95,6 +95,7 @@ import {
STANDARD_PORTAL_KEYS,
buildPortalStateMixin
} from '@/main/utils/portalStateManager'
import { COMMENT_FULL_INFO_FRAGMENT } from '@/graphql/comments'
export default {
name: 'TheComments',
@@ -141,11 +142,12 @@ export default {
totalCount
cursor
items {
id
archived
...CommentFullInfo
}
}
}
${COMMENT_FULL_INFO_FRAGMENT}
`,
fetchPolicy: 'no-cache',
variables() {
@@ -157,17 +159,22 @@ export default {
subscribeToMore: {
document: gql`
subscription ($streamId: String!) {
commentActivity(streamId: $streamId)
commentActivity(streamId: $streamId) {
type
comment {
...CommentFullInfo
}
}
}
${COMMENT_FULL_INFO_FRAGMENT}
`,
variables() {
return { streamId: this.$route.params.streamId }
},
updateQuery(prevResult, { subscriptionData }) {
if (
this.localComments.findIndex((lc) => subscriptionData.id === lc.id) === -1
) {
this.localComments.push({ ...subscriptionData.data.commentActivity })
const { comment } = subscriptionData.data.commentActivity
if (this.localComments.findIndex((lc) => comment.id === lc.id) === -1) {
this.localComments.push(comment)
}
},
skip() {
@@ -129,6 +129,7 @@
</template>
<script>
import gql from 'graphql-tag'
import { COMMENT_FULL_INFO_FRAGMENT } from '@/graphql/comments'
export default {
name: 'TheStreamHome',
@@ -209,11 +210,12 @@ export default {
totalCount
cursor
items {
id
archived
...CommentFullInfo
}
}
}
${COMMENT_FULL_INFO_FRAGMENT}
`,
fetchPolicy: 'no-cache',
variables() {
+1 -1
View File
@@ -22,7 +22,7 @@ const store = new Vuex.Store({
selectedComment: null,
addingComment: false,
preventCommentCollapse: false,
commentReactions: ['❤️', '✏️', '🔥', '📍', '😲'],
commentReactions: ['❤️', '✏️', '🔥', '⚠️'],
emojis
},
mutations: {
@@ -6,6 +6,16 @@ import { VALID_EMAIL_REGEX } from '@/main/lib/common/vuetify/validators'
const appId = 'spklwebapp'
const appSecret = 'spklwebapp'
export function getAuthToken() {
try {
return localStorage.getItem(LocalStorageKeys.AuthToken)
} catch (e) {
// suppressed localStorage errors
}
return null
}
/**
* Checks for an access token in the url and tries to exchange it for a token/refresh pair.
* @return {boolean} true if everything is ok, otherwise throws an error.
+7
View File
@@ -4,6 +4,13 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Attach by Process ID",
"processId": "${command:PickProcess}",
"request": "attach",
"skipFiles": ["<node_internals>/**"],
"type": "node"
},
{
"name": "Launch via YARN",
"request": "launch",
+10 -6
View File
@@ -84,9 +84,14 @@ exports.init = async (app) => {
limits: { fileSize: 104_857_600 }
})
const streamId = req.params.streamId
busboy.on('file', (name, file, info) => {
busboy.on('file', (formKey, file, info) => {
const { filename: fileName } = info
const fileType = fileName.split('.').pop().toLowerCase()
const registerUploadResult = (processingPromise) => {
finalizePromises.push(
processingPromise.then((resultItem) => ({ ...resultItem, formKey }))
)
}
const blobId = crs({ length: 10 })
@@ -102,18 +107,17 @@ exports.init = async (app) => {
//this is handled by the file.on('limit', ...) event
if (file.truncated) return
await uploadOperations[blobId]
finalizePromises.push(
markUploadSuccess(getObjectAttributes, streamId, blobId)
)
registerUploadResult(markUploadSuccess(getObjectAttributes, streamId, blobId))
})
file.on('limit', async () => {
await uploadOperations[blobId]
finalizePromises.push(
registerUploadResult(
markUploadOverFileSizeLimit(deleteObject, streamId, blobId)
)
})
file.on('error', (err) => {
finalizePromises.push(markUploadError(deleteObject, blobId, err.message))
registerUploadResult(markUploadError(deleteObject, blobId, err.message))
})
})
@@ -9,6 +9,26 @@ const BlobStorage = () => knex('blob_storage')
const blobLookup = ({ blobId, streamId }) =>
BlobStorage().where({ id: blobId, streamId })
/**
* Get blobs - use only internally, as this doesn't require a streamId
*/
const getBlobs = async ({ streamId, blobIds }) => {
const q = BlobStorage().whereIn('id', blobIds)
if (streamId) {
q.andWhere('streamId', streamId)
}
return await q
}
/**
* Get a single blob - use only internally, as this doesn't require a streamId
*/
const getBlob = async ({ streamId, blobId }) => {
const blobs = await getBlobs({ streamId, blobIds: [blobId] })
return blobs?.length ? blobs[0] : null
}
const uploadFileStream = async (
storeFileStream,
{ streamId, userId },
@@ -145,5 +165,7 @@ module.exports = {
getFileStream,
deleteBlob,
getBlobMetadataCollection,
blobCollectionSummary
blobCollectionSummary,
getBlobs,
getBlob
}
@@ -0,0 +1,10 @@
const { BaseError } = require('@/modules/shared/errors/base')
class InvalidAttachmentsError extends BaseError {
static defaultMessage = 'Invalid comment attachments specified'
static code = 'INVALID_ATTACHMENTS'
}
module.exports = {
InvalidAttachmentsError
}
@@ -162,7 +162,7 @@ module.exports = {
throw new ApolloForbiddenError('You are not authorized.')
await pubsub.publish('COMMENT_THREAD_ACTIVITY', {
commentThreadActivity: { eventType: 'reply-typing-status', data: args.data },
commentThreadActivity: { type: 'reply-typing-status', data: args.data },
streamId: args.streamId,
commentId: args.commentId
})
@@ -181,22 +181,15 @@ module.exports = {
if (!stream.allowPublicComments && !stream.role)
throw new ApolloForbiddenError('You are not authorized.')
const { id, text } = await createComment({
const comment = await createComment({
userId: context.userId,
input: args.input
})
await pubsub.publish('COMMENT_ACTIVITY', {
commentActivity: {
...args.input,
text,
authorId: context.userId,
id,
replies: { totalCount: 0 },
updatedAt: Date.now(),
createdAt: Date.now(),
eventType: 'comment-added',
archived: false
type: 'comment-added',
comment
},
streamId: args.input.streamId,
resourceIds: args.input.resources.map((res) => res.resourceId).join(',') // TODO: hack for now
@@ -205,14 +198,14 @@ module.exports = {
await saveActivity({
streamId: args.input.streamId,
resourceType: 'comment',
resourceId: id,
resourceId: comment.id,
actionType: 'comment_created',
userId: context.userId,
info: { input: args.input },
message: `Comment added: ${id} (${args.input})`
message: `Comment added: ${comment.id} (${args.input})`
})
return id
return comment.id
},
async commentEdit(parent, args, context) {
@@ -266,7 +259,7 @@ module.exports = {
await pubsub.publish('COMMENT_THREAD_ACTIVITY', {
commentThreadActivity: {
eventType: args.archived ? 'comment-archived' : 'comment-added'
type: args.archived ? 'comment-archived' : 'comment-added'
},
streamId: args.streamId,
commentId: args.commentId
@@ -296,23 +289,19 @@ module.exports = {
if (!stream.allowPublicComments && !stream.role)
throw new ApolloForbiddenError('You are not authorized.')
const { id, text } = await createCommentReply({
const reply = await createCommentReply({
authorId: context.userId,
parentCommentId: args.input.parentComment,
streamId: args.input.streamId,
text: args.input.text,
data: args.input.data
data: args.input.data,
blobIds: args.input.blobIds
})
await pubsub.publish('COMMENT_THREAD_ACTIVITY', {
commentThreadActivity: {
eventType: 'reply-added',
...args.input,
id,
text,
authorId: context.userId,
updatedAt: Date.now(),
createdAt: Date.now()
type: 'reply-added',
reply
},
streamId: args.input.streamId,
commentId: args.input.parentComment
@@ -327,7 +316,7 @@ module.exports = {
info: { input: args.input },
message: `Comment reply created.`
})
return id
return reply.id
}
},
Subscription: {
@@ -132,8 +132,12 @@ input CommentCreateInput {
"""
ProseMirror document object
"""
text: JSONObject!
text: JSONObject
data: JSONObject!
"""
IDs of uploaded blobs that should be attached to this comment
"""
blobIds: [String!]!
screenshot: String
}
@@ -143,7 +147,11 @@ input ReplyCreateInput {
"""
ProseMirror document object
"""
text: JSONObject!
text: JSONObject
"""
IDs of uploaded blobs that should be attached to this reply
"""
blobIds: [String!]!
data: JSONObject
}
@@ -153,7 +161,11 @@ input CommentEditInput {
"""
ProseMirror document object
"""
text: JSONObject!
text: JSONObject
"""
IDs of uploaded blobs that should be attached to this comment
"""
blobIds: [String!]!
}
extend type Mutation {
@@ -213,6 +225,17 @@ extend type Mutation {
@hasScope(scope: "streams:read")
}
type CommentActivityMessage {
type: String!
comment: Comment!
}
type CommentThreadActivityMessage {
type: String!
data: JSONObject
reply: Comment
}
extend type Subscription {
"""
Broadcasts "real-time" location data for viewer users.
@@ -220,11 +243,11 @@ extend type Subscription {
userViewerActivity(streamId: String!, resourceId: String!): JSONObject
"""
Subscribe to comment events. There's two ways to use this subscription:
Subscribe to new comment events. There's two ways to use this subscription:
- for a whole stream: do not pass in any resourceIds; this sub will get called whenever a comment (not reply) is added to any of the stream's resources.
- for a specific resource/set of resources: pass in a list of resourceIds (commit or object ids); this sub will get called when *any* of the resources provided get a comment.
"""
commentActivity(streamId: String!, resourceIds: [String]): JSONObject
commentActivity(streamId: String!, resourceIds: [String]): CommentActivityMessage!
@hasRole(role: "server:user")
@hasScope(scope: "streams:read")
@@ -233,7 +256,10 @@ extend type Subscription {
- a top level comment is deleted (trigger a deletion event outside)
- a top level comment receives a reply.
"""
commentThreadActivity(streamId: String!, commentId: String!): JSONObject
commentThreadActivity(
streamId: String!
commentId: String!
): CommentThreadActivityMessage!
@hasRole(role: "server:user")
@hasScope(scope: "streams:read")
}
@@ -5,25 +5,43 @@ const {
convertBasicStringToDocument,
isSerializedTextEditorValueSchema
} = require('@/modules/core/services/richTextEditorService')
const { isString } = require('lodash')
const { isString, uniq } = require('lodash')
const { getBlobs } = require('@/modules/blobstorage/services')
const { InvalidAttachmentsError } = require('@/modules/comments/errors')
const COMMENT_SCHEMA_VERSION = '1.0.0'
const COMMENT_SCHEMA_TYPE = 'stream_comment'
async function validateInputAttachments(streamId, blobIds) {
blobIds = uniq(blobIds || [])
if (!blobIds.length) return
const blobs = await getBlobs({ blobIds, streamId })
if (!blobs || blobs.length !== blobIds.length) {
throw new InvalidAttachmentsError('Attempting to attach invalid blobs to comment')
}
}
/**
* Build comment.text value from a ProseMirror doc
* @param {import("@tiptap/core").JSONContent} doc
* @param {{
* doc: import("@tiptap/core").JSONContent | undefined,
* blobIds: string[]
* }} param1
* @returns {import('@/modules/core/services/richTextEditorService').SmartTextEditorValueSchema}
*/
function buildCommentTextFromInput(doc) {
if (!isTextEditorDoc(doc)) {
throw new RichTextParseError('Unexpected comment input doc!')
function buildCommentTextFromInput({ doc = undefined, blobIds = [] }) {
if (!isTextEditorDoc(doc) && !blobIds.length) {
throw new RichTextParseError(
'Attempting to build comment text without document & attachments!'
)
}
return {
version: COMMENT_SCHEMA_VERSION,
type: COMMENT_SCHEMA_TYPE,
doc
doc,
blobIds
}
}
@@ -40,7 +58,7 @@ function ensureCommentSchema(stringOrSchema) {
// A basic string, convert it to the schema format
const basicTextDoc = convertBasicStringToDocument(stringOrSchema)
return buildCommentTextFromInput(basicTextDoc)
return buildCommentTextFromInput({ doc: basicTextDoc })
}
throw new RichTextParseError('Unexpected comment schema format')
@@ -48,5 +66,6 @@ function ensureCommentSchema(stringOrSchema) {
module.exports = {
buildCommentTextFromInput,
ensureCommentSchema
ensureCommentSchema,
validateInputAttachments
}
@@ -3,7 +3,8 @@ const crs = require('crypto-random-string')
const knex = require('@/db/knex')
const { ForbiddenError } = require('@/modules/shared/errors')
const {
buildCommentTextFromInput
buildCommentTextFromInput,
validateInputAttachments
} = require('@/modules/comments/services/commentTextService')
const Comments = () => knex('comments')
@@ -74,13 +75,21 @@ module.exports = {
if (stream && stream.resourceId !== input.streamId)
throw Error("Input streamId doesn't match the stream resource.resourceId")
const comment = { ...input }
delete comment.resources
const comment = {
streamId: input.streamId,
text: input.text,
data: input.data,
screenshot: input.screenshot
}
comment.id = crs({ length: 10 })
comment.authorId = userId
comment.text = buildCommentTextFromInput(input.text)
await validateInputAttachments(input.streamId, input.blobIds)
comment.text = buildCommentTextFromInput({
doc: input.text,
blobIds: input.blobIds
})
await Comments().insert(comment)
try {
@@ -100,14 +109,25 @@ module.exports = {
throw e // pass on to resolver
}
await module.exports.viewComment({ userId, commentId: comment.id }) // so we don't self mark a comment as unread the moment it's created
return comment
// Get new comment from DB, that way we don't have to mock/fill in the missing
// values
return module.exports.getComment({ id: comment.id, userId })
},
async createCommentReply({ authorId, parentCommentId, streamId, text, data }) {
async createCommentReply({
authorId,
parentCommentId,
streamId,
text,
data,
blobIds
}) {
await validateInputAttachments(streamId, blobIds)
const comment = {
id: crs({ length: 10 }),
authorId,
text: buildCommentTextFromInput(text),
text: buildCommentTextFromInput({ doc: text, blobIds }),
data,
streamId,
parentComment: parentCommentId
@@ -127,7 +147,9 @@ module.exports = {
}
await Comments().where({ id: parentCommentId }).update({ updatedAt: knex.fn.now() })
return comment
// Get new comment from DB, that way we don't have to mock/fill in the missing
// values
return module.exports.getComment({ id: comment.id, userId: authorId })
},
async editComment({ userId, input, matchUser = false }) {
@@ -136,8 +158,16 @@ module.exports = {
if (matchUser && editedComment.authorId !== userId)
throw new ForbiddenError("You cannot edit someone else's comments")
const newText = buildCommentTextFromInput(input.text)
await validateInputAttachments(input.streamId, input.blobIds)
const newText = buildCommentTextFromInput({
doc: input.text,
blobIds: input.blobIds
})
await Comments().where({ id: input.id }).update({ text: newText })
// Get new comment from DB, that way we don't have to mock/fill in the missing
// values
return module.exports.getComment({ id: input.id, userId })
},
async viewComment({ userId, commentId }) {
@@ -54,6 +54,7 @@ const writeComment = async ({ apollo, resources, shouldSucceed }) => {
input: {
streamId: resources.streamId,
text: buildCommentInputFromString('foo'),
blobIds: [],
data: {},
resources: [{ resourceId: resources.streamId, resourceType: 'stream' }]
}
@@ -133,6 +134,7 @@ const archiveMyComment = async ({ apollo, resources, shouldSucceed }) => {
input: {
streamId: resources.streamId,
text: buildCommentInputFromString('i wrote this myself'),
blobIds: [],
data: {},
resources: [
{ resourceId: resources.streamId, resourceType: 'stream' },
@@ -177,6 +179,7 @@ const editMyComment = async ({ apollo, resources, shouldSucceed }) => {
input: {
streamId: resources.streamId,
text: buildCommentInputFromString('i wrote this myself'),
blobIds: [],
data: {},
resources: [
{ resourceId: resources.streamId, resourceType: 'stream' },
@@ -194,7 +197,8 @@ const editMyComment = async ({ apollo, resources, shouldSucceed }) => {
input: {
streamId: resources.streamId,
id: commentId,
text: buildCommentInputFromString('im going to overwrite myself')
text: buildCommentInputFromString('im going to overwrite myself'),
blobIds: []
}
}
})
@@ -216,7 +220,8 @@ const editOthersComment = async ({ apollo, resources, shouldSucceed }) => {
id: resources.commentId,
text: buildCommentInputFromString(
'what you wrote is dumb, here, let me fix it for you'
)
),
blobIds: []
}
}
})
@@ -239,6 +244,7 @@ const replyToAComment = async ({ apollo, resources, shouldSucceed }) => {
text: buildCommentInputFromString(
'what you wrote is dump, here, let me fix it for you'
),
blobIds: [],
data: {}
}
}
@@ -293,6 +299,7 @@ const queryComments = async ({ apollo, resources, shouldSucceed }) => {
input: {
streamId: resources.streamId,
text: buildCommentInputFromString(`${key}`),
blobIds: [],
data: {},
resources: [{ resourceId: objectId, resourceType: 'object' }]
}
@@ -334,6 +341,7 @@ const queryStreamCommentCount = async ({ apollo, resources, shouldSucceed }) =>
input: {
streamId: resources.streamId,
text: buildCommentInputFromString('im expecting some replies here'),
blobIds: [],
data: {},
resources: [{ resourceId: resources.streamId, resourceType: 'stream' }]
}
@@ -365,6 +373,7 @@ const queryObjectCommentCount = async ({ apollo, resources, shouldSucceed }) =>
input: {
streamId: resources.streamId,
text: buildCommentInputFromString('im expecting some replies here'),
blobIds: [],
data: {},
resources: [{ resourceId: objectId, resourceType: 'object' }]
}
@@ -404,6 +413,7 @@ const queryCommitCommentCount = async ({ apollo, resources, shouldSucceed }) =>
input: {
streamId: resources.streamId,
text: buildCommentInputFromString('im expecting some replies here'),
blobIds: [],
data: {},
resources: [{ resourceId: commitId, resourceType: 'commit' }]
}
@@ -447,6 +457,7 @@ const queryCommitCollectionCommentCount = async ({
input: {
streamId: resources.streamId,
text: buildCommentInputFromString('im expecting some replies here'),
blobIds: [],
data: {},
resources: [{ resourceId: commitId, resourceType: 'commit' }]
}
@@ -837,6 +848,7 @@ describe('Graphql @comments', () => {
input: {
streamId: stream.id,
text: buildCommentInputFromString('foo'),
blobIds: [],
data: {},
resources: [{ resourceId: stream.id, resourceType: 'stream' }]
}
@@ -5,9 +5,11 @@ const commentsServiceMock = mockRequireModule(
['@/modules/comments/graph/resolvers/comments']
)
const path = require('path')
const { appRoot } = require('@/bootstrap')
const expect = require('chai').expect
const crs = require('crypto-random-string')
const { beforeEachContext } = require('@/test/hooks')
const { beforeEachContext, truncateTables } = require('@/test/hooks')
const { createUser } = require('@/modules/core/services/users')
const { createStream } = require('@/modules/core/services/streams')
const { createCommitByBranchName } = require('@/modules/core/services/commits')
@@ -36,6 +38,36 @@ const { buildApolloServer } = require('@/app')
const { addLoadersToCtx } = require('@/modules/shared')
const { Roles, AllScopes } = require('@/modules/core/helpers/mainConstants')
const { gql } = require('apollo-server-core')
const { createAuthTokenForUser } = require('@/test/authHelper')
const { uploadBlob } = require('@/test/blobHelper')
const { Comments } = require('@/modules/core/dbSchema')
const CommentWithRepliesFragment = gql`
fragment CommentWithReplies on Comment {
id
text {
doc
attachments {
id
fileName
streamId
}
}
replies(limit: 10) {
items {
id
text {
doc
attachments {
id
fileName
streamId
}
}
}
}
}
`
function buildCommentInputFromString(textString) {
return convertBasicStringToDocument(textString)
@@ -45,9 +77,10 @@ function generateRandomCommentText() {
return buildCommentInputFromString(crs({ length: 10 }))
}
// TODO: Improve coverage
describe('Comments @comments', () => {
/** @type {import('express').Express} */
let app
const user = {
name: 'The comment wizard',
email: 'comment@wizard.ry',
@@ -77,7 +110,8 @@ describe('Comments @comments', () => {
let commitId1, commitId2
before(async () => {
await beforeEachContext()
const { app: express } = await beforeEachContext()
app = express
user.id = await createUser(user)
otherUser.id = await createUser(otherUser)
@@ -960,8 +994,13 @@ describe('Comments @comments', () => {
describe('when authenticated', () => {
/** @type {import('apollo-server-express').ApolloServer} */
let apollo
let userToken
let blob1
before(async () => {
const scopes = AllScopes
// Init apollo instance w/ authenticated context
apollo = buildApolloServer({
context: () =>
addLoadersToCtx({
@@ -969,9 +1008,22 @@ describe('Comments @comments', () => {
userId: user.id,
role: Roles.Server.User,
token: 'asd',
scopes: AllScopes
scopes
})
})
// Init token for authenticating w/ REST API
userToken = await createAuthTokenForUser(user.id, scopes)
// Upload a small blob
blob1 = await uploadBlob(
app,
path.resolve(appRoot, './test/assets/testimage1.jpg'),
stream.id,
{
authToken: userToken
}
)
})
const createComment = (input = {}) =>
@@ -986,12 +1038,76 @@ describe('Comments @comments', () => {
streamId: stream.id,
resources: [{ resourceId: commitId1, resourceType: 'commit' }],
data: {},
blobIds: [],
...input
}
}
})
const createReply = (input = {}) =>
apollo.executeOperation({
query: gql`
mutation ($input: ReplyCreateInput!) {
commentReply(input: $input)
}
`,
variables: {
input: {
streamId: stream.id,
blobIds: [],
...input
}
}
})
describe('when reading comments', () => {
let emptyCommentId
before(async () => {
// Truncate comments
truncateTables([Comments.name])
// Create a single comment with a blob
const createCommentResult = await createComment({
text: generateRandomCommentText(),
blobIds: [blob1.blobId]
})
const parentCommentId = createCommentResult.data.commentCreate
if (!parentCommentId) throw new Error('Comment creation failed!')
// Create a reply with a blob
await createReply({
text: generateRandomCommentText(),
blobIds: [blob1.blobId],
parentComment: parentCommentId
})
// Create a reply with a blob, but no text
const emptyCommentResult = await createReply({
blobIds: [blob1.blobId],
parentComment: parentCommentId
})
emptyCommentId = emptyCommentResult.data.commentReply
if (!emptyCommentId) throw new Error('Comment creation failed!')
})
const readComment = (input = {}) =>
apollo.executeOperation({
query: gql`
query ($id: String!, $streamId: String!) {
comment(id: $id, streamId: $streamId) {
...CommentWithReplies
}
}
${CommentWithRepliesFragment}
`,
variables: {
streamId: stream.id,
...input
}
})
const readComments = (input = {}) =>
apollo.executeOperation({
query: gql`
@@ -1000,13 +1116,12 @@ describe('Comments @comments', () => {
totalCount
cursor
items {
id
text {
doc
}
...CommentWithReplies
}
}
}
${CommentWithRepliesFragment}
`,
variables: {
cursor: null,
@@ -1029,17 +1144,17 @@ describe('Comments @comments', () => {
{
id: 'b',
text: JSON.stringify(
buildCommentTextFromInput(
buildCommentInputFromString('new comment schema here')
)
buildCommentTextFromInput({
doc: buildCommentInputFromString('new comment schema here')
})
)
},
// New, but for some reason the text object is already deserialized
{
id: 'c',
text: buildCommentTextFromInput(
buildCommentInputFromString('another new comment schema here')
)
text: buildCommentTextFromInput({
doc: buildCommentInputFromString('another new comment schema here')
})
}
],
cursor: new Date().toISOString(),
@@ -1130,10 +1245,59 @@ describe('Comments @comments', () => {
runExpectationsOnTextNode(i, textParts[i].startsWith('http'))
})
})
;[
it('returns uploaded attachment metadata correctly', async () => {
const expectedMetadata = {
fileName: blob1.fileName,
id: blob1.blobId,
streamId: stream.id
}
const { data, errors } = await readComments()
expect(errors?.length || 0).to.eq(0)
// Check first comment
expect(data?.comments?.items?.length || 0).to.eq(1)
expect(data.comments.items[0].text?.attachments?.length || 0).to.eq(1)
expect(data.comments.items[0].text.attachments[0]).to.deep.equalInAnyOrder(
expectedMetadata
)
// Check first reply
expect(data.comments.items[0].replies?.items?.length || 0).to.eq(2)
expect(
data.comments.items[0].replies.items[0].text?.attachments?.length || 0
).to.eq(1)
expect(
data.comments.items[0].replies.items[0].text?.attachments[0]
).to.deep.equalInAnyOrder(expectedMetadata)
// Check 2nd reply
expect(
data.comments.items[0].replies.items[1].text?.attachments?.length || 0
).to.eq(1)
expect(
data.comments.items[0].replies.items[1].text?.attachments[0]
).to.deep.equalInAnyOrder(expectedMetadata)
})
it('returns a blob comment without text correctly', async () => {
const { data, errors } = await readComment({
id: emptyCommentId
})
expect(errors?.length || 0).to.eq(0)
expect(data.comment).to.be.ok
expect(data.comment.text.doc).to.be.null
expect(data.comment.text.attachments.length).to.be.greaterThan(0)
})
const unexpectedValDataset = [
{ display: 'number', value: 3 },
{ display: 'random object', value: { a: 1, b: 2 } }
].forEach(({ display, value }) => {
]
unexpectedValDataset.forEach(({ display, value }) => {
it(`unexpected text value (${display}) in DB throw sanitized errors`, async () => {
const item = {
id: '1',
@@ -1157,59 +1321,94 @@ describe('Comments @comments', () => {
})
})
describe('when creating a new comment thread', () => {
it('invalid input text schema throws an error', async () => {
const { data, errors } = await createComment({
text: { invalid: { json: ['object'] } }
})
expect(data?.commentCreate).to.not.be.ok
expect((errors || []).map((e) => e.message).join(';')).to.contain(
'Unexpected comment input doc!'
)
})
})
describe('when replying to an existing thread', () => {
const creatingOrReplyingDataSet = [
{ replying: true, display: 'replying to an existing thread' },
{ creating: true, display: 'creating a new comment thread' }
]
creatingOrReplyingDataSet.forEach(({ replying, creating, display }) => {
let parentCommentId
before(async () => {
// Create comment for attaching replies to
const { data } = await createComment({
text: generateRandomCommentText()
})
parentCommentId = data.commentCreate
if (!parentCommentId) {
throw new Error("Couldn't successfully create comment for tests!")
}
})
const createReply = (input = {}) =>
apollo.executeOperation({
query: gql`
mutation ($input: ReplyCreateInput!) {
commentReply(input: $input)
}
`,
variables: {
input: {
streamId: stream.id,
const createOrReplyComment = (input = {}) =>
creating
? createComment(input)
: createReply({
parentComment: parentCommentId,
...input
})
const getResult = (data) => (creating ? data?.commentCreate : data?.commentReply)
describe(`when ${display}`, () => {
before(async () => {
if (replying) {
// Create comment for attaching replies to
const { data } = await createComment({
text: generateRandomCommentText()
})
parentCommentId = data.commentCreate
if (!parentCommentId) {
throw new Error("Couldn't successfully create comment for tests!")
}
}
})
it('invalid input text schema throws an error', async () => {
const { data, errors } = await createReply({
text: { invalid: { json: ['object'] } }
it('invalid blob ids get rejected', async () => {
const { data, errors } = await createOrReplyComment({
blobIds: ['idunno'],
text: generateRandomCommentText()
})
expect(getResult(data)).to.not.be.ok
expect((errors || []).map((e) => e.message).join(';')).to.contain(
'Attempting to attach invalid blobs to comment'
)
})
expect(data?.commentReply).to.not.be.ok
expect((errors || []).map((e) => e.message).join(';')).to.contain(
'Unexpected comment input doc!'
)
it('valid blob ids get properly attached', async () => {
const text = buildCommentInputFromString(
"here's a comment with a nice attachment!!"
)
const { data, errors } = await createOrReplyComment({
blobIds: [blob1.blobId],
text
})
expect(getResult(data)).to.be.ok
expect(errors || []).to.be.empty
})
const invalidInputDataSet = [
{
text: { invalid: { json: ['object'] } },
blobIds: [],
display: 'invalid input text'
},
{ text: null, blobIds: [], display: 'no attachments & text' }
]
invalidInputDataSet.forEach(({ text, blobIds, display }) => {
it(`input with ${display} throws an error`, async () => {
const { data, errors } = await createOrReplyComment({
text,
blobIds
})
expect(getResult(data)).to.not.be.ok
expect((errors || []).map((e) => e.message).join(';')).to.contain(
'Attempting to build comment text without document & attachments!'
)
})
})
it('an empty document with blobs attached can be successfully posted', async () => {
const { data, errors } = await createOrReplyComment({
blobIds: [blob1.blobId],
text: undefined
})
expect(getResult(data)).to.be.ok
expect(errors || []).to.be.empty
})
})
})
})
+16
View File
@@ -63,5 +63,21 @@ module.exports = {
role: 'server_acl.role'
}
},
Comments: {
name: 'comments',
knex: () => knex('comments'),
col: {
id: 'comments.id',
streamId: 'comments.streamId',
authorId: 'comments.authorId',
createdAt: 'comments.createdAt',
updatedAt: 'comments.updatedAt',
text: 'comments.text',
screenshot: 'comments.screenshot',
data: 'comments.data',
archived: 'comments.archived',
parentComment: 'comments.parentComment'
}
},
knex
}
@@ -0,0 +1,15 @@
const { getBlobs } = require('@/modules/blobstorage/services')
const { keyBy } = require('lodash')
module.exports = {
SmartTextEditorValue: {
attachments(parent) {
const { blobIds } = parent
if (!blobIds) return null
const blobs = getBlobs({ blobIds })
const blobsById = keyBy(blobs, (b) => b.id)
return blobIds.map((blobId) => blobsById[blobId] || null)
}
}
}
@@ -10,7 +10,13 @@ type SmartTextEditorValue {
type: String!
"""
The actual (ProseMirror) document representing the text
The actual (ProseMirror) document representing the text. Can be empty,
if there are attachments.
"""
doc: JSONObject!
doc: JSONObject
"""
File attachments, if any
"""
attachments: [BlobMetadata!]
}
@@ -4,7 +4,8 @@ const { trim, isString, isObjectLike } = require('lodash')
* @typedef {SmartTextEditorValueSchema}
* @property {string} version The version of the schema
* @property {string} type The type of value (comment, issue, blog post etc.)
* @property {import("@tiptap/core").JSONContent} doc The ProseMirror document representing the text
* @property {import("@tiptap/core").JSONContent} [doc] The ProseMirror document representing the text, if any
* @property {string[]} [blobIds] Attachment blob IDs, if any
*/
/**
@@ -18,7 +19,9 @@ function isTextEditorDoc(value) {
}
function isTextEditorValueSchema(value) {
return isObjectLike(value) && value.type && value.version && value.doc
return (
isObjectLike(value) && value.type && value.version && (value.doc || value.blobIds)
)
}
/**
+4 -4
View File
@@ -53,7 +53,7 @@ const validateStreamRole = ({ requiredRole }) =>
requiredRole,
rolesLookup: getRoles,
iddqd: Roles.Stream.Owner,
roleGetter: (context) => context.stream.role
roleGetter: (context) => context.stream?.role
})
// this could be still useful, if the operation doesnt require a stream context
@@ -116,7 +116,7 @@ const allowForRegisteredUsersOnPublicStreamsEvenWithoutRole = async ({
context,
authResult
}) =>
context.auth && context.stream.isPublic
context.auth && context.stream?.isPublic
? authSuccess(context)
: { context, authResult }
@@ -124,7 +124,7 @@ const allowForAllRegisteredUsersOnPublicStreamsWithPublicComments = async ({
context,
authResult
}) =>
context.auth && context.stream.isPublic && context.stream.allowPublicComments
context.auth && context.stream?.isPublic && context.stream?.allowPublicComments
? authSuccess(context)
: { context, authResult }
@@ -158,7 +158,7 @@ const authMiddlewareCreator = (steps) => {
if (authResult.error instanceof SFE) status = 403
}
return res.status(status).send(message)
return res.status(status).json({ error: message })
}
next()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+16
View File
@@ -0,0 +1,16 @@
const { AllScopes } = require('@/modules/core/helpers/mainConstants')
const { createPersonalAccessToken } = require('@/modules/core/services/tokens')
/**
* Create an auth token for the specified user (use only during tests, of course)
* @param {string} userId User's ID
* @param {string[]} scopes Specify scopes you want to allow. Defaults to all scopes.
* @returns {Promise<string>}
*/
async function createAuthTokenForUser(userId, scopes = AllScopes) {
return await createPersonalAccessToken(userId, 'test-runner-token', scopes)
}
module.exports = {
createAuthTokenForUser
}
+28
View File
@@ -0,0 +1,28 @@
const request = require('supertest')
/**
* Upload a blob from a test runner
* @param {import('express').Express} app Test runner express instance
* @param {string} filePath Absolute path to file on the server
* @param {string} streamId
* @param {string} authToken Token for authenticating with server, if any
* @returns {Promise<Object>} Response result structure
*/
async function uploadBlob(app, filePath, streamId, { authToken }) {
const response = await request(app)
.post(`/api/stream/${streamId}/blob`)
.attach('file', filePath)
.set('Accept', 'application/json')
.set('Authorization', authToken ? `Bearer ${authToken}` : undefined)
const uploadResults = response.body.uploadResults || []
if (!uploadResults.length) {
throw new Error('Test runner blob upload received unexpected results!')
}
return uploadResults[0]
}
module.exports = {
uploadBlob
}
+1 -2
View File
@@ -5018,7 +5018,6 @@ __metadata:
apexcharts: ^3.33.1
babel-eslint: ^10.1.0
babel-plugin-lodash: ^3.3.4
crypto-random-string: ^3.3.0
dompurify: ^2.3.6
duplicate-dependencies-webpack-plugin: ^1.0.2
esbuild-loader: ^2.18.0
@@ -11505,7 +11504,7 @@ __metadata:
languageName: node
linkType: hard
"crypto-random-string@npm:^3.2.0, crypto-random-string@npm:^3.3.0, crypto-random-string@npm:^3.3.1":
"crypto-random-string@npm:^3.2.0, crypto-random-string@npm:^3.3.1":
version: 3.3.1
resolution: "crypto-random-string@npm:3.3.1"
dependencies: