diff --git a/packages/app/src/components/PostCreator/index.jsx b/packages/app/src/components/PostCreator/index.jsx index cb2b74cc..d0e7d30a 100755 --- a/packages/app/src/components/PostCreator/index.jsx +++ b/packages/app/src/components/PostCreator/index.jsx @@ -17,656 +17,755 @@ import SearchModel from "@models/search" import "./index.less" const DEFAULT_POST_POLICY = { - maxMessageLength: 512, - acceptedMimeTypes: [ - "image/gif", - "image/png", - "image/jpeg", - "image/bmp", - "video/mp4", - "video/webm", - "video/quicktime" - ], - maximunFilesPerRequest: 10 + maxMessageLength: 512, + acceptedMimeTypes: [ + "image/gif", + "image/png", + "image/jpeg", + "image/bmp", + "video/mp4", + "video/webm", + "video/quicktime", + ], + maximunFilesPerRequest: 10, } +const VisibilityOptionLabel = ({ label, icon }) => ( +
+ {icon} + {label} +
+) + +const visibilityOptions = [ + { + value: "public", + label: } label="Public" />, + }, + { + value: "private", + label: ( + } label="Private" /> + ), + }, +] + export default class PostCreator extends React.Component { - state = { - pending: [], - loading: false, - uploaderVisible: false, - - postMessage: "", - postAttachments: [], - postPoll: null, - - fileList: [], - postingPolicy: DEFAULT_POST_POLICY, - - mentionsLoadedData: [] - } - - pollRef = React.createRef() - - creatorRef = React.createRef() - - cleanPostData = () => { - this.setState({ - postMessage: "", - postAttachments: [], - fileList: [] - }) - } - - toggleUploaderVisibility = (to) => { - to = to ?? !this.state.uploaderVisible - - if (to === this.state.uploaderVisible) { - return - } - - this.setState({ - uploaderVisible: to - }) - } - - canSubmit = () => { - const { postMessage, postAttachments, pending, postingPolicy } = this.state - - const messageLengthValid = postMessage.length !== 0 && postMessage.length < postingPolicy.maxMessageLength - - if (pending.length !== 0) { - return false - } - - if (!messageLengthValid && postAttachments.length === 0) { - return false - } - - return true - } - - submit = lodash.debounce(async () => { - if (this.state.loading) { - return false - } - - if (!this.canSubmit()) { - return false - } - - await this.setState({ - loading: true, - uploaderVisible: false - }) - - const { postMessage, postAttachments } = this.state - - const payload = { - message: postMessage, - attachments: postAttachments, - timestamp: DateTime.local().toISO(), - } - - if (this.pollRef.current) { - let { options } = this.pollRef.current.getFieldsValue() - - payload.poll_options = options.filter((option) => !!option.label) - } - - let response = null - - if (this.props.reply_to) { - payload.reply_to = this.props.reply_to - } - - if (this.props.edit_post) { - response = await PostModel.update(this.props.edit_post, payload).catch(error => { - console.error(error) - antd.message.error(error) - - return false - }) - } else { - response = await PostModel.create(payload).catch(error => { - console.error(error) - antd.message.error(error) - - return false - }) - } - - this.setState({ - loading: false - }) - - if (response) { - this.cleanPostData() - - if (typeof this.props.onPost === "function") { - this.props.onPost() - } - - if (typeof this.props.close === "function") { - this.props.close() - } - - if (this.props.reply_to) { - app.navigation.goToPost(this.props.reply_to) - } - } - }, 50) - - uploadFile = async (req) => { - this.toggleUploaderVisibility(false) - - const request = await app.cores.remoteStorage.uploadFile(req.file) - .catch(error => { - console.error(error) - antd.message.error(error) - - req.onError(error) - - return false - }) - - if (request) { - console.log(`Upload done >`, request) - - return req.onSuccess(request) - } - } - - removeAttachment = (file_uid) => { - this.setState({ - postAttachments: this.state.postAttachments.filter((file) => file.uid !== file_uid), - fileList: this.state.fileList.filter((file) => file.uid !== file_uid) - }) - } - - addAttachment = (file) => { - if (Array.isArray(file)) { - return this.setState({ - postAttachments: [...this.state.postAttachments, ...file] - }) - } - - return this.setState({ - postAttachments: [...this.state.postAttachments, file] - }) - } - - uploaderScrollToEnd = () => { - // scroll to max right - const element = document.querySelector(".ant-upload-list-picture-card") - - // calculate the element's width and scroll to the end - const scrollToLeft = element.scrollWidth - element.clientWidth - - if (element) { - element.scrollTo({ - top: 0, - left: scrollToLeft, - behavior: "smooth", - }) - } - } - - onUploaderChange = (change) => { - if (this.state.fileList !== change.fileList) { - this.setState({ - fileList: change.fileList - }) - } - - switch (change.file.status) { - case "uploading": { - this.toggleUploaderVisibility(false) - - this.setState({ - pending: [...this.state.pending, change.file.uid] - }) - - this.uploaderScrollToEnd() - - break - } - case "done": { - // remove pending file - this.setState({ - pending: this.state.pending.filter(uid => uid !== change.file.uid) - }) - - if (Array.isArray(change.file.response.files)) { - change.file.response.files.forEach((file) => { - this.addAttachment(file) - }) - } else { - this.addAttachment(change.file.response) - } - - // scroll to end - this.uploaderScrollToEnd() - - break - } - case "error": { - // remove pending file - this.setState({ - pending: this.state.pending.filter(uid => uid !== change.file.uid) - }) - - // remove file from list - this.removeAttachment(change.file.uid) - } - default: { - break - } - } - } - - handleMessageInputChange = (inputText) => { - // if the fist character is a space or a whitespace remove it - if (inputText[0] === " " || inputText[0] === "\n") { - inputText = inputText.slice(1) - } - - this.setState({ - postMessage: inputText - }) - } - - handleMessageInputKeydown = async (e) => { - // detect if the user pressed `enter` key and submit the form, but only if the `shift` key is not pressed - if (e.keyCode === 13 && !e.shiftKey) { - e.preventDefault() - e.stopPropagation() - - if (this.state.loading) { - return false - } - - return await this.submit() - } - } - - handleOnMentionSearch = lodash.debounce(async (value) => { - const results = await SearchModel.userSearch(`username:${value}`) - - this.setState({ - mentionsLoadedData: results - }) - - }, 300) - - updateFileList = (uid, newValue) => { - let updatedFileList = this.state.fileList - - // find the file in the list - const index = updatedFileList.findIndex(file => file.uid === uid) - - // update the file - updatedFileList[index] = newValue - - // update the state - this.setState({ - fileList: updatedFileList - }) - - return updatedFileList - } - - handleManualUpload = async (file) => { - if (!file) { - throw new Error(`No file provided`) - } - - const isValidFormat = (fileType) => { - return this.state.postingPolicy.acceptedMimeTypes.includes(fileType) - } - - if (!isValidFormat(file.type)) { - app.cores.notifications.new({ - type: "error", - title: `Invalid format (${file.type})`, - message: "Only the following file formats are allowed: " + this.state.postingPolicy.acceptedMimeTypes.join(", ") - }) - return null - throw new Error(`Invalid file format`) - } - - file.thumbUrl = URL.createObjectURL(file) - file.uid = `${file.name}-${Math.random() * 1000}` - - file.status = "uploading" - - // add file to the uploader - this.onUploaderChange({ - file, - fileList: [...this.state.fileList, file], - }) - - // upload the file - await this.uploadFile({ - file, - onSuccess: (response) => { - file.status = "done" - file.response = response - - this.onUploaderChange({ - file: file, - fileList: this.updateFileList(file.uid, file) - }) - }, - onError: (error) => { - file.status = "error" - file.error = error - - this.onUploaderChange({ - file: file, - fileList: this.updateFileList(file.uid, file) - }) - } - }) - - return file - } - - handlePaste = async ({ clipboardData }) => { - if (clipboardData && clipboardData.items.length > 0) { - // check if the clipboard contains a file - const hasFile = Array.from(clipboardData.items).some(item => item.kind === "file") - - if (!hasFile) { - return false - } - - for (let index = 0; index < clipboardData.items.length; index++) { - const item = clipboardData.items[index] - - let file = await clipboardEventFileToFile(item).catch((error) => { - console.error(error) - app.message.error(`Failed to upload file:`, error.message) - - return false - }) - - this.handleManualUpload(file).catch((error) => { - console.error(error) - return false - }) - } - } - } - - renderUploadPreviewItem = (item, file, list, actions) => { - const uploading = file.status === "uploading" - - const onClickDelete = () => { - this.removeAttachment(file.uid) - } - - return
-
- -
- -
- { - uploading && - } - { - !uploading && - } - /> - - } -
-
- } - - handleDrag = (event) => { - event.preventDefault() - event.stopPropagation() - - console.log(event) - - if (event.type === "dragenter") { - this.toggleUploaderVisibility(true) - } else if (event.type === "dragleave") { - // check if mouse is over the uploader or outside the creatorRef - if (this.state.uploaderVisible && !this.creatorRef.current.contains(event.target)) { - this.toggleUploaderVisibility(false) - } - } - } - - handleUploadClick = () => { - // create a new dialog - const dialog = document.createElement("input") - - // set the dialog type to file - dialog.type = "file" - - // set the dialog accept to the accepted files - dialog.accept = this.state.postingPolicy.acceptedMimeTypes - - dialog.multiple = true - - // add a listener to the dialog - dialog.addEventListener("change", (event) => { - // get the files - const files = event.target.files - - // loop through the files - for (let index = 0; index < files.length; index++) { - const file = files[index] - - this.handleManualUpload(file).catch((error) => { - console.error(error) - return false - }) - } - }) - - // click the dialog - dialog.click() - } - - handleAddPoll = () => { - if (!this.state.postPoll) { - this.setState({ - postPoll: [] - }) - } - } - - handleDeletePoll = () => { - this.setState({ - postPoll: null - }) - } - - componentDidMount = async () => { - if (this.props.edit_post) { - await this.setState({ - loading: true, - postId: this.props.edit_post, - }) - - const post = await PostModel.getPost({ post_id: this.props.edit_post }) - - await this.setState({ - loading: false, - postMessage: post.message, - postAttachments: post.attachments.map((attachment) => { - return { - ...attachment, - uid: attachment.id, - } - }), - fileList: post.attachments.map((attachment) => { - return { - ...attachment, - uid: attachment.id, - id: attachment.id, - thumbUrl: attachment.url, - status: "done", - } - }), - postPoll: post.poll_options - }) - } - // fetch the posting policy - //this.fetchUploadPolicy() - - // add a listener to the window - document.addEventListener("paste", this.handlePaste) - } - - componentWillUnmount() { - document.removeEventListener("paste", this.handlePaste) - } - - componentDidUpdate(prevProps, prevState) { - // if pending is not empty and is not loading - if (this.state.pending.length > 0 && !this.state.loading) { - this.setState({ loading: true }) - } else if (this.state.pending.length === 0 && this.state.loading) { - this.setState({ loading: false }) - } - } - - render() { - const { postMessage, fileList, loading, uploaderVisible, postingPolicy } = this.state - - const editMode = !!this.props.edit_post - - const showHeader = !!this.props.edit_post || this.props.reply_to - - return
- { - showHeader &&
- { - this.props.edit_post &&
-

- - Editing post -

-
- } - - { - this.props.reply_to &&
-

- - Replaying to -

- - { - this.props.close() - app.navigation.goToPost(this.props.reply_to) - }} - - /> -
- } -
- } - -
-
- -
- - { - return { - key: item.id, - value: item.username, - label: <> - - {item.username} - , - } - })} - onSearch={this.handleOnMentionSearch} - /> - -
- : (editMode ? : )} - /> -
-
- -
- -
-

