Compare commits

..

13 Commits

14 changed files with 682 additions and 14 deletions

View File

@ -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}

View File

@ -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")

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

View File

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

View File

@ -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 =>

View File

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

View File

@ -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 (

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) &&
<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'>

View File

@ -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 () {

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 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 /> },