Compare commits
13 Commits
7dac76558d
...
3927cc6d67
Author | SHA1 | Date | |
---|---|---|---|
3927cc6d67 | |||
92513715bd | |||
b5b86d599a | |||
8a69a1b21d | |||
0f47a25529 | |||
716ca31a60 | |||
e3fd2c8f27 | |||
d56c201ec7 | |||
65e059655f | |||
dd7af64508 | |||
06bd0fb8ac | |||
8c1145e54f | |||
5692258cc1 |
@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
import re
|
||||
import os
|
||||
import math
|
||||
import pymongo
|
||||
from bson.objectid import ObjectId
|
||||
from util import database, util, mail, push
|
||||
@ -42,6 +43,9 @@ def create(user, data):
|
||||
"postNoticeboard",
|
||||
"viewProjects",
|
||||
"postProjects",
|
||||
"viewForumTopics",
|
||||
"postForumTopics",
|
||||
"postForumTopicReplies",
|
||||
],
|
||||
}
|
||||
result = db.groups.insert_one(group)
|
||||
@ -486,3 +490,257 @@ def get_projects(user, id):
|
||||
project["fullName"] = a["username"] + "/" + project["path"]
|
||||
break
|
||||
return {"projects": projects}
|
||||
|
||||
|
||||
def create_forum_topic(user, id, data):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if not has_group_permission(user, group, "postForumTopics"):
|
||||
raise util.errors.Forbidden("You don't have permission to create a topic")
|
||||
topic = {
|
||||
"createdAt": datetime.datetime.now(),
|
||||
"group": id,
|
||||
"user": user["_id"],
|
||||
"title": data["title"],
|
||||
"description": data.get("description", ""),
|
||||
}
|
||||
result = db.groupForumTopics.insert_one(topic)
|
||||
topic["_id"] = result.inserted_id
|
||||
return topic
|
||||
|
||||
|
||||
def update_forum_topic(user, id, topic_id, data):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
topic_id = ObjectId(topic_id)
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
||||
if not topic or topic.get("group") != id:
|
||||
raise util.errors.NotFound("Topic not found")
|
||||
if not (user["_id"] in group.get("admins", []) or user["_id"] == topic.get("user")):
|
||||
raise util.errors.Forbidden("You don't have permission to edit the topic")
|
||||
allowed_keys = ["title", "description"]
|
||||
updater = util.build_updater(data, allowed_keys)
|
||||
if updater:
|
||||
db.groupForumTopics.update_one({"_id": topic_id}, updater)
|
||||
return db.groupForumTopics.find_one({"_id": topic_id})
|
||||
|
||||
|
||||
def delete_forum_topic(user, id, topic_id):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
topic_id = ObjectId(topic_id)
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
||||
if not topic or topic.get("group") != id:
|
||||
raise util.errors.NotFound("Topic not found")
|
||||
if not (user["_id"] in group.get("admins", []) or user["_id"] == topic.get("user")):
|
||||
raise util.errors.Forbidden("You don't have permission to delete the topic")
|
||||
db.groupForumTopics.delete_one({"_id": topic_id})
|
||||
db.groupForumTopicReplies.delete_many({"topic": topic_id})
|
||||
return {"deletedTopic": topic_id}
|
||||
|
||||
|
||||
def get_forum_topics(user, id):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
if not has_group_permission(user, group, "viewForumTopics"):
|
||||
raise util.errors.Forbidden(
|
||||
"You don't have permission to view the forum topics"
|
||||
)
|
||||
return {
|
||||
"topics": list(
|
||||
db.groupForumTopics.find({"group": id}).sort(
|
||||
"createdAt", pymongo.DESCENDING
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def create_forum_topic_reply(user, id, topic_id, data):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
topic_id = ObjectId(topic_id)
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
||||
if not topic or topic.get("group") != id:
|
||||
raise util.errors.NotFound("Topic not found")
|
||||
if not has_group_permission(user, group, "postForumTopicReplies"):
|
||||
raise util.errors.Forbidden("You don't have permission to create a reply")
|
||||
reply = {
|
||||
"createdAt": datetime.datetime.now(),
|
||||
"group": id,
|
||||
"topic": topic_id,
|
||||
"user": user["_id"],
|
||||
"content": data["content"],
|
||||
"attachments": data.get("attachments", []),
|
||||
}
|
||||
result = db.groupForumTopicReplies.insert_one(reply)
|
||||
db.groupForumTopics.update_one(
|
||||
{"_id": topic_id},
|
||||
{
|
||||
"$set": {
|
||||
"lastReplyAt": reply["createdAt"],
|
||||
"totalReplies": db.groupForumTopicReplies.count_documents(
|
||||
{"topic": topic_id}
|
||||
),
|
||||
"lastReply": result.inserted_id,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
reply["_id"] = result.inserted_id
|
||||
reply["author"] = {
|
||||
"_id": user["_id"],
|
||||
"username": user["username"],
|
||||
"avatar": user.get("avatar"),
|
||||
}
|
||||
if "avatar" in user:
|
||||
reply["author"]["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
||||
)
|
||||
for attachment in reply["attachments"]:
|
||||
if re.search(
|
||||
r"(.jpg)|(.png)|(.jpeg)|(.gif)$", attachment["storedName"].lower()
|
||||
):
|
||||
attachment["isImage"] = True
|
||||
if attachment["type"] == "file":
|
||||
attachment["url"] = uploads.get_presigned_url(
|
||||
"groups/{0}/topics/{1}/{2}".format(
|
||||
id, topic_id, attachment["storedName"]
|
||||
)
|
||||
)
|
||||
|
||||
for u in db.users.find(
|
||||
{
|
||||
"_id": {"$ne": user["_id"]},
|
||||
"groups": id,
|
||||
"subscriptions.email": "groupForumTopic-" + str(topic_id),
|
||||
},
|
||||
{"email": 1, "username": 1},
|
||||
):
|
||||
mail.send(
|
||||
{
|
||||
"to_user": u,
|
||||
"subject": "A new reply was posted to " + topic["title"],
|
||||
"text": "Dear {0},\n\n{1} posted a new reply in {2} (in the group {3}) on {6}:\n\n{4}\n\nFollow the link below to visit the group:\n\n{5}".format(
|
||||
u["username"],
|
||||
user["username"],
|
||||
topic["title"],
|
||||
group["name"],
|
||||
data["content"],
|
||||
"{}/groups/{}/forum/topics/{}".format(
|
||||
APP_URL, str(id), str(topic_id)
|
||||
),
|
||||
APP_NAME,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return reply
|
||||
|
||||
|
||||
def get_forum_topic_replies(user, id, topic_id, data):
|
||||
REPLIES_PER_PAGE = 20
|
||||
page = int(data.get("page", 1))
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
topic_id = ObjectId(topic_id)
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
||||
if not topic or topic.get("group") != id:
|
||||
raise util.errors.NotFound("Topic not found")
|
||||
if not has_group_permission(user, group, "viewForumTopics"):
|
||||
raise util.errors.Forbidden(
|
||||
"You don't have permission to view the forum topics"
|
||||
)
|
||||
total_replies = db.groupForumTopicReplies.count_documents({"topic": topic_id})
|
||||
replies = list(
|
||||
db.groupForumTopicReplies.find({"topic": topic_id})
|
||||
.sort("createdAt", pymongo.ASCENDING)
|
||||
.skip((page - 1) * REPLIES_PER_PAGE)
|
||||
.limit(REPLIES_PER_PAGE)
|
||||
)
|
||||
authors = list(
|
||||
db.users.find(
|
||||
{"_id": {"$in": [r["user"] for r in replies]}}, {"username": 1, "avatar": 1}
|
||||
)
|
||||
)
|
||||
for reply in replies:
|
||||
author = next((a for a in authors if a["_id"] == reply["user"]), None)
|
||||
if author:
|
||||
reply["author"] = author
|
||||
if "avatar" in author:
|
||||
reply["author"]["avatarUrl"] = uploads.get_presigned_url(
|
||||
"users/{0}/{1}".format(author["_id"], author["avatar"])
|
||||
)
|
||||
if "attachments" in reply:
|
||||
for attachment in reply["attachments"]:
|
||||
if attachment["type"] == "file":
|
||||
attachment["isImage"] = False
|
||||
if re.search(
|
||||
r"(.jpg)|(.png)|(.jpeg)|(.gif)$",
|
||||
attachment["storedName"].lower(),
|
||||
):
|
||||
attachment["isImage"] = True
|
||||
attachment["url"] = uploads.get_presigned_url(
|
||||
"groups/{0}/topics/{1}/{2}".format(
|
||||
id, topic_id, attachment["storedName"]
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"topic": topic,
|
||||
"replies": replies,
|
||||
"totalReplies": total_replies,
|
||||
"page": page,
|
||||
"totalPages": math.ceil(total_replies / REPLIES_PER_PAGE),
|
||||
}
|
||||
|
||||
|
||||
def delete_forum_topic_reply(user, id, topic_id, reply_id):
|
||||
db = database.get_db()
|
||||
id = ObjectId(id)
|
||||
topic_id = ObjectId(topic_id)
|
||||
reply_id = ObjectId(reply_id)
|
||||
group = db.groups.find_one({"_id": id})
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
||||
if not topic or topic.get("group") != id:
|
||||
raise util.errors.NotFound("Topic not found")
|
||||
reply = db.groupForumTopicReplies.find_one({"_id": reply_id})
|
||||
if not reply or reply.get("topic") != topic_id:
|
||||
raise util.errors.NotFound("Reply not found")
|
||||
if not (user["_id"] in group.get("admins", []) or user["_id"] == reply.get("user")):
|
||||
raise util.errors.Forbidden("You don't have permission to delete the reply")
|
||||
db.groupForumTopicReplies.delete_one({"_id": reply_id})
|
||||
last_reply = db.groupForumTopicReplies.find_one(
|
||||
{"topic": topic_id}, sort=[("createdAt", pymongo.DESCENDING)]
|
||||
)
|
||||
db.groupForumTopics.update_one(
|
||||
{"_id": topic_id},
|
||||
{
|
||||
"$set": {
|
||||
"totalReplies": db.groupForumTopicReplies.count_documents(
|
||||
{"topic": topic_id}
|
||||
),
|
||||
"lastReply": last_reply["_id"] if last_reply else None,
|
||||
}
|
||||
},
|
||||
)
|
||||
return {"deletedReply": reply_id}
|
||||
|
@ -6,6 +6,7 @@ from bson.objectid import ObjectId
|
||||
import boto3
|
||||
import blurhash
|
||||
from util import database, util
|
||||
from api.groups import has_group_permission
|
||||
|
||||
|
||||
def sanitise_filename(s):
|
||||
@ -66,6 +67,15 @@ def generate_file_upload_request(
|
||||
if for_type == "group":
|
||||
allowed = ObjectId(for_id) in user.get("groups", [])
|
||||
path = "groups/" + for_id + "/"
|
||||
if for_type == "groupForum":
|
||||
topic = db.groupForumTopics.find_one(ObjectId(for_id))
|
||||
if not topic:
|
||||
raise util.errors.NotFound("Topic not found")
|
||||
group = db.groups.find_one(topic["group"])
|
||||
if not group:
|
||||
raise util.errors.NotFound("Group not found")
|
||||
allowed = has_group_permission(user, group, "postForumTopicReplies")
|
||||
path = "groups/" + str(group["_id"]) + "/topics/" + for_id + "/"
|
||||
if not allowed:
|
||||
raise util.errors.Forbidden("You're not allowed to upload this file")
|
||||
|
||||
|
75
api/app.py
75
api/app.py
@ -599,6 +599,81 @@ def group_requests_route(id):
|
||||
)
|
||||
|
||||
|
||||
@app.route("/groups/<id>/topics", methods=["GET"])
|
||||
def group_topics_route(id):
|
||||
return util.jsonify(groups.get_forum_topics(util.get_user(required=True), id))
|
||||
|
||||
|
||||
@app.route("/groups/<id>/topics", methods=["POST"])
|
||||
@use_args(
|
||||
{
|
||||
"title": fields.Str(required=True, validate=validate.Length(min=3)),
|
||||
"description": fields.Str(),
|
||||
}
|
||||
)
|
||||
def group_topics_route_post(args, id):
|
||||
return util.jsonify(
|
||||
groups.create_forum_topic(util.get_user(required=True), id, args)
|
||||
)
|
||||
|
||||
|
||||
@app.route("/groups/<id>/topics/<topic_id>", methods=["PUT"])
|
||||
@use_args(
|
||||
{
|
||||
"title": fields.Str(),
|
||||
"description": fields.Str(),
|
||||
}
|
||||
)
|
||||
def group_topic_route_put(args, id, topic_id):
|
||||
return util.jsonify(
|
||||
groups.update_forum_topic(util.get_user(required=True), id, topic_id, args)
|
||||
)
|
||||
|
||||
|
||||
@app.route("/groups/<id>/topics/<topic_id>", methods=["DELETE"])
|
||||
def group_topic_route_delete(id, topic_id):
|
||||
return util.jsonify(
|
||||
groups.delete_forum_topic(util.get_user(required=True), id, topic_id)
|
||||
)
|
||||
|
||||
|
||||
@app.route("/groups/<id>/topics/<topic_id>/replies", methods=["GET"])
|
||||
@use_args(
|
||||
{
|
||||
"page": fields.Int(),
|
||||
},
|
||||
location="query",
|
||||
)
|
||||
def group_topic_replies_route(args, id, topic_id):
|
||||
return util.jsonify(
|
||||
groups.get_forum_topic_replies(util.get_user(required=True), id, topic_id, args)
|
||||
)
|
||||
|
||||
|
||||
@app.route("/groups/<id>/topics/<topic_id>/replies", methods=["POST"])
|
||||
@use_args(
|
||||
{
|
||||
"content": fields.Str(required=True, validate=validate.Length(min=1)),
|
||||
"attachments": fields.List(fields.Dict(), allow_none=True),
|
||||
}
|
||||
)
|
||||
def group_topic_replies_route_post(args, id, topic_id):
|
||||
return util.jsonify(
|
||||
groups.create_forum_topic_reply(
|
||||
util.get_user(required=True), id, topic_id, args
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.route("/groups/<id>/topics/<topic_id>/replies/<reply_id>", methods=["DELETE"])
|
||||
def group_topic_reply_route_delete(id, topic_id, reply_id):
|
||||
return util.jsonify(
|
||||
groups.delete_forum_topic_reply(
|
||||
util.get_user(required=True), id, topic_id, reply_id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# SEARCH
|
||||
|
||||
|
||||
|
@ -63,5 +63,26 @@ export const groups = {
|
||||
},
|
||||
deleteAdmin (id, userId, success, fail) {
|
||||
api.authenticatedRequest('DELETE', `/groups/${id}/admins/${userId}`, null, success, fail)
|
||||
},
|
||||
getForumTopics (id, success, fail) {
|
||||
api.authenticatedRequest('GET', `/groups/${id}/topics`, null, data => success && success(data.topics), fail)
|
||||
},
|
||||
createForumTopic (id, data, success, fail) {
|
||||
api.authenticatedRequest('POST', `/groups/${id}/topics`, data, success, fail)
|
||||
},
|
||||
deleteForumTopic (id, topicId, success, fail) {
|
||||
api.authenticatedRequest('DELETE', `/groups/${id}/topics/${topicId}`, null, success, fail)
|
||||
},
|
||||
updateForumTopic (id, topicId, data, success, fail) {
|
||||
api.authenticatedRequest('PUT', `/groups/${id}/topics/${topicId}`, data, success, fail)
|
||||
},
|
||||
getForumTopicReplies (id, topicId, page, success, fail) {
|
||||
api.authenticatedRequest('GET', `/groups/${id}/topics/${topicId}/replies?page=${page || 1}`, null, success, fail)
|
||||
},
|
||||
createForumTopicReply (id, topicId, data, success, fail) {
|
||||
api.authenticatedRequest('POST', `/groups/${id}/topics/${topicId}/replies`, data, success, fail)
|
||||
},
|
||||
deleteForumTopicReply (id, topicId, replyId, success, fail) {
|
||||
api.authenticatedRequest('DELETE', `/groups/${id}/topics/${topicId}/replies/${replyId}`, null, success, fail)
|
||||
}
|
||||
}
|
||||
|
@ -72,7 +72,7 @@ const FeedMessage = connect(
|
||||
{post?.attachments?.length > 0 &&
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{post.attachments.filter(e => e.isImage).map(a =>
|
||||
<a key={a.url} href={a.url} target='_blank' rel='noopener noreferrer'><div style={{ width: 100, height: 100, backgroundImage: `url(${a.url})`, backgroundSize: 'cover', backgroundPosition: 'center center', margin: 3, display: 'inline-block' }} /></a>
|
||||
<a key={a.url} href={a.url} target='_blank' rel='noopener noreferrer'><div style={{ width: 100, height: 100, backgroundImage: `url(${a.url})`, backgroundSize: 'cover', backgroundPosition: 'center center', margin: 3, display: 'inline-block', borderRadius: 10 }} /></a>
|
||||
)}
|
||||
<div />
|
||||
{post.attachments.filter(e => e.type === 'project').map(a =>
|
||||
|
@ -7,7 +7,7 @@ function FormattedMessage ({ content }) {
|
||||
return `<a href="${prefix}${match}" target="_blank">${match}</a>`
|
||||
})
|
||||
return (
|
||||
<div dangerouslySetInnerHTML={{ __html: newContent }} />
|
||||
<div style={{ whiteSpace: 'pre-line' }} dangerouslySetInnerHTML={{ __html: newContent }} />
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -22,10 +22,11 @@ const NewFeedMessage = connect(
|
||||
updateReplyingTo: entryId => dispatch(actions.posts.updateReplyingTo(entryId)),
|
||||
updatePosting: p => dispatch(actions.posts.updatePosting(p))
|
||||
})
|
||||
)(({ autoFocus, inReplyTo, user, forType, group, object, projects, post, attachments, attachmentUploading, replyingTo, posting, placeholder, noAttachments, addAttachment, deleteAttachment, updatePost, updateAttachmentUploading, clear, updateReplyingTo, updatePosting, onPosted }) => {
|
||||
)(({ autoFocus, inReplyTo, user, forType, group, object, topic, projects, post, attachments, attachmentUploading, replyingTo, posting, placeholder, noAttachments, addAttachment, deleteAttachment, updatePost, updateAttachmentUploading, clear, updateReplyingTo, updatePosting, onPosted }) => {
|
||||
let forObj
|
||||
if (forType === 'group') forObj = group
|
||||
if (forType === 'object') forObj = object
|
||||
if (forType === 'groupForum') forObj = topic
|
||||
|
||||
const attachProject = (project) => {
|
||||
addAttachment({
|
||||
@ -60,6 +61,9 @@ const NewFeedMessage = connect(
|
||||
if (forType === 'object') {
|
||||
api.objects.createComment(object._id, data, successCallback, errorCallback)
|
||||
}
|
||||
if (forType === 'groupForum') {
|
||||
api.groups.createForumTopicReply(group._id, topic._id, data, successCallback, errorCallback)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
136
web/src/components/main/groups/Forum.jsx
Normal file
136
web/src/components/main/groups/Forum.jsx
Normal file
@ -0,0 +1,136 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Form, Input, TextArea, Modal, Loader, Segment, Card, Button } from 'semantic-ui-react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import moment from 'moment'
|
||||
import api from '../../../api'
|
||||
import utils from '../../../utils/utils.js'
|
||||
import TrendsImage from '../../../images/trends.png'
|
||||
|
||||
export default function Forum () {
|
||||
const [topics, setTopics] = useState([])
|
||||
const [loadingTopics, setLoadingTopics] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const { id } = useParams()
|
||||
|
||||
const { user, group } = useSelector(state => {
|
||||
const group = state.groups.groups?.filter(g => g._id === id)[0]
|
||||
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0]
|
||||
return { user, group }
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingTopics(true)
|
||||
api.groups.getForumTopics(group._id, topics => {
|
||||
setLoadingTopics(false)
|
||||
setTopics(topics)
|
||||
}, err => {
|
||||
toast.error(err.message)
|
||||
setLoadingTopics(false)
|
||||
})
|
||||
}, [group._id])
|
||||
|
||||
function deleteTopic (topicId) {
|
||||
utils.confirm('Delete topic', 'Really delete this topic? This cannot be undone.').then(() => {
|
||||
api.groups.deleteForumTopic(group._id, topicId, () => {
|
||||
setTopics(topics.filter(t => t._id !== topicId))
|
||||
}, err => {
|
||||
toast.error(err.message)
|
||||
})
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const filteredTopics = query ? topics.filter(t => t.title.toLowerCase().includes(query.toLowerCase())) : topics
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Forum</h1>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<Input placeholder='Search topics...' value={query} onChange={e => setQuery(e.target.value)} />
|
||||
{utils.hasGroupPermission(user, group, 'postForumTopics') &&
|
||||
<TopicCreator
|
||||
trigger={<Button color='teal'>Create a topic</Button>}
|
||||
onCreateTopic={newTopic => setTopics([newTopic, ...topics])}
|
||||
/>}
|
||||
</div>
|
||||
{loadingTopics
|
||||
? <Loader active />
|
||||
: (
|
||||
topics.length === 0
|
||||
? (
|
||||
<Segment placeholder textAlign='center'>
|
||||
<img src={TrendsImage} alt='No topics' style={{ maxWidth: 300, display: 'block', margin: '10px auto' }} />
|
||||
<h3>Forum topics</h3>
|
||||
<p>This group doesn't yet have any forum topics.</p>
|
||||
</Segment>)
|
||||
: (
|
||||
<Card.Group>
|
||||
{filteredTopics?.map(topic => (
|
||||
<Card key={topic._id}>
|
||||
<Card.Content as={Link} to={`/groups/${group._id}/forum/topics/${topic._id}`}>
|
||||
<Card.Header>{topic.title}</Card.Header>
|
||||
<Card.Description>{topic.description}</Card.Description>
|
||||
</Card.Content>
|
||||
{topic.totalReplies > 0 &&
|
||||
<Card.Content extra>
|
||||
{topic.totalReplies} {topic.totalReplies === 1 ? 'reply' : 'replies'} (last {moment(topic.lastReplyAt).fromNow()})
|
||||
</Card.Content>}
|
||||
{(utils.isGroupAdmin(user, group) || topic.user === user._id) &&
|
||||
<Card.Content extra>
|
||||
<TopicCreator
|
||||
topic={topic}
|
||||
trigger={<Button size='tiny' basic>Edit</Button>}
|
||||
onCreateTopic={newTopic => setTopics(topics.map(t => t._id === newTopic._id ? newTopic : t))}
|
||||
/>
|
||||
<Button size='tiny' basic onClick={() => deleteTopic(topic._id)} color='orange'>Delete</Button>
|
||||
</Card.Content>}
|
||||
</Card>
|
||||
))}
|
||||
</Card.Group>))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TopicCreator ({ topic, trigger, onCreateTopic }) {
|
||||
const [title, setTitle] = useState(topic?.title || '')
|
||||
const [description, setDescription] = useState(topic?.description || '')
|
||||
const [open, setOpen] = useState(false)
|
||||
const { id } = useParams()
|
||||
|
||||
function createTopic () {
|
||||
const successCb = newTopic => {
|
||||
onCreateTopic && onCreateTopic(newTopic)
|
||||
setOpen(false)
|
||||
}
|
||||
const errorCb = err => {
|
||||
toast.error(err.message)
|
||||
}
|
||||
if (topic) {
|
||||
api.groups.updateForumTopic(id, topic._id, { title, description }, successCb, errorCb)
|
||||
} else {
|
||||
api.groups.createForumTopic(id, { title, description }, successCb, errorCb)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{trigger && React.cloneElement(trigger, { onClick: () => setOpen(true) })}
|
||||
<Modal open={open} onClose={() => setOpen(false)}>
|
||||
<Modal.Header>{topic ? 'Edit' : 'New'} Topic</Modal.Header>
|
||||
<Modal.Content>
|
||||
<Form>
|
||||
<Input placeholder='Title' value={title} onChange={e => setTitle(e.target.value)} />
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<TextArea placeholder='Write a brief description...' value={description} onChange={e => setDescription(e.target.value)} />
|
||||
</div>
|
||||
</Form>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button primary onClick={createTopic}>{topic ? 'Save' : 'Create'}</Button>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
157
web/src/components/main/groups/ForumTopic.jsx
Normal file
157
web/src/components/main/groups/ForumTopic.jsx
Normal file
@ -0,0 +1,157 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Loader, Segment, Button } from 'semantic-ui-react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { Link, useParams, useLocation } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import actions from '../../../actions'
|
||||
import api from '../../../api'
|
||||
import utils from '../../../utils/utils'
|
||||
import UserChip from '../../includes/UserChip'
|
||||
import NewFeedMessage from '../../includes/NewFeedMessage'
|
||||
import FormattedMessage from '../../includes/FormattedMessage'
|
||||
import StartChatImage from '../../../images/startchat.png'
|
||||
|
||||
export default function ForumTopic () {
|
||||
const [topic, setTopic] = useState(null)
|
||||
const [replies, setReplies] = useState([])
|
||||
const [totalReplies, setTotalReplies] = useState(0)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [loadingReplies, setLoadingReplies] = useState(false)
|
||||
const { hash } = useLocation()
|
||||
const { id, topicId, page: pageParam } = useParams()
|
||||
const dispatch = useDispatch()
|
||||
const page = parseInt(pageParam) || 1
|
||||
|
||||
const { user, group } = useSelector(state => {
|
||||
const group = state.groups.groups.filter(g => g._id === id)[0]
|
||||
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0]
|
||||
return { user, group }
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingReplies(true)
|
||||
api.groups.getForumTopicReplies(group._id, topicId, page, ({ topic, replies, totalReplies, totalPages }) => {
|
||||
setLoadingReplies(false)
|
||||
setTopic(topic)
|
||||
setReplies(replies)
|
||||
setTotalReplies(totalReplies)
|
||||
setTotalPages(totalPages)
|
||||
}, err => {
|
||||
toast.error(err.message)
|
||||
setLoadingReplies(false)
|
||||
})
|
||||
}, [group._id, topicId, page])
|
||||
|
||||
useEffect(() => {
|
||||
if (hash === '#forum-reply') {
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById('forum-reply')
|
||||
element.scrollIntoView({ behavior: 'smooth' })
|
||||
}, 400)
|
||||
}
|
||||
}, [hash])
|
||||
|
||||
const toggleEmailSub = (key, enable) => {
|
||||
if (enable) { api.users.createEmailSubscription(user.username, key, ({ subscriptions }) => dispatch(actions.users.updateSubscriptions(user._id, subscriptions)), err => toast.error(err.message)) } else { api.users.deleteEmailSubscription(user.username, key, ({ subscriptions }) => dispatch(actions.users.updateSubscriptions(user._id, subscriptions)), err => toast.error(err.message)) }
|
||||
}
|
||||
|
||||
function handleDeleteReply (replyId) {
|
||||
utils.confirm('Delete reply', 'Really delete this reply? This cannot be undone.').then(() => {
|
||||
api.groups.deleteForumTopicReply(group._id, topicId, replyId, () => {
|
||||
setReplies(replies.filter(r => r._id !== replyId))
|
||||
setTotalReplies(totalReplies - 1)
|
||||
}, err => {
|
||||
toast.error(err.message)
|
||||
})
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const BASE_ROUTE = `/groups/${id}/forum/topics/${topicId}`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button basic as={Link} to={`/groups/${id}/forum`}>View all topics</Button>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 20 }}>
|
||||
<h2>{topic?.title || 'Topic'}</h2>
|
||||
<div>
|
||||
{utils.hasEmailSubscription(user, `groupForumTopic-${topic?._id}`)
|
||||
? <Button color='blue' basic size='tiny' icon='check' content='Subscribed to email updates' onClick={e => toggleEmailSub(`groupForumTopic-${topic?._id}`, false)} />
|
||||
: <Button color='blue' size='tiny' icon='rss' content='Subscribe to updates' onClick={e => toggleEmailSub(`groupForumTopic-${topic?._id}`, true)} />}
|
||||
{utils.hasGroupPermission(user, group, 'postForumTopicReplies') && totalPages > 0 &&
|
||||
<Button
|
||||
color='teal' size='small' as={Link} to={`${BASE_ROUTE}/page/${totalPages}#forum-reply`} onClick={e => {
|
||||
if (page === totalPages) {
|
||||
e.preventDefault()
|
||||
document.querySelector('#forum-reply textarea').focus()
|
||||
}
|
||||
}}
|
||||
>
|
||||
Write a reply to this topic
|
||||
</Button>}
|
||||
</div>
|
||||
</div>
|
||||
{loadingReplies && <Loader active />}
|
||||
{!loadingReplies && replies.length === 0 &&
|
||||
<Segment placeholder textAlign='center'>
|
||||
<img src={StartChatImage} alt='Start chatting' style={{ width: 200, margin: '20px auto' }} />
|
||||
<p>This topic doesn't have any replies yet.</p>
|
||||
</Segment>}
|
||||
|
||||
{replies.map(reply => (
|
||||
<Segment key={reply._id}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<UserChip user={reply.author} compact />
|
||||
<div style={{ fontSize: 12, color: 'rgb(150,150,150)', display: 'flex' }}>
|
||||
<div>{new Date(reply.createdAt).toLocaleString()}</div>
|
||||
{(utils.isGroupAdmin(user, group) || user._id === reply.author._id) &&
|
||||
<div role='button' style={{ textDecoration: 'underline', cursor: 'pointer', marginLeft: 10 }} onClick={() => handleDeleteReply(reply._id)}>Delete</div>}
|
||||
</div>
|
||||
</div>
|
||||
<FormattedMessage content={reply.content} />
|
||||
{reply.attachments && reply.attachments.length > 0 &&
|
||||
<div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', marginTop: 10 }}>
|
||||
{reply.attachments.filter(a => a.isImage).map(a =>
|
||||
<a key={a.storedName} href={a.url} target='blank' rel='noopener noreferrer'><div style={{ width: 100, height: 100, background: `url(${a.url}) center center / cover`, marginRight: 10, borderRadius: 10 }} /></a>)}
|
||||
</div>
|
||||
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap' }}>
|
||||
{reply.attachments.map(a => {
|
||||
if (a.type === 'project') {
|
||||
return <Button key={a.fullName} as={Link} to={`/${a.fullName}`} size='tiny' icon='book' content={a.name} style={{ margin: 4 }} />
|
||||
} else if (a.type === 'file' && !a.isImage) {
|
||||
return <Button key={a.url} as='a' href={a.url} target='blank' rel='noopener noreferrer' basic compact size='tiny' style={{ margin: 5 }} icon='download' content={a.name} />
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
</div>}
|
||||
</Segment>
|
||||
))}
|
||||
|
||||
{totalPages > 1 &&
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginTop: 20, marginBottom: 20 }}>
|
||||
<Button size='tiny' disabled={page === 1} as={Link} to={`${BASE_ROUTE}/page/${page - 1}`}>Previous page</Button>
|
||||
<span style={{ margin: '0 10px' }}>Page {page} of {totalPages}</span>
|
||||
<Button size='tiny' disabled={page === totalPages} as={Link} to={`${BASE_ROUTE}/page/${page + 1}`}>Next page</Button>
|
||||
</div>}
|
||||
|
||||
{utils.hasGroupPermission(user, group, 'postForumTopicReplies') &&
|
||||
(page >= totalPages
|
||||
? (
|
||||
<div id='forum-reply'>
|
||||
<NewFeedMessage
|
||||
user={user} group={group} topic={topic} forType='groupForum' onPosted={newReply => {
|
||||
setTotalReplies(totalReplies + 1)
|
||||
setReplies([...replies, newReply])
|
||||
toggleEmailSub(`groupForumTopic-${topic?._id}`, true)
|
||||
}}
|
||||
/>
|
||||
</div>)
|
||||
: (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginTop: 20 }}>
|
||||
<Button color='teal' as={Link} to={`${BASE_ROUTE}/page/${totalPages}#forum-reply`}>Write a reply to this topic</Button>
|
||||
</div>))}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
@ -109,16 +109,15 @@ function Group () {
|
||||
|
||||
{utils.isInGroup(user, group._id) &&
|
||||
<Menu fluid vertical>
|
||||
<Menu.Item active={utils.activePath('^/groups/[a-zA-Z0-9]+$')} as={Link} to={`/groups/${group._id}`} icon='chat' content='Notice Board' />
|
||||
{utils.isInGroup(user, group._id) &&
|
||||
<>
|
||||
{utils.hasGroupPermission(user, group, 'viewMembers') &&
|
||||
<Menu.Item active={utils.activePath('members')} as={Link} to={`/groups/${group._id}/members`} icon='user' content='Members' />}
|
||||
{utils.hasGroupPermission(user, group, 'viewProjects') &&
|
||||
<Menu.Item active={utils.activePath('projects')} as={Link} to={`/groups/${group._id}/projects`} icon='book' content='Projects' />}
|
||||
{utils.isGroupAdmin(user, group) &&
|
||||
<Menu.Item active={utils.activePath('settings')} as={Link} to={`/groups/${group._id}/settings`} icon='settings' content='Settings' />}
|
||||
</>}
|
||||
<Menu.Item active={utils.activePath('^/groups/[a-zA-Z0-9]+$')} as={Link} to={`/groups/${group._id}`} icon='bullhorn' content='Notice Board' />
|
||||
{utils.hasGroupPermission(user, group, 'viewForumTopics') &&
|
||||
<Menu.Item active={utils.activePath('forum')} as={Link} to={`/groups/${group._id}/forum`} icon='comments' content='Forum Topics' />}
|
||||
{utils.hasGroupPermission(user, group, 'viewMembers') &&
|
||||
<Menu.Item active={utils.activePath('members')} as={Link} to={`/groups/${group._id}/members`} icon='user' content='Members' />}
|
||||
{utils.hasGroupPermission(user, group, 'viewProjects') &&
|
||||
<Menu.Item active={utils.activePath('projects')} as={Link} to={`/groups/${group._id}/projects`} icon='book' content='Projects' />}
|
||||
{utils.isGroupAdmin(user, group) &&
|
||||
<Menu.Item active={utils.activePath('settings')} as={Link} to={`/groups/${group._id}/settings`} icon='settings' content='Settings' />}
|
||||
</Menu>}
|
||||
|
||||
<Card fluid color='yellow'>
|
||||
|
@ -12,7 +12,10 @@ const PERMISSIONS = [
|
||||
{ name: 'viewMembers', label: 'Allow members to view other members' },
|
||||
{ name: 'viewNoticeboard', label: 'Allow members to view the noticeboard' },
|
||||
{ name: 'postNoticeboard', label: 'Allow members to post to the noticeboard' },
|
||||
{ name: 'viewProjects', label: 'Allow members to view projects linked to the group' }
|
||||
{ name: 'viewProjects', label: 'Allow members to view projects linked to the group' },
|
||||
{ name: 'viewForumTopics', label: 'Allow members to view forum topics' },
|
||||
{ name: 'postForumTopics', label: 'Allow members to post new topics to the forum' },
|
||||
{ name: 'postForumTopicReplies', label: 'Allow members to reply to forum topics' }
|
||||
]
|
||||
|
||||
function Settings () {
|
||||
|
BIN
web/src/images/startchat.png
Normal file
BIN
web/src/images/startchat.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 89 KiB |
BIN
web/src/images/trends.png
Normal file
BIN
web/src/images/trends.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 48 KiB |
@ -40,6 +40,8 @@ import ObjectList from './components/main/projects/ObjectList'
|
||||
import NewGroup from './components/main/groups/New'
|
||||
import Group from './components/main/groups/Group'
|
||||
import GroupFeed from './components/main/groups/Feed'
|
||||
import GroupForum from './components/main/groups/Forum'
|
||||
import GroupForumTopic from './components/main/groups/ForumTopic'
|
||||
import GroupMembers from './components/main/groups/Members'
|
||||
import GroupProjects from './components/main/groups/Projects'
|
||||
import GroupSettings from './components/main/groups/Settings'
|
||||
@ -99,6 +101,9 @@ const router = createBrowserRouter([
|
||||
element: <Group />,
|
||||
children: [
|
||||
{ path: 'feed', element: <GroupFeed />, errorElement: <ErrorElement /> },
|
||||
{ path: 'forum', element: <GroupForum />, errorElement: <ErrorElement /> },
|
||||
{ path: 'forum/topics/:topicId', element: <GroupForumTopic />, errorElement: <ErrorElement /> },
|
||||
{ path: 'forum/topics/:topicId/page/:page', element: <GroupForumTopic />, errorElement: <ErrorElement /> },
|
||||
{ path: 'members', element: <GroupMembers />, errorElement: <ErrorElement /> },
|
||||
{ path: 'projects', element: <GroupProjects />, errorElement: <ErrorElement /> },
|
||||
{ path: 'settings', element: <GroupSettings />, errorElement: <ErrorElement /> },
|
||||
|
Loading…
Reference in New Issue
Block a user