Drag and drop files here

- Max {humanSize.fromBytes(postingPolicy.maximumFileSize)} -
-
-
- - { - this.state.postPoll && - } - -
- } - /> - - } - onClick={this.handleAddPoll} - /> -
-
- } -} \ No newline at end of file + state = { + pending: [], + loading: false, + uploaderVisible: false, + + postMessage: "", + postAttachments: [], + postPoll: null, + postVisibility: "public", + + fileList: [], + postingPolicy: DEFAULT_POST_POLICY, + + mentionsLoadedData: [], + } + + pollRef = React.createRef() + + creatorRef = React.createRef() + + cleanPostData = () => { + this.setState({ + postMessage: "", + postAttachments: [], + fileList: [], + }) + } + + toggleUploaderVisibility = (to) => { + to = to ?? !this.state.uploaderVisible + + if (to === this.state.uploaderVisible) { + return + } + + this.setState({ + uploaderVisible: to, + }) + } + + canSubmit = () => { + const { postMessage, postAttachments, pending, postingPolicy } = + this.state + + const messageLengthValid = + postMessage.length !== 0 && + postMessage.length < postingPolicy.maxMessageLength + + if (pending.length !== 0) { + return false + } + + if (!messageLengthValid && postAttachments.length === 0) { + return false + } + + return true + } + + submit = lodash.debounce(async () => { + if (this.state.loading) { + return false + } + + if (!this.canSubmit()) { + return false + } + + await this.setState({ + loading: true, + uploaderVisible: false, + }) + + const { postMessage, postAttachments } = this.state + + const payload = { + message: postMessage, + attachments: postAttachments, + timestamp: DateTime.local().toISO(), + visibility: this.state.postVisibility, + } + + if (this.pollRef.current) { + let { options } = this.pollRef.current.getFieldsValue() + + payload.poll_options = options.filter((option) => !!option.label) + } + + let response = null + + if (this.props.reply_to) { + payload.reply_to = this.props.reply_to + } + + if (this.props.edit_post) { + response = await PostModel.update( + this.props.edit_post, + payload, + ).catch((error) => { + console.error(error) + antd.message.error(error) + + return false + }) + } else { + response = await PostModel.create(payload).catch((error) => { + console.error(error) + antd.message.error(error) + + return false + }) + } + + this.setState({ + loading: false, + }) + + if (response) { + this.cleanPostData() + + if (typeof this.props.onPost === "function") { + this.props.onPost() + } + + if (typeof this.props.close === "function") { + this.props.close() + } + + if (this.props.reply_to) { + app.navigation.goToPost(this.props.reply_to) + } + } + }, 50) + + uploadFile = async (req) => { + this.toggleUploaderVisibility(false) + + const request = await app.cores.remoteStorage + .uploadFile(req.file) + .catch((error) => { + console.error(error) + antd.message.error(error) + + req.onError(error) + + return false + }) + + if (request) { + console.log(`Upload done >`, request) + + return req.onSuccess(request) + } + } + + removeAttachment = (file_uid) => { + this.setState({ + postAttachments: this.state.postAttachments.filter( + (file) => file.uid !== file_uid, + ), + fileList: this.state.fileList.filter( + (file) => file.uid !== file_uid, + ), + }) + } + + addAttachment = (file) => { + if (Array.isArray(file)) { + return this.setState({ + postAttachments: [...this.state.postAttachments, ...file], + }) + } + + return this.setState({ + postAttachments: [...this.state.postAttachments, file], + }) + } + + uploaderScrollToEnd = () => { + // scroll to max right + const element = document.querySelector(".ant-upload-list-picture-card") + + // calculate the element's width and scroll to the end + const scrollToLeft = element.scrollWidth - element.clientWidth + + if (element) { + element.scrollTo({ + top: 0, + left: scrollToLeft, + behavior: "smooth", + }) + } + } + + onUploaderChange = (change) => { + if (this.state.fileList !== change.fileList) { + this.setState({ + fileList: change.fileList, + }) + } + + switch (change.file.status) { + case "uploading": { + this.toggleUploaderVisibility(false) + + this.setState({ + pending: [...this.state.pending, change.file.uid], + }) + + this.uploaderScrollToEnd() + + break + } + case "done": { + // remove pending file + this.setState({ + pending: this.state.pending.filter( + (uid) => uid !== change.file.uid, + ), + }) + + if (Array.isArray(change.file.response.files)) { + change.file.response.files.forEach((file) => { + this.addAttachment(file) + }) + } else { + this.addAttachment(change.file.response) + } + + // scroll to end + this.uploaderScrollToEnd() + + break + } + case "error": { + // remove pending file + this.setState({ + pending: this.state.pending.filter( + (uid) => uid !== change.file.uid, + ), + }) + + // remove file from list + this.removeAttachment(change.file.uid) + } + default: { + break + } + } + } + + handleMessageInputChange = (inputText) => { + // if the fist character is a space or a whitespace remove it + if (inputText[0] === " " || inputText[0] === "\n") { + inputText = inputText.slice(1) + } + + this.setState({ + postMessage: inputText, + }) + } + + handleMessageInputKeydown = async (e) => { + // detect if the user pressed `enter` key and submit the form, but only if the `shift` key is not pressed + if (e.keyCode === 13 && !e.shiftKey) { + e.preventDefault() + e.stopPropagation() + + if (this.state.loading) { + return false + } + + return await this.submit() + } + } + + handleOnMentionSearch = lodash.debounce(async (value) => { + const results = await SearchModel.userSearch(`username:${value}`) + + this.setState({ + mentionsLoadedData: results, + }) + }, 300) + + updateFileList = (uid, newValue) => { + let updatedFileList = this.state.fileList + + // find the file in the list + const index = updatedFileList.findIndex((file) => file.uid === uid) + + // update the file + updatedFileList[index] = newValue + + // update the state + this.setState({ + fileList: updatedFileList, + }) + + return updatedFileList + } + + handleManualUpload = async (file) => { + if (!file) { + throw new Error(`No file provided`) + } + + const isValidFormat = (fileType) => { + return this.state.postingPolicy.acceptedMimeTypes.includes(fileType) + } + + if (!isValidFormat(file.type)) { + app.cores.notifications.new({ + type: "error", + title: `Invalid format (${file.type})`, + message: + "Only the following file formats are allowed: " + + this.state.postingPolicy.acceptedMimeTypes.join(", "), + }) + return null + throw new Error(`Invalid file format`) + } + + file.thumbUrl = URL.createObjectURL(file) + file.uid = `${file.name}-${Math.random() * 1000}` + + file.status = "uploading" + + // add file to the uploader + this.onUploaderChange({ + file, + fileList: [...this.state.fileList, file], + }) + + // upload the file + await this.uploadFile({ + file, + onSuccess: (response) => { + file.status = "done" + file.response = response + + this.onUploaderChange({ + file: file, + fileList: this.updateFileList(file.uid, file), + }) + }, + onError: (error) => { + file.status = "error" + file.error = error + + this.onUploaderChange({ + file: file, + fileList: this.updateFileList(file.uid, file), + }) + }, + }) + + return file + } + + handlePaste = async ({ clipboardData }) => { + if (clipboardData && clipboardData.items.length > 0) { + // check if the clipboard contains a file + const hasFile = Array.from(clipboardData.items).some( + (item) => item.kind === "file", + ) + + if (!hasFile) { + return false + } + + for (let index = 0; index < clipboardData.items.length; index++) { + const item = clipboardData.items[index] + + let file = await clipboardEventFileToFile(item).catch( + (error) => { + console.error(error) + app.message.error( + `Failed to upload file:`, + error.message, + ) + + return false + }, + ) + + this.handleManualUpload(file).catch((error) => { + console.error(error) + return false + }) + } + } + } + + handleVisibilityChange = (key) => { + this.setState({ postVisibility: key }) + } + + renderUploadPreviewItem = (item, file, list, actions) => { + const uploading = file.status === "uploading" + + const onClickDelete = () => { + this.removeAttachment(file.uid) + } + + return ( +
+
+ +
+ +
+ {uploading && ( + + )} + {!uploading && ( + + } /> + + )} +
+
+ ) + } + + handleDrag = (event) => { + event.preventDefault() + event.stopPropagation() + + console.log(event) + + if (event.type === "dragenter") { + this.toggleUploaderVisibility(true) + } else if (event.type === "dragleave") { + // check if mouse is over the uploader or outside the creatorRef + if ( + this.state.uploaderVisible && + !this.creatorRef.current.contains(event.target) + ) { + this.toggleUploaderVisibility(false) + } + } + } + + handleUploadClick = () => { + // create a new dialog + const dialog = document.createElement("input") + + // set the dialog type to file + dialog.type = "file" + + // set the dialog accept to the accepted files + dialog.accept = this.state.postingPolicy.acceptedMimeTypes + + dialog.multiple = true + + // add a listener to the dialog + dialog.addEventListener("change", (event) => { + // get the files + const files = event.target.files + + // loop through the files + for (let index = 0; index < files.length; index++) { + const file = files[index] + + this.handleManualUpload(file).catch((error) => { + console.error(error) + return false + }) + } + }) + + // click the dialog + dialog.click() + } + + handleAddPoll = () => { + if (!this.state.postPoll) { + this.setState({ + postPoll: [], + }) + } + } + + handleDeletePoll = () => { + this.setState({ + postPoll: null, + }) + } + + componentDidMount = async () => { + if (this.props.edit_post) { + await this.setState({ + loading: true, + postId: this.props.edit_post, + }) + + const post = await PostModel.getPost({ + post_id: this.props.edit_post, + }) + + await this.setState({ + loading: false, + postMessage: post.message, + postAttachments: post.attachments.map((attachment) => { + return { + ...attachment, + uid: attachment.id, + } + }), + fileList: post.attachments.map((attachment) => { + return { + ...attachment, + uid: attachment.id, + id: attachment.id, + thumbUrl: attachment.url, + status: "done", + } + }), + postPoll: post.poll_options, + postVisibility: post.visibility, + }) + } + // fetch the posting policy + //this.fetchUploadPolicy() + + // add a listener to the window + document.addEventListener("paste", this.handlePaste) + } + + componentWillUnmount() { + document.removeEventListener("paste", this.handlePaste) + } + + componentDidUpdate(prevProps, prevState) { + // if pending is not empty and is not loading + if (this.state.pending.length > 0 && !this.state.loading) { + this.setState({ loading: true }) + } else if (this.state.pending.length === 0 && this.state.loading) { + this.setState({ loading: false }) + } + } + + render() { + const { + postMessage, + fileList, + loading, + uploaderVisible, + postingPolicy, + } = this.state + + const editMode = !!this.props.edit_post + + const showHeader = !!this.props.edit_post || this.props.reply_to + + return ( +
+ {showHeader && ( +
+ {this.props.edit_post && ( +
+

+ + Editing post +

+
+ )} + + {this.props.reply_to && ( +
+

+ + Replaying to +

+ + { + this.props.close() + app.navigation.goToPost( + this.props.reply_to, + ) + }} + /> +
+ )} +
+ )} + +
+
+ +
+ + { + return { + key: item.id, + value: item.username, + label: ( + <> + + {item.username} + + ), + } + })} + onSearch={this.handleOnMentionSearch} + /> + +
+ + ) : editMode ? ( + + ) : ( + + ) + } + /> +
+
+ +
+ +
+

