Compare commits

..

13 Commits

14 changed files with 682 additions and 14 deletions

View File

@ -1,6 +1,7 @@
import datetime import datetime
import re import re
import os import os
import math
import pymongo import pymongo
from bson.objectid import ObjectId from bson.objectid import ObjectId
from util import database, util, mail, push from util import database, util, mail, push
@ -42,6 +43,9 @@ def create(user, data):
"postNoticeboard", "postNoticeboard",
"viewProjects", "viewProjects",
"postProjects", "postProjects",
"viewForumTopics",
"postForumTopics",
"postForumTopicReplies",
], ],
} }
result = db.groups.insert_one(group) result = db.groups.insert_one(group)
@ -486,3 +490,257 @@ def get_projects(user, id):
project["fullName"] = a["username"] + "/" + project["path"] project["fullName"] = a["username"] + "/" + project["path"]
break break
return {"projects": projects} 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}

View File

@ -6,6 +6,7 @@ from bson.objectid import ObjectId
import boto3 import boto3
import blurhash import blurhash
from util import database, util from util import database, util
from api.groups import has_group_permission
def sanitise_filename(s): def sanitise_filename(s):
@ -66,6 +67,15 @@ def generate_file_upload_request(
if for_type == "group": if for_type == "group":
allowed = ObjectId(for_id) in user.get("groups", []) allowed = ObjectId(for_id) in user.get("groups", [])
path = "groups/" + for_id + "/" 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: if not allowed:
raise util.errors.Forbidden("You're not allowed to upload this file") raise util.errors.Forbidden("You're not allowed to upload this file")

View File

@ -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 # SEARCH

View File

@ -63,5 +63,26 @@ export const groups = {
}, },
deleteAdmin (id, userId, success, fail) { deleteAdmin (id, userId, success, fail) {
api.authenticatedRequest('DELETE', `/groups/${id}/admins/${userId}`, null, 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)
} }
} }

View File

@ -72,7 +72,7 @@ const FeedMessage = connect(
{post?.attachments?.length > 0 && {post?.attachments?.length > 0 &&
<div style={{ marginTop: 10 }}> <div style={{ marginTop: 10 }}>
{post.attachments.filter(e => e.isImage).map(a => {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 /> <div />
{post.attachments.filter(e => e.type === 'project').map(a => {post.attachments.filter(e => e.type === 'project').map(a =>

View File

@ -7,7 +7,7 @@ function FormattedMessage ({ content }) {
return `<a href="${prefix}${match}" target="_blank">${match}</a>` return `<a href="${prefix}${match}" target="_blank">${match}</a>`
}) })
return ( return (
<div dangerouslySetInnerHTML={{ __html: newContent }} /> <div style={{ whiteSpace: 'pre-line' }} dangerouslySetInnerHTML={{ __html: newContent }} />
) )
} }

View File

@ -22,10 +22,11 @@ const NewFeedMessage = connect(
updateReplyingTo: entryId => dispatch(actions.posts.updateReplyingTo(entryId)), updateReplyingTo: entryId => dispatch(actions.posts.updateReplyingTo(entryId)),
updatePosting: p => dispatch(actions.posts.updatePosting(p)) 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 let forObj
if (forType === 'group') forObj = group if (forType === 'group') forObj = group
if (forType === 'object') forObj = object if (forType === 'object') forObj = object
if (forType === 'groupForum') forObj = topic
const attachProject = (project) => { const attachProject = (project) => {
addAttachment({ addAttachment({
@ -60,6 +61,9 @@ const NewFeedMessage = connect(
if (forType === 'object') { if (forType === 'object') {
api.objects.createComment(object._id, data, successCallback, errorCallback) api.objects.createComment(object._id, data, successCallback, errorCallback)
} }
if (forType === 'groupForum') {
api.groups.createForumTopicReply(group._id, topic._id, data, successCallback, errorCallback)
}
} }
return ( return (

View 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>
</>
)
}

View 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>
)
}

View File

@ -109,16 +109,15 @@ function Group () {
{utils.isInGroup(user, group._id) && {utils.isInGroup(user, group._id) &&
<Menu fluid vertical> <Menu fluid vertical>
<Menu.Item active={utils.activePath('^/groups/[a-zA-Z0-9]+$')} as={Link} to={`/groups/${group._id}`} icon='chat' content='Notice Board' /> <Menu.Item active={utils.activePath('^/groups/[a-zA-Z0-9]+$')} as={Link} to={`/groups/${group._id}`} icon='bullhorn' content='Notice Board' />
{utils.isInGroup(user, group._id) && {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') && {utils.hasGroupPermission(user, group, 'viewMembers') &&
<Menu.Item active={utils.activePath('members')} as={Link} to={`/groups/${group._id}/members`} icon='user' content='Members' />} <Menu.Item active={utils.activePath('members')} as={Link} to={`/groups/${group._id}/members`} icon='user' content='Members' />}
{utils.hasGroupPermission(user, group, 'viewProjects') && {utils.hasGroupPermission(user, group, 'viewProjects') &&
<Menu.Item active={utils.activePath('projects')} as={Link} to={`/groups/${group._id}/projects`} icon='book' content='Projects' />} <Menu.Item active={utils.activePath('projects')} as={Link} to={`/groups/${group._id}/projects`} icon='book' content='Projects' />}
{utils.isGroupAdmin(user, group) && {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('settings')} as={Link} to={`/groups/${group._id}/settings`} icon='settings' content='Settings' />}
</>}
</Menu>} </Menu>}
<Card fluid color='yellow'> <Card fluid color='yellow'>

View File

@ -12,7 +12,10 @@ const PERMISSIONS = [
{ name: 'viewMembers', label: 'Allow members to view other members' }, { name: 'viewMembers', label: 'Allow members to view other members' },
{ name: 'viewNoticeboard', label: 'Allow members to view the noticeboard' }, { name: 'viewNoticeboard', label: 'Allow members to view the noticeboard' },
{ name: 'postNoticeboard', label: 'Allow members to post to 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 () { function Settings () {

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
web/src/images/trends.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@ -40,6 +40,8 @@ import ObjectList from './components/main/projects/ObjectList'
import NewGroup from './components/main/groups/New' import NewGroup from './components/main/groups/New'
import Group from './components/main/groups/Group' import Group from './components/main/groups/Group'
import GroupFeed from './components/main/groups/Feed' 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 GroupMembers from './components/main/groups/Members'
import GroupProjects from './components/main/groups/Projects' import GroupProjects from './components/main/groups/Projects'
import GroupSettings from './components/main/groups/Settings' import GroupSettings from './components/main/groups/Settings'
@ -99,6 +101,9 @@ const router = createBrowserRouter([
element: <Group />, element: <Group />,
children: [ children: [
{ path: 'feed', element: <GroupFeed />, errorElement: <ErrorElement /> }, { 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: 'members', element: <GroupMembers />, errorElement: <ErrorElement /> },
{ path: 'projects', element: <GroupProjects />, errorElement: <ErrorElement /> }, { path: 'projects', element: <GroupProjects />, errorElement: <ErrorElement /> },
{ path: 'settings', element: <GroupSettings />, errorElement: <ErrorElement /> }, { path: 'settings', element: <GroupSettings />, errorElement: <ErrorElement /> },