feat: stream comment attachments
This commit is contained in:
+1
-1
@@ -23,4 +23,4 @@ utils/helm/speckle-server/templates
|
||||
venv
|
||||
|
||||
.*.{ts,js,vue,tsx,jsx}
|
||||
generated/**/*
|
||||
**/generated/**/*
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Vendored
+7
@@ -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",
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user