Drag and drop files here

+ + Max{" "} + {humanSize.fromBytes( + postingPolicy.maximumFileSize, + )} + +
+
+
+ + {this.state.postPoll && ( + + )} + +
+ } + /> + + } + onClick={this.handleAddPoll} + /> + + +
+
+ ) + } +} diff --git a/packages/app/src/components/PostCreator/index.less b/packages/app/src/components/PostCreator/index.less index 4564e970..37fc22f3 100755 --- a/packages/app/src/components/PostCreator/index.less +++ b/packages/app/src/components/PostCreator/index.less @@ -1,380 +1,387 @@ @file_preview_borderRadius: 7px; .postCreator { - position: relative; + position: relative; - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; - align-items: center; + align-items: center; - width: 100%; - z-index: 55; + width: 100%; + z-index: 55; - max-width: 600px; - border-radius: 12px; + max-width: 600px; + border-radius: 12px; - background-color: var(--background-color-accent); - padding: 15px; + background-color: var(--background-color-accent); + padding: 15px; - gap: 10px; + gap: 10px; - .postCreator-header { - display: flex; - flex-direction: row; + .postCreator-header { + display: flex; + flex-direction: row; - align-items: center; + align-items: center; - width: 100%; + width: 100%; - p { - margin: 0; - } + p { + margin: 0; + } - .postCreator-header-indicator { - display: flex; - flex-direction: row; + .postCreator-header-indicator { + display: flex; + flex-direction: row; - gap: 7px; + gap: 7px; - align-items: center; - } - } + align-items: center; + } + } - .actions { - display: inline-flex; - flex-direction: row; + .actions { + display: inline-flex; + flex-direction: row; - align-items: center; - justify-content: flex-start; + align-items: center; + justify-content: flex-start; - width: 100%; - padding: 0 10px; + width: 100%; + padding: 0 10px; - gap: 10px; + gap: 10px; - transition: all 150ms ease-in-out; + transition: all 150ms ease-in-out; - .ant-btn { - font-size: 1.2rem; + .ant-btn { + font-size: 1.2rem; - color: var(--text-color); + color: var(--text-color); - opacity: 0.7; + opacity: 0.7; - svg { - margin: 0; - } - } - } + svg { + margin: 0; + } + } + } - .uploader { - position: relative; + .uploader { + position: relative; - display: flex; - flex-direction: row; + display: flex; + flex-direction: row; - align-items: center; - justify-content: center; + align-items: center; + justify-content: center; - width: 100%; - height: 100%; + width: 100%; + height: 100%; - overflow: hidden; - overflow-x: scroll; + overflow: hidden; + overflow-x: scroll; - transition: all 150ms ease-in-out; + transition: all 150ms ease-in-out; - &.visible { - position: absolute; - top: 0; - left: 0; + &.visible { + position: absolute; + top: 0; + left: 0; - width: 100%; - height: 100%; + width: 100%; + height: 100%; - z-index: 150; + z-index: 150; - display: flex; + display: flex; - padding: 0; + padding: 0; - align-items: center; - justify-content: center; + align-items: center; + justify-content: center; - border: 0; + border: 0; - .ant-upload-drag { - width: 100%; - height: 100%; + .ant-upload-drag { + width: 100%; + height: 100%; - opacity: 1; - } + opacity: 1; + } - .ant-upload-list { - opacity: 0; - } - } + .ant-upload-list { + opacity: 0; + } + } - .ant-upload-drag { - opacity: 0; - width: 0; - height: 0; + .ant-upload-drag { + opacity: 0; + width: 0; + height: 0; - background-color: transparent !important; - border: 0; - } + background-color: transparent !important; + border: 0; + } - .ant-upload-wrapper { - position: relative; + .ant-upload-wrapper { + position: relative; - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; - justify-content: center; + justify-content: center; - background-color: transparent; - backdrop-filter: blur(8px); + background-color: transparent; + backdrop-filter: blur(8px); - width: 100%; - height: 100%; + width: 100%; + height: 100%; - overflow: hidden; + overflow: hidden; - .ant-upload-list { - display: flex; - flex-direction: row; + .ant-upload-list { + display: flex; + flex-direction: row; - align-items: center; + align-items: center; - width: 100%; + width: 100%; - overflow-x: auto; - overflow-y: hidden; + overflow-x: auto; + overflow-y: hidden; - white-space: nowrap; + white-space: nowrap; - align-items: center; - justify-content: flex-start; + align-items: center; + justify-content: flex-start; - gap: 10px; + gap: 10px; - border-radius: @file_preview_borderRadius; + border-radius: @file_preview_borderRadius; - .ant-upload-list-item-container { - background-color: rgba(var(--bg_color_5), 1); + .ant-upload-list-item-container { + background-color: rgba(var(--bg_color_5), 1); - width: fit-content; - height: fit-content; + width: fit-content; + height: fit-content; - border-radius: @file_preview_borderRadius; + border-radius: @file_preview_borderRadius; - margin: 0; - } - } - } + margin: 0; + } + } + } - .ant-upload-list-item-actions { - display: flex; - align-items: center; - justify-content: center; + .ant-upload-list-item-actions { + display: flex; + align-items: center; + justify-content: center; - height: 100%; - width: 100%; + height: 100%; + width: 100%; - font-size: 2rem; + font-size: 2rem; - border-radius: @file_preview_borderRadius; - } + border-radius: @file_preview_borderRadius; + } - .hint { - display: flex; - flex-direction: column; + .hint { + display: flex; + flex-direction: column; - align-items: center; - justify-content: center; - align-self: center; + align-items: center; + justify-content: center; + align-self: center; - text-align: center; + text-align: center; - background-color: transparent; + background-color: transparent; - font-size: 0.8rem; + font-size: 0.8rem; - padding: 10px; + padding: 10px; - svg { - font-size: 1.5rem; - margin-bottom: 6px; - margin-right: 0 !important; - } - } + svg { + font-size: 1.5rem; + margin-bottom: 6px; + margin-right: 0 !important; + } + } - .file { - position: relative; + .file { + position: relative; - width: 10vw; - height: 10vw; + width: 10vw; + height: 10vw; - max-width: 150px; - max-height: 150px; + max-width: 150px; + max-height: 150px; - font-size: 2rem; + font-size: 2rem; - transition: all 150ms ease-in-out; + transition: all 150ms ease-in-out; - border-radius: 10px; + border-radius: 10px; - &.uploading { - img { - opacity: 0.5; - } + &.uploading { + img { + opacity: 0.5; + } - .actions { - backdrop-filter: blur(5px); - opacity: 1; - } - } + .actions { + backdrop-filter: blur(5px); + opacity: 1; + } + } - &:hover { - .actions { - opacity: 1; - backdrop-filter: blur(2px); - } + &:hover { + .actions { + opacity: 1; + backdrop-filter: blur(2px); + } - .preview { - opacity: 0.5; - } - } + .preview { + opacity: 0.5; + } + } - .preview { - position: absolute; + .preview { + position: absolute; - top: 0; - left: 0; + top: 0; + left: 0; - width: 100%; - height: 100%; + width: 100%; + height: 100%; - img { - width: 100%; - height: 100%; + img { + width: 100%; + height: 100%; - transition: all 150ms ease-in-out; - border-radius: @file_preview_borderRadius; + transition: all 150ms ease-in-out; + border-radius: @file_preview_borderRadius; - object-fit: contain; - } - } + object-fit: contain; + } + } + } + } + + .actions { + display: flex; + flex-direction: inline-flex; + + svg { + color: var(--text-color); + margin: 0; + } - .actions { - display: flex; - flex-direction: column; - - align-items: center; - justify-content: center; - - width: 100%; - height: 100%; - - transition: all 150ms ease-in-out; - - opacity: 0; - color: rgba(var(--bg_color_1), 1); - - border-radius: @file_preview_borderRadius; - - svg { - color: rgba(var(--bg_color_1), 1); - margin: 0; - } - } - } - } - - .textInput { - display: flex; - - width: 100%; - - transition: all 150ms ease-in-out; - - background-color: transparent; - - svg { - margin: 0 !important; - } - - .avatar { - width: fit-content; - height: 45px; - - display: flex; - - img { - width: 45px; - height: 45px; - border-radius: 12px; - } - } - - textarea { - color: var(--text-color); - } - - textarea::placeholder { - color: rgb(var(--bg_color_4)); - } - - .textArea { - border-radius: 8px !important; - transition: all 150ms ease-in-out !important; - - &.active { - background-color: var(--background-color-primary); - } - } - - .ant-btn-primary { - z-index: 10; - position: relative; - border-radius: 0 10px 10px 0; - height: 100%; - vertical-align: bottom; - border: none; - box-shadow: none; - - svg { - color: var(--text-color) !important; - } - } - - .ant-input { - background-color: transparent; - - z-index: 10; - position: relative; - border-color: transparent !important; - box-shadow: none; - border-radius: 3px 0 0; - height: 100%; - padding: 5px 10px; - transition: height 150ms linear; - width: 100%; - } - - .ant-btn-primary[disabled] { - background-color: var(--background-color-accent); - } - - .ant-input-affix-wrapper { - height: 100%; - - border: 0; - outline: 0; - box-shadow: none; - - background-color: transparent; - } - } -} \ No newline at end of file + .ant-select { + .ant-select-selector { + border: 0; + padding: 4px 10px !important; + background-color: rgba(var(--bg_color_1), 0.8); + + .ant-select-selection-item { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-color); + } + } + + .ant-select-arrow { + font-size: 0.6rem; + right: 7px; + } + } + } + + .textInput { + display: flex; + + width: 100%; + + transition: all 150ms ease-in-out; + + background-color: transparent; + + svg { + margin: 0 !important; + } + + .avatar { + width: fit-content; + height: 45px; + + display: flex; + + img { + width: 45px; + height: 45px; + border-radius: 12px; + } + } + + textarea { + color: var(--text-color); + } + + textarea::placeholder { + color: rgb(var(--bg_color_4)); + } + + .textArea { + border-radius: 8px !important; + transition: all 150ms ease-in-out !important; + + &.active { + background-color: var(--background-color-primary); + } + } + + .ant-btn-primary { + z-index: 10; + position: relative; + border-radius: 0 10px 10px 0; + height: 100%; + vertical-align: bottom; + border: none; + box-shadow: none; + + svg { + color: var(--text-color) !important; + } + } + + .ant-input { + background-color: transparent; + + z-index: 10; + position: relative; + border-color: transparent !important; + box-shadow: none; + border-radius: 3px 0 0; + height: 100%; + padding: 5px 10px; + transition: height 150ms linear; + width: 100%; + } + + .ant-btn-primary[disabled] { + background-color: var(--background-color-accent); + } + + .ant-input-affix-wrapper { + height: 100%; + + border: 0; + outline: 0; + box-shadow: none; + + background-color: transparent; + } + } +} diff --git a/packages/app/src/components/PostsList/index.jsx b/packages/app/src/components/PostsList/index.jsx index 4a651d36..4a1d6ed6 100755 --- a/packages/app/src/components/PostsList/index.jsx +++ b/packages/app/src/components/PostsList/index.jsx @@ -334,6 +334,8 @@ export class PostsListsComponent extends React.Component { "posts", ) }) + + app.cores.api.client().sockets.posts.emit("connect_realtime") } } @@ -362,6 +364,8 @@ export class PostsListsComponent extends React.Component { "posts", ) }) + + app.cores.api.client().sockets.posts.emit("disconnect_realtime") } } diff --git a/packages/server/db_models/post/index.js b/packages/server/db_models/post/index.js index a8b402d9..5fdc1844 100755 --- a/packages/server/db_models/post/index.js +++ b/packages/server/db_models/post/index.js @@ -1,37 +1,41 @@ export default { - name: "Post", - collection: "posts", - schema: { - user_id: { - type: String, - required: true - }, - created_at: { - type: Date, - required: true - }, - message: { - type: String - }, - attachments: { - type: Array, - default: [] - }, - flags: { - type: Array, - default: [] - }, - reply_to: { - type: String, - default: null - }, - updated_at: { - type: String, - default: null - }, - poll_options: { - type: Array, - default: null - } - } -} \ No newline at end of file + name: "Post", + collection: "posts", + schema: { + user_id: { + type: String, + required: true, + }, + created_at: { + type: Date, + required: true, + }, + message: { + type: String, + }, + attachments: { + type: Array, + default: [], + }, + flags: { + type: Array, + default: [], + }, + reply_to: { + type: String, + default: null, + }, + updated_at: { + type: String, + default: null, + }, + poll_options: { + type: Array, + default: null, + }, + visibility: { + type: String, + default: "public", + }, + }, +} diff --git a/packages/server/services/posts/classes/posts/index.js b/packages/server/services/posts/classes/posts/index.js index 1acfa5a9..7ac9aff5 100644 --- a/packages/server/services/posts/classes/posts/index.js +++ b/packages/server/services/posts/classes/posts/index.js @@ -1,19 +1,19 @@ export default class Posts { - static timeline = require("./methods/timeline").default - static globalTimeline = require("./methods/globalTimeline").default - static data = require("./methods/data").default - static getLiked = require("./methods/getLiked").default - static getSaved = require("./methods/getSaved").default - static fromUserId = require("./methods/fromUserId").default - static create = require("./methods/create").default - static fullfillPost = require("./methods/fullfill").default - static toggleSave = require("./methods/toggleSave").default - static toggleLike = require("./methods/toggleLike").default - static report = require("./methods/report").default - static flag = require("./methods/flag").default - static delete = require("./methods/delete").default - static update = require("./methods/update").default - static replies = require("./methods/replies").default - static votePoll = require("./methods/votePoll").default - static deleteVotePoll = require("./methods/deletePollVote").default -} \ No newline at end of file + static timeline = require("./methods/timeline").default + static globalTimeline = require("./methods/globalTimeline").default + static data = require("./methods/data").default + static getLiked = require("./methods/getLiked").default + static getSaved = require("./methods/getSaved").default + static fromUserId = require("./methods/fromUserId").default + static create = require("./methods/create").default + static stage = require("./methods/stage").default + static toggleSave = require("./methods/toggleSave").default + static toggleLike = require("./methods/toggleLike").default + static report = require("./methods/report").default + static flag = require("./methods/flag").default + static delete = require("./methods/delete").default + static update = require("./methods/update").default + static replies = require("./methods/replies").default + static votePoll = require("./methods/votePoll").default + static deleteVotePoll = require("./methods/deletePollVote").default +} diff --git a/packages/server/services/posts/classes/posts/methods/create.js b/packages/server/services/posts/classes/posts/methods/create.js index 6b6a5214..7083634f 100644 --- a/packages/server/services/posts/classes/posts/methods/create.js +++ b/packages/server/services/posts/classes/posts/methods/create.js @@ -2,73 +2,113 @@ import requiredFields from "@shared-utils/requiredFields" import { DateTime } from "luxon" import { Post } from "@db_models" -import fullfill from "./fullfill" +import stage from "./stage" -export default async (payload = {}) => { - await requiredFields(["user_id"], payload) +const visibilityOptions = ["public", "private", "only_mutuals"] - let { user_id, message, attachments, timestamp, reply_to, poll_options } = payload +export default async (payload = {}, req) => { + await requiredFields(["user_id"], payload) - // check if is a Array and have at least one element - const isAttachmentArray = Array.isArray(attachments) && attachments.length > 0 + let { + user_id, + message, + attachments, + timestamp, + reply_to, + poll_options, + visibility = "public", + } = payload - if (!isAttachmentArray && !message) { - throw new OperationError(400, "Cannot create a post without message or attachments") - } + // check if visibility is valid + if (!visibilityOptions.includes(visibility)) { + throw new OperationError(400, "Invalid visibility option") + } - if (isAttachmentArray) { - // clean empty attachments - attachments = attachments.filter((attachment) => attachment) + // check if is a Array and have at least one element + const isAttachmentArray = + Array.isArray(attachments) && attachments.length > 0 - // fix attachments with url strings if needed - attachments = attachments.map((attachment) => { - if (typeof attachment === "string") { - attachment = { - url: attachment, - } - } + if (!isAttachmentArray && !message) { + throw new OperationError( + 400, + "Cannot create a post without message or attachments", + ) + } - return attachment - }) - } + if (isAttachmentArray) { + // clean empty attachments + attachments = attachments.filter((attachment) => attachment) - if (!timestamp) { - timestamp = DateTime.local().toISO() - } else { - timestamp = DateTime.fromISO(timestamp).toISO() - } + // fix attachments with url strings if needed + attachments = attachments.map((attachment) => { + if (typeof attachment === "string") { + attachment = { + url: attachment, + } + } - if (Array.isArray(poll_options)) { - poll_options = poll_options.map((option) => { - if (!option.id) { - option.id = nanoid() - } + return attachment + }) + } - return option - }) - } + if (!timestamp) { + timestamp = DateTime.local().toISO() + } else { + timestamp = DateTime.fromISO(timestamp).toISO() + } - let post = new Post({ - created_at: timestamp, - user_id: typeof user_id === "object" ? user_id.toString() : user_id, - message: message, - attachments: attachments ?? [], - reply_to: reply_to, - flags: [], - poll_options: poll_options, - }) + if (Array.isArray(poll_options)) { + poll_options = poll_options.map((option) => { + if (!option.id) { + option.id = nanoid() + } - await post.save() + return option + }) + } - post = post.toObject() + let post = new Post({ + created_at: timestamp, + user_id: typeof user_id === "object" ? user_id.toString() : user_id, + message: message, + attachments: attachments ?? [], + reply_to: reply_to, + flags: [], + poll_options: poll_options, + visibility: visibility.toLocaleLowerCase(), + }) - const result = await fullfill({ - posts: post, - for_user_id: user_id - }) + await post.save() - // TODO: create background jobs (nsfw dectection) - global.websocket.io.of("/").emit(`post.new`, result[0]) + post = post.toObject() - return post -} \ No newline at end of file + const result = await stage({ + posts: post, + for_user_id: user_id, + }) + + // broadcast post to all users + if (visibility === "public") { + global.websocket.io + .to("global:posts:realtime") + .emit(`post.new`, result[0]) + } + + if (visibility === "private") { + const userSocket = await global.websocket.find.socketByUserId( + post.user_id, + ) + + if (userSocket) { + userSocket.emit(`post.new`, result[0]) + } + } + + // TODO: create background jobs (nsfw dectection) + global.queues.createJob("classify_post_attachments", { + post_id: post._id.toString(), + auth_token: req.headers.authorization, + }) + + return post +} diff --git a/packages/server/services/posts/classes/posts/methods/data.js b/packages/server/services/posts/classes/posts/methods/data.js index c2daee3a..dbbc158b 100644 --- a/packages/server/services/posts/classes/posts/methods/data.js +++ b/packages/server/services/posts/classes/posts/methods/data.js @@ -1,54 +1,54 @@ import { Post } from "@db_models" -import fullfillPostsData from "./fullfill" +import stage from "./stage" const maxLimit = 300 export default async (payload = {}) => { - let { - for_user_id, - post_id, - query = {}, - trim = 0, - limit = 20, - sort = { created_at: -1 }, - } = payload + let { + for_user_id, + post_id, + query = {}, + trim = 0, + limit = 20, + sort = { created_at: -1 }, + } = payload - // set a hard limit on the number of posts to retrieve, used for pagination - if (limit > maxLimit) { - limit = maxLimit - } + // set a hard limit on the number of posts to retrieve, used for pagination + if (limit > maxLimit) { + limit = maxLimit + } - let posts = [] + let posts = [] - if (post_id) { - try { - const post = await Post.findById(post_id) + if (post_id) { + try { + const post = await Post.findById(post_id) - posts = [post] - } catch (error) { - throw new OperationError(404, "Post not found") - } - } else { - posts = await Post.find({ ...query }) - .sort(sort) - .skip(trim) - .limit(limit) - } + posts = [post] + } catch (error) { + throw new OperationError(404, "Post not found") + } + } else { + posts = await Post.find({ ...query }) + .sort(sort) + .skip(trim) + .limit(limit) + } - // fullfill data - posts = await fullfillPostsData({ - posts, - for_user_id, - }) + // fullfill data + posts = await stage({ + posts, + for_user_id, + }) - // if post_id is specified, return only one post - if (post_id) { - if (posts.length === 0) { - throw new OperationError(404, "Post not found") - } + // if post_id is specified, return only one post + if (post_id) { + if (posts.length === 0) { + throw new OperationError(404, "Post not found") + } - return posts[0] - } + return posts[0] + } - return posts -} \ No newline at end of file + return posts +} diff --git a/packages/server/services/posts/classes/posts/methods/delete.js b/packages/server/services/posts/classes/posts/methods/delete.js index b4716971..c64a9920 100644 --- a/packages/server/services/posts/classes/posts/methods/delete.js +++ b/packages/server/services/posts/classes/posts/methods/delete.js @@ -1,45 +1,65 @@ import { Post, PostLike, PostSave } from "@db_models" export default async (payload = {}) => { - let { - post_id - } = payload + let { post_id } = payload - if (!post_id) { - throw new OperationError(400, "Missing post_id") - } + if (!post_id) { + throw new OperationError(400, "Missing post_id") + } - await Post.deleteOne({ - _id: post_id, - }).catch((err) => { - throw new OperationError(500, `An error has occurred: ${err.message}`) - }) + const post = await Post.findById(post_id) - // search for likes - await PostLike.deleteMany({ - post_id: post_id, - }).catch((err) => { - throw new OperationError(500, `An error has occurred: ${err.message}`) - }) + if (!post) { + throw new OperationError(404, "Post not found") + } - // deleted from saved - await PostSave.deleteMany({ - post_id: post_id, - }).catch((err) => { - throw new OperationError(500, `An error has occurred: ${err.message}`) - }) + await Post.deleteOne({ + _id: post_id, + }).catch((err) => { + throw new OperationError(500, `An error has occurred: ${err.message}`) + }) - // delete replies - await Post.deleteMany({ - reply_to: post_id, - }).catch((err) => { - throw new OperationError(500, `An error has occurred: ${err.message}`) - }) + // search for likes + await PostLike.deleteMany({ + post_id: post_id, + }).catch((err) => { + throw new OperationError(500, `An error has occurred: ${err.message}`) + }) - global.websocket.io.of("/").emit(`post.delete`, post_id) - global.websocket.io.of("/").emit(`post.delete.${post_id}`, post_id) + // deleted from saved + await PostSave.deleteMany({ + post_id: post_id, + }).catch((err) => { + throw new OperationError(500, `An error has occurred: ${err.message}`) + }) - return { - deleted: true, - } -} \ No newline at end of file + // delete replies + await Post.deleteMany({ + reply_to: post_id, + }).catch((err) => { + throw new OperationError(500, `An error has occurred: ${err.message}`) + }) + + if (post.visibility === "public") { + global.websocket.io + .to("global:posts:realtime") + .emit(`post.delete`, post) + global.websocket.io + .to("global:posts:realtime") + .emit(`post.delete.${post_id}`, post) + } + + if (post.visibility === "private") { + const userSocket = await global.websocket.find.socketByUserId( + post.user_id, + ) + if (userSocket) { + userSocket.emit(`post.delete`, post_id) + userSocket.emit(`post.delete.${post_id}`, post_id) + } + } + + return { + deleted: true, + } +} diff --git a/packages/server/services/posts/classes/posts/methods/fullfill.js b/packages/server/services/posts/classes/posts/methods/fullfill.js deleted file mode 100644 index 92f37000..00000000 --- a/packages/server/services/posts/classes/posts/methods/fullfill.js +++ /dev/null @@ -1,140 +0,0 @@ -import { User, PostLike, PostSave, Post, VotePoll } from "@db_models" - -export default async (payload = {}) => { - let { - posts, - for_user_id, - } = payload - - if (!Array.isArray(posts)) { - posts = [posts] - } - - if (posts.every((post) => !post)) { - return [] - } - - let postsSavesIds = [] - - if (for_user_id) { - const postsSaves = await PostSave.find({ user_id: for_user_id }) - .sort({ saved_at: -1 }) - - postsSavesIds = postsSaves.map((postSave) => postSave.post_id) - } - - const postsIds = posts.map((post) => post._id) - const usersIds = posts.map((post) => post.user_id) - - let [usersData, likesData, pollVotes] = await Promise.all([ - User.find({ - _id: { - $in: usersIds - } - }).catch(() => { }), - PostLike.find({ - post_id: { - $in: postsIds - } - }).catch(() => []), - VotePoll.find({ - post_id: { - $in: postsIds - } - }).catch(() => []) - ]) - - // wrap likesData by post_id - likesData = likesData.reduce((acc, like) => { - if (!acc[like.post_id]) { - acc[like.post_id] = [] - } - - acc[like.post_id].push(like) - - return acc - }, {}) - - posts = await Promise.all(posts.map(async (post, index) => { - if (typeof post.toObject === "function") { - post = post.toObject() - } - - let user = usersData.find((user) => user._id.toString() === post.user_id.toString()) - - if (!user) { - user = { - _deleted: true, - username: "Deleted user", - } - } - - if (post.reply_to) { - post.reply_to_data = await Post.findById(post.reply_to) - - if (post.reply_to_data) { - post.reply_to_data = post.reply_to_data.toObject() - - const replyUserData = await User.findById(post.reply_to_data.user_id) - - if (replyUserData) { - post.reply_to_data.user = replyUserData.toObject() - } - } - } - - post.hasReplies = await Post.countDocuments({ reply_to: post._id }) - - let likes = likesData[post._id.toString()] ?? [] - - post.countLikes = likes.length - - const postPollVotes = pollVotes.filter((vote) => { - if (vote.post_id !== post._id.toString()) { - return false - } - - return true - }) - - if (for_user_id) { - post.isLiked = likes.some((like) => like.user_id.toString() === for_user_id) - post.isSaved = postsSavesIds.includes(post._id.toString()) - - if (Array.isArray(post.poll_options)) { - post.poll_options = post.poll_options.map((option) => { - option.voted = !!postPollVotes.find((vote) => { - if (vote.user_id !== for_user_id) { - return false - } - - if (vote.option_id !== option.id) { - return false - } - - return true - }) - - option.count = postPollVotes.filter((vote) => { - if (vote.option_id !== option.id) { - return false - } - - return true - }).length - - return option - }) - } - } - - post.share_url = `${process.env.APP_URL}/post/${post._id}` - - return { - ...post, - user, - } - })) - - return posts -} \ No newline at end of file diff --git a/packages/server/services/posts/classes/posts/methods/replies.js b/packages/server/services/posts/classes/posts/methods/replies.js index 2cc8ec33..d245d2a7 100644 --- a/packages/server/services/posts/classes/posts/methods/replies.js +++ b/packages/server/services/posts/classes/posts/methods/replies.js @@ -1,29 +1,24 @@ import { Post } from "@db_models" -import fullfillPostsData from "./fullfill" +import stage from "./stage" export default async (payload = {}) => { - const { - post_id, - for_user_id, - trim = 0, - limit = 50, - } = payload + const { post_id, for_user_id, trim = 0, limit = 50 } = payload - if (!post_id) { - throw new OperationError(400, "Post ID is required") - } + if (!post_id) { + throw new OperationError(400, "Post ID is required") + } - let posts = await Post.find({ - reply_to: post_id, - }) - .limit(limit) - .skip(trim) - .sort({ created_at: -1 }) + let posts = await Post.find({ + reply_to: post_id, + }) + .limit(limit) + .skip(trim) + .sort({ created_at: -1 }) - posts = await fullfillPostsData({ - posts, - for_user_id, - }) + posts = await stage({ + posts, + for_user_id, + }) - return posts -} \ No newline at end of file + return posts +} diff --git a/packages/server/services/posts/classes/posts/methods/stage.js b/packages/server/services/posts/classes/posts/methods/stage.js new file mode 100644 index 00000000..75325b13 --- /dev/null +++ b/packages/server/services/posts/classes/posts/methods/stage.js @@ -0,0 +1,161 @@ +import { User, PostLike, PostSave, Post, VotePoll } from "@db_models" + +export default async (payload = {}) => { + let { posts, for_user_id } = payload + + if (!Array.isArray(posts)) { + posts = [posts] + } + + if (posts.every((post) => !post)) { + return [] + } + + let postsSavesIds = [] + + if (for_user_id) { + const postsSaves = await PostSave.find({ user_id: for_user_id }).sort({ + saved_at: -1, + }) + + postsSavesIds = postsSaves.map((postSave) => postSave.post_id) + } + + const postsIds = posts.map((post) => post._id) + const usersIds = posts.map((post) => post.user_id) + + let [usersData, likesData, pollVotes] = await Promise.all([ + User.find({ + _id: { + $in: usersIds, + }, + }).catch(() => {}), + PostLike.find({ + post_id: { + $in: postsIds, + }, + }).catch(() => []), + VotePoll.find({ + post_id: { + $in: postsIds, + }, + }).catch(() => []), + ]) + + // wrap likesData by post_id + likesData = likesData.reduce((acc, like) => { + if (!acc[like.post_id]) { + acc[like.post_id] = [] + } + + acc[like.post_id].push(like) + + return acc + }, {}) + + posts = await Promise.all( + posts.map(async (post, index) => { + if (typeof post.toObject === "function") { + post = post.toObject() + } + + if (post.visibility === "private" && post.user_id !== for_user_id) { + return null + } + + if ( + post.visibility === "only_mutuals" && + post.user_id !== for_user_id + ) { + // TODO + return null + } + + let user = usersData.find( + (user) => user._id.toString() === post.user_id.toString(), + ) + + if (!user) { + user = { + _deleted: true, + username: "Deleted user", + } + } + + if (post.reply_to) { + post.reply_to_data = await Post.findById(post.reply_to) + + if (post.reply_to_data) { + post.reply_to_data = post.reply_to_data.toObject() + + const replyUserData = await User.findById( + post.reply_to_data.user_id, + ) + + if (replyUserData) { + post.reply_to_data.user = replyUserData.toObject() + } + } + } + + post.hasReplies = await Post.countDocuments({ reply_to: post._id }) + + let likes = likesData[post._id.toString()] ?? [] + + post.countLikes = likes.length + + const postPollVotes = pollVotes.filter((vote) => { + if (vote.post_id !== post._id.toString()) { + return false + } + + return true + }) + + if (for_user_id) { + post.isLiked = likes.some( + (like) => like.user_id.toString() === for_user_id, + ) + post.isSaved = postsSavesIds.includes(post._id.toString()) + + if (Array.isArray(post.poll_options)) { + post.poll_options = post.poll_options.map((option) => { + option.voted = !!postPollVotes.find((vote) => { + if (vote.user_id !== for_user_id) { + return false + } + + if (vote.option_id !== option.id) { + return false + } + + return true + }) + + option.count = postPollVotes.filter((vote) => { + if (vote.option_id !== option.id) { + return false + } + + return true + }).length + + return option + }) + } + } + + post.share_url = `${process.env.APP_URL}/post/${post._id}` + + return { + ...post, + user, + } + }), + ) + + // clear undefined and null + posts = posts.filter((post) => post !== undefined && post !== null) + + return posts +} diff --git a/packages/server/services/posts/classes/posts/methods/update.js b/packages/server/services/posts/classes/posts/methods/update.js index f80b1fac..9db0334d 100644 --- a/packages/server/services/posts/classes/posts/methods/update.js +++ b/packages/server/services/posts/classes/posts/methods/update.js @@ -1,42 +1,60 @@ import { Post } from "@db_models" import { DateTime } from "luxon" -import fullfill from "./fullfill" +import stage from "./stage" export default async (post_id, update) => { - let post = await Post.findById(post_id) + let post = await Post.findById(post_id) - if (!post) { - throw new OperationError(404, "Post not found") - } + if (!post) { + throw new OperationError(404, "Post not found") + } - const updateKeys = Object.keys(update) + const updateKeys = Object.keys(update) - updateKeys.forEach((key) => { - post[key] = update[key] - }) + updateKeys.forEach((key) => { + post[key] = update[key] + }) - post.updated_at = DateTime.local().toISO() + post.updated_at = DateTime.local().toISO() - if (Array.isArray(update.poll_options)) { - post.poll_options = update.poll_options.map((option) => { - if (!option.id) { - option.id = nanoid() - } + if (Array.isArray(update.poll_options)) { + post.poll_options = update.poll_options.map((option) => { + if (!option.id) { + option.id = nanoid() + } - return option - }) - } + return option + }) + } - await post.save() + await post.save() - post = post.toObject() + post = post.toObject() - const result = await fullfill({ - posts: post, - }) + const result = await stage({ + posts: post, + for_user_id: post.user_id, + }) - global.websocket.io.of("/").emit(`post.update`, result[0]) - global.websocket.io.of("/").emit(`post.update.${post_id}`, result[0]) + if (post.visibility === "public") { + global.websocket.io + .to("global:posts:realtime") + .emit(`post.update`, result[0]) + global.websocket.io + .to("global:posts:realtime") + .emit(`post.update.${post_id}`, result[0]) + } - return result[0] -} \ No newline at end of file + if (post.visibility === "private") { + const userSocket = await global.websocket.find.socketByUserId( + post.user_id, + ) + + if (userSocket) { + userSocket.emit(`post.update`, result[0]) + userSocket.emit(`post.update.${post_id}`, result[0]) + } + } + + return result[0] +} diff --git a/packages/server/services/posts/posts.service.js b/packages/server/services/posts/posts.service.js index 9291e4e4..7ddda673 100644 --- a/packages/server/services/posts/posts.service.js +++ b/packages/server/services/posts/posts.service.js @@ -2,30 +2,72 @@ import { Server } from "linebridge" import DbManager from "@shared-classes/DbManager" import RedisClient from "@shared-classes/RedisClient" +import TaskQueueManager from "@shared-classes/TaskQueueManager" import SharedMiddlewares from "@shared-middlewares" -export default class API extends Server { - static refName = "posts" - static enableWebsockets = true - static routesPath = `${__dirname}/routes` - static listen_port = process.env.HTTP_LISTEN_PORT ?? 3001 +// wsfast +import HyperExpress from "hyper-express" - middlewares = { - ...SharedMiddlewares - } +class WSFastServer { + router = new HyperExpress.Router() - contexts = { - db: new DbManager(), - redis: RedisClient(), - } + clients = new Set() - async onInitialize() { - await this.contexts.db.initialize() - await this.contexts.redis.initialize() - } + routes = { + connect: async (socket) => { + console.log("Client connected", socket) + }, + } - handleWsAuth = require("@shared-lib/handleWsAuth").default + async initialize(engine) { + this.engine = engine + + Object.keys(this.routes).forEach((route) => { + this.router.ws(`/${route}`, this.routes[route]) + }) + + this.engine.app.use(this.router) + } } -Boot(API) \ No newline at end of file +export default class API extends Server { + static refName = "posts" + static enableWebsockets = true + static routesPath = `${__dirname}/routes` + static wsRoutesPath = `${__dirname}/routes_ws` + + static listen_port = process.env.HTTP_LISTEN_PORT ?? 3001 + + middlewares = { + ...SharedMiddlewares, + } + + contexts = { + db: new DbManager(), + redis: RedisClient(), + ws: new WSFastServer(this.engine), + } + + queuesManager = new TaskQueueManager( + { + workersPath: `${__dirname}/queues`, + }, + this, + ) + + async onInitialize() { + await this.contexts.db.initialize() + await this.contexts.redis.initialize() + await this.queuesManager.initialize({ + redisOptions: this.engine.ws.redis.options, + }) + await this.contexts.ws.initialize(this.engine) + + global.queues = this.queuesManager + } + + handleWsAuth = require("@shared-lib/handleWsAuth").default +} + +Boot(API) diff --git a/packages/server/services/posts/queues/classify_post_attachments/index.js b/packages/server/services/posts/queues/classify_post_attachments/index.js new file mode 100644 index 00000000..c5609f0c --- /dev/null +++ b/packages/server/services/posts/queues/classify_post_attachments/index.js @@ -0,0 +1,67 @@ +import { Post } from "@db_models" +import axios from "axios" + +const classifyAPI = "https://vision-service.ragestudio.net" + +const adultLevels = [ + "VERY_UNLIKELY", + "UNLIKELY", + "POSSIBLE", + "LIKELY", + "VERY_LIKELY", +] + +export default { + id: "classify_post_attachments", + maxJobs: 100, + process: async (job) => { + const { post_id, auth_token } = job.data + + let post = await Post.findById(post_id).lean() + + console.log(`[CLASSIFY] Checking post ${post_id}`) + + if (!post) { + return false + } + + if (!Array.isArray(post.attachments)) { + return false + } + + for await (const attachment of post.attachments) { + if (!attachment.url) { + continue + } + + const response = await axios({ + method: "GET", + url: `${classifyAPI}/safe_detect`, + headers: { + Authorization: auth_token, + }, + params: { + url: attachment.url, + }, + }) + + console.log( + `[CLASSIFY] Attachment [${attachment.url}] classified as ${response.data.detections.adult}`, + ) + + const adultLevel = adultLevels.indexOf( + response.data.detections.adult, + ) + + if (!Array.isArray(attachment.flags)) { + attachment.flags = [] + } + + if (adultLevel > 2) { + attachment.flags.push("nsfw") + } + } + + await Post.findByIdAndUpdate(post._id, post) + }, +} diff --git a/packages/server/services/posts/routes/posts/[post_id]/update/put.js b/packages/server/services/posts/routes/posts/[post_id]/update/put.js index d48c340d..1cdd9439 100644 --- a/packages/server/services/posts/routes/posts/[post_id]/update/put.js +++ b/packages/server/services/posts/routes/posts/[post_id]/update/put.js @@ -1,44 +1,56 @@ import PostClass from "@classes/posts" import { Post } from "@db_models" -const AllowedFields = ["message", "tags", "attachments", "poll_options"] +const AllowedFields = [ + "message", + "tags", + "attachments", + "poll_options", + "visibility", +] // TODO: Get limits from LimitsAPI const MaxStringsLengths = { - message: 2000 + message: 2000, } export default { - middlewares: ["withAuthentication"], - fn: async (req) => { - let update = {} + middlewares: ["withAuthentication"], + fn: async (req) => { + let update = {} - const post = await Post.findById(req.params.post_id) + const post = await Post.findById(req.params.post_id) - if (!post) { - throw new OperationError(404, "Post not found") - } + if (!post) { + throw new OperationError(404, "Post not found") + } - if (post.user_id !== req.auth.session.user_id) { - throw new OperationError(403, "You cannot edit this post") - } + if (post.user_id !== req.auth.session.user_id) { + throw new OperationError(403, "You cannot edit this post") + } - AllowedFields.forEach((key) => { - if (typeof req.body[key] !== "undefined") { - // check maximung strings length - if (typeof req.body[key] === "string" && MaxStringsLengths[key]) { - if (req.body[key].length > MaxStringsLengths[key]) { - // create a substring - update[key] = req.body[key].substring(0, MaxStringsLengths[key]) - } else { - update[key] = req.body[key] - } - } else { - update[key] = req.body[key] - } - } - }) + AllowedFields.forEach((key) => { + if (typeof req.body[key] !== "undefined") { + // check maximung strings length + if ( + typeof req.body[key] === "string" && + MaxStringsLengths[key] + ) { + if (req.body[key].length > MaxStringsLengths[key]) { + // create a substring + update[key] = req.body[key].substring( + 0, + MaxStringsLengths[key], + ) + } else { + update[key] = req.body[key] + } + } else { + update[key] = req.body[key] + } + } + }) - return await PostClass.update(req.params.post_id, update) - } -} \ No newline at end of file + return await PostClass.update(req.params.post_id, update) + }, +} diff --git a/packages/server/services/posts/routes/posts/new/post.js b/packages/server/services/posts/routes/posts/new/post.js index e6f857c2..24739265 100644 --- a/packages/server/services/posts/routes/posts/new/post.js +++ b/packages/server/services/posts/routes/posts/new/post.js @@ -1,13 +1,16 @@ import Posts from "@classes/posts" export default { - middlewares: ["withAuthentication"], - fn: async (req, res) => { - const result = await Posts.create({ - ...req.body, - user_id: req.auth.session.user_id, - }) + middlewares: ["withAuthentication"], + fn: async (req, res) => { + const result = await Posts.create( + { + ...req.body, + user_id: req.auth.session.user_id, + }, + req, + ) - return result - } -} \ No newline at end of file + return result + }, +} diff --git a/packages/server/services/posts/routes/posts/trending/[trending]/get.js b/packages/server/services/posts/routes/posts/trending/[trending]/get.js index 1a022c49..753b9302 100644 --- a/packages/server/services/posts/routes/posts/trending/[trending]/get.js +++ b/packages/server/services/posts/routes/posts/trending/[trending]/get.js @@ -1,25 +1,25 @@ import { Post } from "@db_models" -import fullfill from "@classes/posts/methods/fullfill" +import stage from "@classes/posts/methods/stage" export default { - middlewares: ["withOptionalAuthentication"], - fn: async (req) => { - const { limit, trim } = req.query + middlewares: ["withOptionalAuthentication"], + fn: async (req) => { + const { limit, trim } = req.query - let result = await Post.find({ - message: { - $regex: new RegExp(`#${req.params.trending}`, "gi") - } - }) - .sort({ created_at: -1 }) - .skip(trim ?? 0) - .limit(limit ?? 20) + let result = await Post.find({ + message: { + $regex: new RegExp(`#${req.params.trending}`, "gi"), + }, + }) + .sort({ created_at: -1 }) + .skip(trim ?? 0) + .limit(limit ?? 20) - result = await fullfill({ - posts: result, - for_user_id: req.auth.session.user_id, - }) + result = await stage({ + posts: result, + for_user_id: req.auth.session.user_id, + }) - return result - } -} \ No newline at end of file + return result + }, +} diff --git a/packages/server/services/posts/routes_ws/connect_realtime.js b/packages/server/services/posts/routes_ws/connect_realtime.js new file mode 100644 index 00000000..f06abf0a --- /dev/null +++ b/packages/server/services/posts/routes_ws/connect_realtime.js @@ -0,0 +1,4 @@ +export default async function (socket) { + console.log(`Socket ${socket.id} connected to realtime posts`) + socket.join("global:posts:realtime") +} diff --git a/packages/server/services/posts/routes_ws/disconnect_realtime.js b/packages/server/services/posts/routes_ws/disconnect_realtime.js new file mode 100644 index 00000000..90372242 --- /dev/null +++ b/packages/server/services/posts/routes_ws/disconnect_realtime.js @@ -0,0 +1,4 @@ +export default async function (socket) { + console.log(`Socket ${socket.id} disconnected from realtime posts`) + socket.leave("global:posts:realtime") +}