Compare commits
22 Commits
46965c0040
...
4f4a71bf72
Author | SHA1 | Date | |
---|---|---|---|
4f4a71bf72 | |||
239adc4816 | |||
15bfb43965 | |||
384cf75400 | |||
89ffa94553 | |||
e03ceed668 | |||
31d6f41276 | |||
fe24bcef1e | |||
29d0af6e5b | |||
045a0af4a2 | |||
8446c209b3 | |||
c6fdc1d537 | |||
397ec5072b | |||
82f0a1eb6d | |||
e174abce33 | |||
d72038212f | |||
957cbebdd2 | |||
fdb363abe4 | |||
859d78cf5d | |||
f0a0a55bce | |||
0019f4e019 | |||
af07226227 |
@ -102,6 +102,11 @@ def update(user, id, update):
|
|||||||
]
|
]
|
||||||
updater = util.build_updater(update, allowed_keys)
|
updater = util.build_updater(update, allowed_keys)
|
||||||
if updater:
|
if updater:
|
||||||
|
if "$set" in updater and (
|
||||||
|
"name" in update or "description" in update or "image" in update
|
||||||
|
):
|
||||||
|
updater["$set"]["moderationRequired"] = True
|
||||||
|
util.send_moderation_request(user, "groups", group)
|
||||||
db.groups.update_one({"_id": id}, updater)
|
db.groups.update_one({"_id": id}, updater)
|
||||||
return get_one(user, id)
|
return get_one(user, id)
|
||||||
|
|
||||||
@ -137,6 +142,7 @@ def create_entry(user, id, data):
|
|||||||
"group": id,
|
"group": id,
|
||||||
"user": user["_id"],
|
"user": user["_id"],
|
||||||
"content": data["content"],
|
"content": data["content"],
|
||||||
|
"moderationRequired": True,
|
||||||
}
|
}
|
||||||
if "attachments" in data:
|
if "attachments" in data:
|
||||||
entry["attachments"] = data["attachments"]
|
entry["attachments"] = data["attachments"]
|
||||||
@ -161,12 +167,24 @@ def create_entry(user, id, data):
|
|||||||
entry["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
entry["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
||||||
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
||||||
)
|
)
|
||||||
|
util.send_moderation_request(user, "groupEntries", entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def send_entry_notification(id):
|
||||||
|
db = database.get_db()
|
||||||
|
entry = db.groupEntries.find_one({"_id": ObjectId(id)})
|
||||||
|
# If this is a reply, then send the reply email instead
|
||||||
|
if entry.get("inReplyTo"):
|
||||||
|
return send_entry_reply_notification(id)
|
||||||
|
group = db.groups.find_one({"_id": entry["group"]})
|
||||||
|
user = db.users.find_one({"_id": entry["user"]})
|
||||||
|
|
||||||
for u in db.users.find(
|
for u in db.users.find(
|
||||||
{
|
{
|
||||||
"_id": {"$ne": user["_id"]},
|
"_id": {"$ne": user["_id"]},
|
||||||
"groups": id,
|
"groups": group["_id"],
|
||||||
"subscriptions.email": "groupFeed-" + str(id),
|
"subscriptions.email": "groupFeed-" + str(group["_id"]),
|
||||||
},
|
},
|
||||||
{"email": 1, "username": 1},
|
{"email": 1, "username": 1},
|
||||||
):
|
):
|
||||||
@ -178,18 +196,17 @@ def create_entry(user, id, data):
|
|||||||
u["username"],
|
u["username"],
|
||||||
user["username"],
|
user["username"],
|
||||||
group["name"],
|
group["name"],
|
||||||
data["content"],
|
entry["content"],
|
||||||
"{}/groups/{}".format(APP_URL, str(id)),
|
"{}/groups/{}".format(APP_URL, str(group["_id"])),
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
push.send_multiple(
|
push.send_multiple(
|
||||||
list(db.users.find({"_id": {"$ne": user["_id"]}, "groups": id})),
|
list(db.users.find({"_id": {"$ne": user["_id"]}, "groups": group["_id"]})),
|
||||||
"{} posted in {}".format(user["username"], group["name"]),
|
"{} posted in {}".format(user["username"], group["name"]),
|
||||||
data["content"][:30] + "...",
|
entry["content"][:30] + "...",
|
||||||
)
|
)
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
def get_entries(user, id):
|
def get_entries(user, id):
|
||||||
@ -202,8 +219,14 @@ def get_entries(user, id):
|
|||||||
raise util.errors.BadRequest("You're not a member of this group")
|
raise util.errors.BadRequest("You're not a member of this group")
|
||||||
if not has_group_permission(user, group, "viewNoticeboard"):
|
if not has_group_permission(user, group, "viewNoticeboard"):
|
||||||
raise util.errors.Forbidden("You don't have permission to view the feed")
|
raise util.errors.Forbidden("You don't have permission to view the feed")
|
||||||
|
# Only return entries that have been moderated or are owned by the user
|
||||||
entries = list(
|
entries = list(
|
||||||
db.groupEntries.find({"group": id}).sort("createdAt", pymongo.DESCENDING)
|
db.groupEntries.find(
|
||||||
|
{
|
||||||
|
"group": id,
|
||||||
|
"$or": [{"user": user["_id"]}, {"moderationRequired": {"$ne": True}}],
|
||||||
|
}
|
||||||
|
).sort("createdAt", pymongo.DESCENDING)
|
||||||
)
|
)
|
||||||
authors = list(
|
authors = list(
|
||||||
db.users.find(
|
db.users.find(
|
||||||
@ -266,6 +289,7 @@ def create_entry_reply(user, id, entry_id, data):
|
|||||||
"inReplyTo": entry_id,
|
"inReplyTo": entry_id,
|
||||||
"user": user["_id"],
|
"user": user["_id"],
|
||||||
"content": data["content"],
|
"content": data["content"],
|
||||||
|
"moderationRequired": True,
|
||||||
}
|
}
|
||||||
if "attachments" in data:
|
if "attachments" in data:
|
||||||
reply["attachments"] = data["attachments"]
|
reply["attachments"] = data["attachments"]
|
||||||
@ -290,9 +314,22 @@ def create_entry_reply(user, id, entry_id, data):
|
|||||||
reply["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
reply["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
||||||
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
||||||
)
|
)
|
||||||
|
util.send_moderation_request(user, "groupEntries", entry)
|
||||||
|
return reply
|
||||||
|
|
||||||
|
|
||||||
|
def send_entry_reply_notification(id):
|
||||||
|
db = database.get_db()
|
||||||
|
reply = db.groupEntries.find_one({"_id": ObjectId(id)})
|
||||||
|
user = db.users.find_one({"_id": reply["user"]})
|
||||||
|
original_entry = db.groupEntries.find_one({"_id": reply["inReplyTo"]})
|
||||||
|
group = db.groups.find_one({"_id": original_entry["group"]})
|
||||||
op = db.users.find_one(
|
op = db.users.find_one(
|
||||||
{
|
{
|
||||||
"$and": [{"_id": entry.get("user")}, {"_id": {"$ne": user["_id"]}}],
|
"$and": [
|
||||||
|
{"_id": original_entry.get("user")},
|
||||||
|
{"_id": {"$ne": user["_id"]}},
|
||||||
|
],
|
||||||
"subscriptions.email": "messages.replied",
|
"subscriptions.email": "messages.replied",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -305,13 +342,12 @@ def create_entry_reply(user, id, entry_id, data):
|
|||||||
op["username"],
|
op["username"],
|
||||||
user["username"],
|
user["username"],
|
||||||
group["name"],
|
group["name"],
|
||||||
data["content"],
|
reply["content"],
|
||||||
"{}/groups/{}".format(APP_URL, str(id)),
|
"{}/groups/{}".format(APP_URL, str(group["_id"])),
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return reply
|
|
||||||
|
|
||||||
|
|
||||||
def delete_entry_reply(user, id, entry_id, reply_id):
|
def delete_entry_reply(user, id, entry_id, reply_id):
|
||||||
@ -594,6 +630,7 @@ def create_forum_topic_reply(user, id, topic_id, data):
|
|||||||
"user": user["_id"],
|
"user": user["_id"],
|
||||||
"content": data["content"],
|
"content": data["content"],
|
||||||
"attachments": data.get("attachments", []),
|
"attachments": data.get("attachments", []),
|
||||||
|
"moderationRequired": True,
|
||||||
}
|
}
|
||||||
result = db.groupForumTopicReplies.insert_one(reply)
|
result = db.groupForumTopicReplies.insert_one(reply)
|
||||||
db.groupForumTopics.update_one(
|
db.groupForumTopics.update_one(
|
||||||
@ -631,11 +668,21 @@ def create_forum_topic_reply(user, id, topic_id, data):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
util.send_moderation_request(user, "groupForumTopicReplies", reply)
|
||||||
|
return reply
|
||||||
|
|
||||||
|
|
||||||
|
def send_forum_topic_reply_notification(id):
|
||||||
|
db = database.get_db()
|
||||||
|
reply = db.groupForumTopicReplies.find_one({"_id": ObjectId(id)})
|
||||||
|
user = db.users.find_one({"_id": reply["user"]})
|
||||||
|
topic = db.groupForumTopics.find_one({"_id": reply["topic"]})
|
||||||
|
group = db.groups.find_one({"_id": topic["group"]})
|
||||||
for u in db.users.find(
|
for u in db.users.find(
|
||||||
{
|
{
|
||||||
"_id": {"$ne": user["_id"]},
|
"_id": {"$ne": reply["user"]},
|
||||||
"groups": id,
|
"groups": topic["group"],
|
||||||
"subscriptions.email": "groupForumTopic-" + str(topic_id),
|
"subscriptions.email": "groupForumTopic-" + str(topic["_id"]),
|
||||||
},
|
},
|
||||||
{"email": 1, "username": 1},
|
{"email": 1, "username": 1},
|
||||||
):
|
):
|
||||||
@ -648,17 +695,15 @@ def create_forum_topic_reply(user, id, topic_id, data):
|
|||||||
user["username"],
|
user["username"],
|
||||||
topic["title"],
|
topic["title"],
|
||||||
group["name"],
|
group["name"],
|
||||||
data["content"],
|
reply["content"],
|
||||||
"{}/groups/{}/forum/topics/{}".format(
|
"{}/groups/{}/forum/topics/{}".format(
|
||||||
APP_URL, str(id), str(topic_id)
|
APP_URL, str(group["_id"]), str(topic["_id"])
|
||||||
),
|
),
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return reply
|
|
||||||
|
|
||||||
|
|
||||||
def get_forum_topic_replies(user, id, topic_id, data):
|
def get_forum_topic_replies(user, id, topic_id, data):
|
||||||
REPLIES_PER_PAGE = 20
|
REPLIES_PER_PAGE = 20
|
||||||
@ -678,7 +723,12 @@ def get_forum_topic_replies(user, id, topic_id, data):
|
|||||||
)
|
)
|
||||||
total_replies = db.groupForumTopicReplies.count_documents({"topic": topic_id})
|
total_replies = db.groupForumTopicReplies.count_documents({"topic": topic_id})
|
||||||
replies = list(
|
replies = list(
|
||||||
db.groupForumTopicReplies.find({"topic": topic_id})
|
db.groupForumTopicReplies.find(
|
||||||
|
{
|
||||||
|
"topic": topic_id,
|
||||||
|
"$or": [{"moderationRequired": {"$ne": True}}, {"user": user["_id"]}],
|
||||||
|
}
|
||||||
|
)
|
||||||
.sort("createdAt", pymongo.ASCENDING)
|
.sort("createdAt", pymongo.ASCENDING)
|
||||||
.skip((page - 1) * REPLIES_PER_PAGE)
|
.skip((page - 1) * REPLIES_PER_PAGE)
|
||||||
.limit(REPLIES_PER_PAGE)
|
.limit(REPLIES_PER_PAGE)
|
||||||
|
@ -34,7 +34,9 @@ def get(user, id):
|
|||||||
raise util.errors.NotFound("Project not found")
|
raise util.errors.NotFound("Project not found")
|
||||||
is_owner = user and (user.get("_id") == proj["user"])
|
is_owner = user and (user.get("_id") == proj["user"])
|
||||||
if not is_owner and proj["visibility"] != "public":
|
if not is_owner and proj["visibility"] != "public":
|
||||||
raise util.errors.BadRequest("Forbidden")
|
raise util.errors.Forbidden("Forbidden")
|
||||||
|
if not util.can_edit_project(user, proj) and obj.get("moderationRequired"):
|
||||||
|
raise util.errors.Forbidden("Awaiting moderation")
|
||||||
owner = db.users.find_one({"_id": proj["user"]}, {"username": 1, "avatar": 1})
|
owner = db.users.find_one({"_id": proj["user"]}, {"username": 1, "avatar": 1})
|
||||||
if obj["type"] == "file" and "storedName" in obj:
|
if obj["type"] == "file" and "storedName" in obj:
|
||||||
obj["url"] = uploads.get_presigned_url(
|
obj["url"] = uploads.get_presigned_url(
|
||||||
@ -169,12 +171,12 @@ def create_comment(user, id, data):
|
|||||||
obj = db.objects.find_one({"_id": ObjectId(id)})
|
obj = db.objects.find_one({"_id": ObjectId(id)})
|
||||||
if not obj:
|
if not obj:
|
||||||
raise util.errors.NotFound("We could not find the specified object")
|
raise util.errors.NotFound("We could not find the specified object")
|
||||||
project = db.projects.find_one({"_id": obj["project"]})
|
|
||||||
comment = {
|
comment = {
|
||||||
"content": data.get("content", ""),
|
"content": data.get("content", ""),
|
||||||
"object": ObjectId(id),
|
"object": ObjectId(id),
|
||||||
"user": user["_id"],
|
"user": user["_id"],
|
||||||
"createdAt": datetime.datetime.now(),
|
"createdAt": datetime.datetime.now(),
|
||||||
|
"moderationRequired": True,
|
||||||
}
|
}
|
||||||
result = db.comments.insert_one(comment)
|
result = db.comments.insert_one(comment)
|
||||||
db.objects.update_one({"_id": ObjectId(id)}, {"$inc": {"commentCount": 1}})
|
db.objects.update_one({"_id": ObjectId(id)}, {"$inc": {"commentCount": 1}})
|
||||||
@ -186,6 +188,16 @@ def create_comment(user, id, data):
|
|||||||
"users/{0}/{1}".format(user["_id"], user.get("avatar"))
|
"users/{0}/{1}".format(user["_id"], user.get("avatar"))
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
util.send_moderation_request(user, "comments", comment)
|
||||||
|
return comment
|
||||||
|
|
||||||
|
|
||||||
|
def send_comment_notification(id):
|
||||||
|
db = database.get_db()
|
||||||
|
comment = db.comments.find_one({"_id": ObjectId(id)})
|
||||||
|
user = db.users.find_one({"_id": comment["user"]})
|
||||||
|
obj = db.objects.find_one({"_id": comment["object"]})
|
||||||
|
project = db.projects.find_one({"_id": obj["project"]})
|
||||||
project_owner = db.users.find_one(
|
project_owner = db.users.find_one(
|
||||||
{"_id": project["user"], "subscriptions.email": "projects.commented"}
|
{"_id": project["user"], "subscriptions.email": "projects.commented"}
|
||||||
)
|
)
|
||||||
@ -209,7 +221,6 @@ def create_comment(user, id, data):
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return comment
|
|
||||||
|
|
||||||
|
|
||||||
def get_comments(user, id):
|
def get_comments(user, id):
|
||||||
@ -224,7 +235,14 @@ def get_comments(user, id):
|
|||||||
is_owner = user and (user.get("_id") == proj["user"])
|
is_owner = user and (user.get("_id") == proj["user"])
|
||||||
if not is_owner and proj["visibility"] != "public":
|
if not is_owner and proj["visibility"] != "public":
|
||||||
raise util.errors.Forbidden("This project is private")
|
raise util.errors.Forbidden("This project is private")
|
||||||
comments = list(db.comments.find({"object": id}))
|
query = {
|
||||||
|
"object": id,
|
||||||
|
"$or": [
|
||||||
|
{"moderationRequired": {"$ne": True}},
|
||||||
|
{"user": user["_id"] if user else None},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
comments = list(db.comments.find(query))
|
||||||
user_ids = list(map(lambda c: c["user"], comments))
|
user_ids = list(map(lambda c: c["user"], comments))
|
||||||
users = list(
|
users = list(
|
||||||
db.users.find({"_id": {"$in": user_ids}}, {"username": 1, "avatar": 1})
|
db.users.find({"_id": {"$in": user_ids}}, {"username": 1, "avatar": 1})
|
||||||
|
@ -241,9 +241,12 @@ def get_objects(user, username, path):
|
|||||||
if not util.can_view_project(user, project):
|
if not util.can_view_project(user, project):
|
||||||
raise util.errors.Forbidden("This project is private")
|
raise util.errors.Forbidden("This project is private")
|
||||||
|
|
||||||
|
query = {"project": project["_id"]}
|
||||||
|
if not util.can_edit_project(user, project):
|
||||||
|
query["moderationRequired"] = {"$ne": True}
|
||||||
objs = list(
|
objs = list(
|
||||||
db.objects.find(
|
db.objects.find(
|
||||||
{"project": project["_id"]},
|
query,
|
||||||
{
|
{
|
||||||
"createdAt": 1,
|
"createdAt": 1,
|
||||||
"name": 1,
|
"name": 1,
|
||||||
@ -295,6 +298,7 @@ def create_object(user, username, path, data):
|
|||||||
"storedName": data["storedName"],
|
"storedName": data["storedName"],
|
||||||
"createdAt": datetime.datetime.now(),
|
"createdAt": datetime.datetime.now(),
|
||||||
"type": "file",
|
"type": "file",
|
||||||
|
"moderationRequired": True,
|
||||||
}
|
}
|
||||||
if re.search(r"(.jpg)|(.png)|(.jpeg)|(.gif)$", data["storedName"].lower()):
|
if re.search(r"(.jpg)|(.png)|(.jpeg)|(.gif)$", data["storedName"].lower()):
|
||||||
obj["isImage"] = True
|
obj["isImage"] = True
|
||||||
@ -313,6 +317,7 @@ def create_object(user, username, path, data):
|
|||||||
uploads.blur_image(
|
uploads.blur_image(
|
||||||
"projects/" + str(project["_id"]) + "/" + data["storedName"], handle_cb
|
"projects/" + str(project["_id"]) + "/" + data["storedName"], handle_cb
|
||||||
)
|
)
|
||||||
|
util.send_moderation_request(user, "object", obj)
|
||||||
return obj
|
return obj
|
||||||
if data["type"] == "pattern":
|
if data["type"] == "pattern":
|
||||||
obj = {
|
obj = {
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
|
import datetime
|
||||||
|
from bson.objectid import ObjectId
|
||||||
from util import database, util
|
from util import database, util
|
||||||
from api import uploads
|
from api import uploads, objects, groups
|
||||||
|
|
||||||
|
|
||||||
def get_users(user):
|
def get_users(user):
|
||||||
@ -52,3 +54,82 @@ def get_groups(user):
|
|||||||
for group in groups:
|
for group in groups:
|
||||||
group["memberCount"] = db.users.count_documents({"groups": group["_id"]})
|
group["memberCount"] = db.users.count_documents({"groups": group["_id"]})
|
||||||
return {"groups": groups}
|
return {"groups": groups}
|
||||||
|
|
||||||
|
|
||||||
|
def get_moderation(user):
|
||||||
|
db = database.get_db()
|
||||||
|
if not util.is_root(user):
|
||||||
|
raise util.errors.Forbidden("Not allowed")
|
||||||
|
object_list = list(db.objects.find({"moderationRequired": True}))
|
||||||
|
for obj in object_list:
|
||||||
|
if obj["type"] == "file" and "storedName" in obj:
|
||||||
|
obj["url"] = uploads.get_presigned_url(
|
||||||
|
"projects/{0}/{1}".format(obj["project"], obj["storedName"])
|
||||||
|
)
|
||||||
|
comment_list = list(db.comments.find({"moderationRequired": True}))
|
||||||
|
user_list = list(db.users.find({"moderationRequired": True}, {"username": 1}))
|
||||||
|
group_list = list(db.groups.find({"moderationRequired": True}, {"name": 1}))
|
||||||
|
group_entry_list = list(db.groupEntries.find({"moderationRequired": True}))
|
||||||
|
for entry in group_entry_list:
|
||||||
|
for a in entry.get("attachments", []):
|
||||||
|
if a["type"] == "file" and "storedName" in a:
|
||||||
|
a["url"] = uploads.get_presigned_url(
|
||||||
|
"groups/{0}/{1}".format(entry["group"], a["storedName"])
|
||||||
|
)
|
||||||
|
group_topic_reply_list = list(
|
||||||
|
db.groupForumTopicReplies.find({"moderationRequired": True})
|
||||||
|
)
|
||||||
|
for reply in group_topic_reply_list:
|
||||||
|
for a in reply.get("attachments", []):
|
||||||
|
if a["type"] == "file" and "storedName" in a:
|
||||||
|
a["url"] = uploads.get_presigned_url(
|
||||||
|
"groups/{0}/topics/{1}/{2}".format(
|
||||||
|
reply["group"], reply["topic"], a["storedName"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"objects": object_list,
|
||||||
|
"comments": comment_list,
|
||||||
|
"users": user_list,
|
||||||
|
"groups": group_list,
|
||||||
|
"groupEntries": group_entry_list,
|
||||||
|
"groupForumTopicReplies": group_topic_reply_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def moderate(user, item_type, item_id, allowed):
|
||||||
|
db = database.get_db()
|
||||||
|
if not util.is_root(user):
|
||||||
|
raise util.errors.Forbidden("Not allowed")
|
||||||
|
if item_type not in [
|
||||||
|
"objects",
|
||||||
|
"comments",
|
||||||
|
"users",
|
||||||
|
"groups",
|
||||||
|
"groupEntries",
|
||||||
|
"groupForumTopicReplies",
|
||||||
|
]:
|
||||||
|
raise util.errors.BadRequest("Invalid item type")
|
||||||
|
item_id = ObjectId(item_id)
|
||||||
|
item = db[item_type].find_one({"_id": item_id})
|
||||||
|
# For now, handle only allowed moderations.
|
||||||
|
# Disallowed will be manually managed.
|
||||||
|
if item and allowed:
|
||||||
|
db[item_type].update_one(
|
||||||
|
{"_id": item_id},
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"moderationRequired": False,
|
||||||
|
"moderated": True,
|
||||||
|
"moderatedAt": datetime.datetime.now(),
|
||||||
|
"moderatedBy": user["_id"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if item_type == "comments":
|
||||||
|
objects.send_comment_notification(item_id)
|
||||||
|
if item_type == "groupEntries":
|
||||||
|
groups.send_entry_notification(item_id)
|
||||||
|
if item_type == "groupForumTopicReplies":
|
||||||
|
groups.send_forum_topic_reply_notification(item_id)
|
||||||
|
return {"success": True}
|
||||||
|
@ -121,6 +121,11 @@ def update(user, username, data):
|
|||||||
"$unset", {}
|
"$unset", {}
|
||||||
): # Also unset blurhash if removing avatar
|
): # Also unset blurhash if removing avatar
|
||||||
updater["$unset"]["avatarBlurHash"] = ""
|
updater["$unset"]["avatarBlurHash"] = ""
|
||||||
|
if "$set" in updater and (
|
||||||
|
"avatar" in data or "bio" in data or "website" in data or "username" in data
|
||||||
|
):
|
||||||
|
updater["$set"]["moderationRequired"] = True
|
||||||
|
util.send_moderation_request(user, "users", user)
|
||||||
db.users.update_one({"username": username}, updater)
|
db.users.update_one({"username": username}, updater)
|
||||||
return get(user, data.get("username", username))
|
return get(user, data.get("username", username))
|
||||||
|
|
||||||
|
30
api/app.py
30
api/app.py
@ -758,6 +758,36 @@ def root_groups():
|
|||||||
return util.jsonify(root.get_groups(util.get_user(required=True)))
|
return util.jsonify(root.get_groups(util.get_user(required=True)))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/root/moderation", methods=["GET"])
|
||||||
|
def root_moderation():
|
||||||
|
return util.jsonify(root.get_moderation(util.get_user(required=True)))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/root/moderation/<item_type>/<id>", methods=["PUT", "DELETE"])
|
||||||
|
def root_moderation_item(item_type, id):
|
||||||
|
return util.jsonify(
|
||||||
|
root.moderate(
|
||||||
|
util.get_user(required=True), item_type, id, request.method == "PUT"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
## REPORTS
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/reports", methods=["POST"])
|
||||||
|
@use_args(
|
||||||
|
{
|
||||||
|
"referrer": fields.Str(),
|
||||||
|
"url": fields.Str(required=True, validate=validate.Length(min=5)),
|
||||||
|
"description": fields.Str(required=True, validate=validate.Length(min=5)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def reports(args):
|
||||||
|
util.send_report_email(args)
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
## ActivityPub Support
|
## ActivityPub Support
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
import json
|
import json
|
||||||
import datetime
|
import datetime
|
||||||
from flask import request, Response
|
from flask import request, Response
|
||||||
@ -7,7 +8,7 @@ from cryptography.hazmat.primitives import serialization
|
|||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
from api import accounts
|
from api import accounts
|
||||||
from util import util
|
from util import util, mail
|
||||||
|
|
||||||
errors = werkzeug.exceptions
|
errors = werkzeug.exceptions
|
||||||
|
|
||||||
@ -92,6 +93,34 @@ def build_updater(obj, allowed_keys):
|
|||||||
return updater
|
return updater
|
||||||
|
|
||||||
|
|
||||||
|
def send_report_email(report):
|
||||||
|
if not report:
|
||||||
|
return
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to": os.environ.get("ADMIN_EMAIL"),
|
||||||
|
"subject": "{} report".format(os.environ.get("APP_NAME")),
|
||||||
|
"text": "A new report has been submitted: {0}".format(
|
||||||
|
json.dumps(report, indent=4)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_moderation_request(from_user, item_type, item):
|
||||||
|
if not from_user or not item_type or not item:
|
||||||
|
return
|
||||||
|
mail.send(
|
||||||
|
{
|
||||||
|
"to": os.environ.get("ADMIN_EMAIL"),
|
||||||
|
"subject": "{} moderation needed".format(os.environ.get("APP_NAME")),
|
||||||
|
"text": "New content has been added by {0} ({1}) and needs moderating: {2} ({3})".format(
|
||||||
|
from_user["username"], from_user["email"], item_type, item["_id"]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def generate_rsa_keypair():
|
def generate_rsa_keypair():
|
||||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
|
||||||
private_pem = private_key.private_bytes(
|
private_pem = private_key.private_bytes(
|
||||||
|
6
web/.env
6
web/.env
@ -9,3 +9,9 @@ VITE_IOS_APP_URL="https://apps.apple.com/gb/app/treadl/id1525094357"
|
|||||||
VITE_ANDROID_APP_URL="https://play.google.com/store/apps/details/Treadl?id=com.treadl"
|
VITE_ANDROID_APP_URL="https://play.google.com/store/apps/details/Treadl?id=com.treadl"
|
||||||
VITE_CONTACT_EMAIL="hello@treadl.com"
|
VITE_CONTACT_EMAIL="hello@treadl.com"
|
||||||
VITE_APP_NAME="Treadl"
|
VITE_APP_NAME="Treadl"
|
||||||
|
VITE_APP_DOMAIN="treadl.com"
|
||||||
|
VITE_ONLINE_SAFETY_URL="https://git.wilw.dev/wilw/treadl/wiki/Online-Safety"
|
||||||
|
VITE_FOLLOWING_ENABLED="false"
|
||||||
|
VITE_GROUP_DISCOVERY_ENABLED="false"
|
||||||
|
VITE_USER_DISCOVERY_ENABLED="false"
|
||||||
|
VITE_GROUPS_ENABLED="false"
|
||||||
|
@ -6,5 +6,11 @@ export const root = {
|
|||||||
},
|
},
|
||||||
getGroups (success, fail) {
|
getGroups (success, fail) {
|
||||||
api.authenticatedRequest('GET', '/root/groups', null, success, fail)
|
api.authenticatedRequest('GET', '/root/groups', null, success, fail)
|
||||||
|
},
|
||||||
|
getToBeModerated (success, fail) {
|
||||||
|
api.authenticatedRequest('GET', '/root/moderation', null, success, fail)
|
||||||
|
},
|
||||||
|
moderate (itemType, itemId, allowed, success, fail) {
|
||||||
|
api.authenticatedRequest(allowed ? 'PUT' : 'DELETE', `/root/moderation/${itemType}/${itemId}`, null, success, fail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -33,5 +33,8 @@ export const users = {
|
|||||||
},
|
},
|
||||||
getFeed (username, success, fail) {
|
getFeed (username, success, fail) {
|
||||||
api.authenticatedRequest('GET', `/users/${username}/feed`, null, success, fail)
|
api.authenticatedRequest('GET', `/users/${username}/feed`, null, success, fail)
|
||||||
|
},
|
||||||
|
submitReport (data, success, fail) {
|
||||||
|
api.unauthenticatedRequest('POST', '/reports', data, success, fail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,10 @@ import api from '../../api'
|
|||||||
import utils from '../../utils/utils.js'
|
import utils from '../../utils/utils.js'
|
||||||
import FollowButton from './FollowButton'
|
import FollowButton from './FollowButton'
|
||||||
|
|
||||||
|
const FOLLOWING_ENABLED = import.meta.env.VITE_FOLLOWING_ENABLED === 'true'
|
||||||
|
const GROUP_DISCOVERY_ENABLED = import.meta.env.VITE_GROUP_DISCOVERY_ENABLED === 'true'
|
||||||
|
const USER_DISCOVERY_ENABLED = import.meta.env.VITE_USER_DISCOVERY_ENABLED === 'true'
|
||||||
|
|
||||||
export default function ExploreCard ({ count, asCard }) {
|
export default function ExploreCard ({ count, asCard }) {
|
||||||
const [highlightProjects, setHighlightProjects] = useState([])
|
const [highlightProjects, setHighlightProjects] = useState([])
|
||||||
const [highlightUsers, setHighlightUsers] = useState([])
|
const [highlightUsers, setHighlightUsers] = useState([])
|
||||||
@ -24,8 +28,8 @@ export default function ExploreCard ({ count, asCard }) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
api.search.discover(count || 3, ({ highlightProjects, highlightUsers, highlightGroups }) => {
|
api.search.discover(count || 3, ({ highlightProjects, highlightUsers, highlightGroups }) => {
|
||||||
setHighlightProjects(highlightProjects)
|
setHighlightProjects(highlightProjects)
|
||||||
setHighlightUsers(highlightUsers)
|
if (USER_DISCOVERY_ENABLED) { setHighlightUsers(highlightUsers) }
|
||||||
setHighlightGroups(highlightGroups)
|
if (GROUP_DISCOVERY_ENABLED) { setHighlightGroups(highlightGroups) }
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
}, [userId])
|
}, [userId])
|
||||||
@ -57,38 +61,45 @@ export default function ExploreCard ({ count, asCard }) {
|
|||||||
</List>
|
</List>
|
||||||
</>}
|
</>}
|
||||||
|
|
||||||
<h4>Find others on {utils.appName()}</h4>
|
{USER_DISCOVERY_ENABLED &&
|
||||||
{loading && <BulletList />}
|
|
||||||
{highlightUsers?.length > 0 &&
|
|
||||||
<>
|
<>
|
||||||
<List relaxed>
|
<h4>Find others on {utils.appName()}</h4>
|
||||||
{highlightUsers?.map(u =>
|
{loading && <BulletList />}
|
||||||
<List.Item key={u._id}>
|
{highlightUsers?.length > 0 &&
|
||||||
<List.Content>
|
<>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<List relaxed>
|
||||||
<UserChip user={u} className='umami--click--discover-user' />
|
{highlightUsers?.map(u =>
|
||||||
<div>
|
<List.Item key={u._id}>
|
||||||
<FollowButton compact targetUser={u} onChange={f => updateFollowing(u._id, f)} />
|
<List.Content>
|
||||||
</div>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
</div>
|
<UserChip user={u} className='umami--click--discover-user' />
|
||||||
</List.Content>
|
<div>
|
||||||
</List.Item>
|
{FOLLOWING_ENABLED &&
|
||||||
)}
|
<FollowButton compact targetUser={u} onChange={f => updateFollowing(u._id, f)} />}
|
||||||
</List>
|
</div>
|
||||||
|
</div>
|
||||||
|
</List.Content>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</>}
|
||||||
</>}
|
</>}
|
||||||
|
|
||||||
<h4>Discover a group</h4>
|
{GROUP_DISCOVERY_ENABLED &&
|
||||||
{loading && <BulletList />}
|
|
||||||
{highlightGroups?.length > 0 &&
|
|
||||||
<>
|
<>
|
||||||
{highlightGroups?.map(g =>
|
<h4>Discover a group</h4>
|
||||||
<div key={g._id} style={{ display: 'flex', alignItems: 'center', marginBottom: 10 }}>
|
{loading && <BulletList />}
|
||||||
{g.imageUrl
|
{highlightGroups?.length > 0 &&
|
||||||
? <div style={{ display: 'inline-block', width: '30px', height: '30px', overflow: 'hidden', borderRadius: 7, backgroundImage: `url(${g.imageUrl})`, backgroundSize: 'cover', backgroundPosition: 'center' }} />
|
<>
|
||||||
: <div style={{ width: '30px', textAlign: 'center' }}><Icon name='users' size='large' verticalAlign='middle' /></div>}
|
{highlightGroups?.map(g =>
|
||||||
<Link to={`/groups/${g._id}`} style={{ marginLeft: 10 }}>{g.name}</Link>
|
<div key={g._id} style={{ display: 'flex', alignItems: 'center', marginBottom: 10 }}>
|
||||||
</div>
|
{g.imageUrl
|
||||||
)}
|
? <div style={{ display: 'inline-block', width: '30px', height: '30px', overflow: 'hidden', borderRadius: 7, backgroundImage: `url(${g.imageUrl})`, backgroundSize: 'cover', backgroundPosition: 'center' }} />
|
||||||
|
: <div style={{ width: '30px', textAlign: 'center' }}><Icon name='users' size='large' verticalAlign='middle' /></div>}
|
||||||
|
<Link to={`/groups/${g._id}`} style={{ marginLeft: 10 }}>{g.name}</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>}
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -7,6 +7,8 @@ import api from '../../api'
|
|||||||
import UserChip from './UserChip'
|
import UserChip from './UserChip'
|
||||||
import DiscoverCard from './DiscoverCard'
|
import DiscoverCard from './DiscoverCard'
|
||||||
|
|
||||||
|
const FOLLOWING_ENABLED = import.meta.env.VITE_FOLLOWING_ENABLED === 'true'
|
||||||
|
|
||||||
export default function Feed () {
|
export default function Feed () {
|
||||||
const [feed, setFeed] = useState([])
|
const [feed, setFeed] = useState([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@ -17,7 +19,7 @@ export default function Feed () {
|
|||||||
const username = user?.username
|
const username = user?.username
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!username) return
|
if (!username || !FOLLOWING_ENABLED) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
api.users.getFeed(username, result => {
|
api.users.getFeed(username, result => {
|
||||||
setFeed(result.feed)
|
setFeed(result.feed)
|
||||||
@ -28,41 +30,44 @@ export default function Feed () {
|
|||||||
return (
|
return (
|
||||||
<Card fluid>
|
<Card fluid>
|
||||||
<Card.Content style={{ maxHeight: 500, overflowY: 'scroll' }}>
|
<Card.Content style={{ maxHeight: 500, overflowY: 'scroll' }}>
|
||||||
<Card.Header style={{ marginBottom: 10 }}>Recent activity</Card.Header>
|
{FOLLOWING_ENABLED &&
|
||||||
{loading &&
|
<>
|
||||||
<div>
|
<Card.Header style={{ marginBottom: 10 }}>Recent activity</Card.Header>
|
||||||
<BulletList />
|
{loading &&
|
||||||
</div>}
|
<div>
|
||||||
{!loading && !feed?.length &&
|
<BulletList />
|
||||||
<div>
|
</div>}
|
||||||
<p style={{ background: 'rgba(0,0,0,0.05)', padding: 5, borderRadius: 5 }}><Icon name='feed' /> Your feed is empty. You can <Link to='/explore'>follow others</Link> to stay up-to-date.</p>
|
{!loading && !feed?.length &&
|
||||||
<DiscoverCard />
|
<div>
|
||||||
</div>}
|
<p style={{ background: 'rgba(0,0,0,0.05)', padding: 5, borderRadius: 5 }}><Icon name='feed' /> Your feed is empty. You can <Link to='/explore'>follow others</Link> to stay up-to-date.</p>
|
||||||
{!loading && feed?.map(item =>
|
</div>}
|
||||||
<div key={item._id} style={{ display: 'flex', alignItems: 'center', marginBottom: 10 }}>
|
{!loading && feed?.map(item =>
|
||||||
<div style={{ marginRight: 10 }}>
|
<div key={item._id} style={{ display: 'flex', alignItems: 'center', marginBottom: 10 }}>
|
||||||
<UserChip user={item.userObject} avatarOnly />
|
<div style={{ marginRight: 10 }}>
|
||||||
</div>
|
<UserChip user={item.userObject} avatarOnly />
|
||||||
<div>
|
</div>
|
||||||
<span style={{ marginRight: 5 }}><Link to={`/${item.userObject?.username}`}>{item.userObject?.username}</Link></span>
|
<div>
|
||||||
{item.feedType === 'comment' &&
|
<span style={{ marginRight: 5 }}><Link to={`/${item.userObject?.username}`}>{item.userObject?.username}</Link></span>
|
||||||
<span>wrote a comment
|
{item.feedType === 'comment' &&
|
||||||
{item.projectObject?.userObject && item.object &&
|
<span>wrote a comment
|
||||||
<span> on <Link to={`/${item.projectObject.userObject.username}/${item.projectObject.path}/${item.object}`}>an item</Link> in {item.projectObject.name}</span>}
|
{item.projectObject?.userObject && item.object &&
|
||||||
</span>}
|
<span> on <Link to={`/${item.projectObject.userObject.username}/${item.projectObject.path}/${item.object}`}>an item</Link> in {item.projectObject.name}</span>}
|
||||||
{item.feedType === 'object' &&
|
</span>}
|
||||||
<span>added an item
|
{item.feedType === 'object' &&
|
||||||
{item.projectObject?.userObject &&
|
<span>added an item
|
||||||
<span> to <Link to={`/${item.projectObject.userObject.username}/${item.projectObject.path}`}>{item.projectObject.name}</Link></span>}
|
{item.projectObject?.userObject &&
|
||||||
</span>}
|
<span> to <Link to={`/${item.projectObject.userObject.username}/${item.projectObject.path}`}>{item.projectObject.name}</Link></span>}
|
||||||
{item.feedType === 'project' &&
|
</span>}
|
||||||
<span>started a new project
|
{item.feedType === 'project' &&
|
||||||
{item.userObject && item.path &&
|
<span>started a new project
|
||||||
<span>: <Link to={`/${item.userObject.username}/${item.path}`}>{item.name}</Link></span>}
|
{item.userObject && item.path &&
|
||||||
</span>}
|
<span>: <Link to={`/${item.userObject.username}/${item.path}`}>{item.name}</Link></span>}
|
||||||
</div>
|
</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
</>}
|
||||||
|
<DiscoverCard />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
@ -11,6 +11,7 @@ import api from '../../api'
|
|||||||
import UserChip from './UserChip'
|
import UserChip from './UserChip'
|
||||||
import NewFeedMessage from './NewFeedMessage'
|
import NewFeedMessage from './NewFeedMessage'
|
||||||
import FormattedMessage from './FormattedMessage'
|
import FormattedMessage from './FormattedMessage'
|
||||||
|
import ReportLink from './ReportLink'
|
||||||
|
|
||||||
const StyledMessage = styled.div`
|
const StyledMessage = styled.div`
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
@ -58,13 +59,20 @@ const FeedMessage = connect(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 20 }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<UserChip user={post.authorUser} meta={moment(post.createdAt).fromNow()} />
|
<div style={{ display: 'flex', alignItems: 'end', justifyContent: 'space-between' }}>
|
||||||
{canDelete() &&
|
<div>
|
||||||
<Dropdown icon='ellipsis horizontal' style={{ marginLeft: 10 }}>
|
<UserChip user={post.authorUser} meta={moment(post.createdAt).fromNow()} />
|
||||||
<Dropdown.Menu>
|
{canDelete() &&
|
||||||
<Dropdown.Item icon='trash' content='Delete' onClick={() => deletePost(post._id)} />
|
<Dropdown icon='ellipsis horizontal' style={{ marginLeft: 10 }}>
|
||||||
</Dropdown.Menu>
|
<Dropdown.Menu>
|
||||||
</Dropdown>}
|
<Dropdown.Item icon='trash' content='Delete' onClick={() => deletePost(post._id)} />
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ReportLink style={{ fontSize: 10 }} referrer={`/comments/${post._id}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 10 }}>
|
<div style={{ marginTop: 10 }}>
|
||||||
<StyledMessage>
|
<StyledMessage>
|
||||||
@ -82,6 +90,7 @@ const FeedMessage = connect(
|
|||||||
<Button key={a.url} as='a' href={a.url} target='_blank' rel='noopener noreferrer' size='tiny' icon='download' content={a.name} style={{ margin: 4 }} />
|
<Button key={a.url} as='a' href={a.url} target='_blank' rel='noopener noreferrer' size='tiny' icon='download' content={a.name} style={{ margin: 4 }} />
|
||||||
)}
|
)}
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{!post.inReplyTo &&
|
{!post.inReplyTo &&
|
||||||
<div style={{ padding: 10 }}>
|
<div style={{ padding: 10 }}>
|
||||||
{utils.hasGroupPermission(user, group, 'postNoticeboard') && replyingTo !== post._id && !post.inReplyTo && onReplyPosted &&
|
{utils.hasGroupPermission(user, group, 'postNoticeboard') && replyingTo !== post._id && !post.inReplyTo && onReplyPosted &&
|
||||||
|
@ -41,8 +41,13 @@ export default function Footer () {
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<Icon name='file alternate outline' />
|
<Icon name='file alternate outline' />
|
||||||
<Link to='terms-of-use'>Terms of Use</Link>
|
<Link to='/terms-of-use'>Terms of Use</Link>
|
||||||
</p>
|
</p>
|
||||||
|
{import.meta.env.VITE_ONLINE_SAFETY_URL &&
|
||||||
|
<p>
|
||||||
|
<Icon name='file alternate outline' />
|
||||||
|
<a href={import.meta.env.VITE_ONLINE_SAFETY_URL} target='_blank' rel='noopener noreferrer'>Online Safety</a>
|
||||||
|
</p>}
|
||||||
{import.meta.env.VITE_STATUS_URL &&
|
{import.meta.env.VITE_STATUS_URL &&
|
||||||
<p>
|
<p>
|
||||||
<a target='_blank' rel='noopener noreferrer' href={import.meta.env.VITE_STATUS_URL}>
|
<a target='_blank' rel='noopener noreferrer' href={import.meta.env.VITE_STATUS_URL}>
|
||||||
|
@ -12,6 +12,8 @@ import UserChip from './UserChip'
|
|||||||
import SupporterBadge from './SupporterBadge'
|
import SupporterBadge from './SupporterBadge'
|
||||||
import SearchBar from './SearchBar'
|
import SearchBar from './SearchBar'
|
||||||
|
|
||||||
|
const GROUPS_ENABLED = import.meta.env.VITE_GROUPS_ENABLED === 'true'
|
||||||
|
|
||||||
const StyledNavBar = styled.div`
|
const StyledNavBar = styled.div`
|
||||||
height:60px;
|
height:60px;
|
||||||
background: linen;
|
background: linen;
|
||||||
@ -61,23 +63,24 @@ export default function NavBar () {
|
|||||||
<Link to={user ? '/projects' : '/'}><img alt={`${utils.appName()} logo`} src={logo} className='logo' /></Link>
|
<Link to={user ? '/projects' : '/'}><img alt={`${utils.appName()} logo`} src={logo} className='logo' /></Link>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Menu secondary>
|
<Menu secondary>
|
||||||
<Menu.Item className='above-mobile' as={Link} to='/projects' name='projects' active={location.pathname === '/projects'} />
|
<Menu.Item className='above-mobile' as={Link} to='/projects' name='my projects' active={location.pathname === '/projects'} />
|
||||||
<Menu.Item className='above-mobile' as={Link} to='/explore' name='explore' active={location.pathname === '/explore'} />
|
<Menu.Item className='above-mobile' as={Link} to='/explore' name='explore' active={location.pathname === '/explore'} />
|
||||||
<Menu.Item className='above-mobile' active={location.pathname.startsWith('/groups')} name='Groups'>
|
{GROUPS_ENABLED &&
|
||||||
<Dropdown
|
<Menu.Item className='above-mobile' active={location.pathname.startsWith('/groups')} name='Groups'>
|
||||||
pointing='top left' icon={null}
|
<Dropdown
|
||||||
trigger={<span>Groups</span>}
|
pointing='top left' icon={null}
|
||||||
>
|
trigger={<span>Groups</span>}
|
||||||
<Dropdown.Menu>
|
>
|
||||||
<Dropdown.Header icon='users' content='Your groups' />
|
<Dropdown.Menu>
|
||||||
{groups.map(g =>
|
<Dropdown.Header icon='users' content='Your groups' />
|
||||||
<Dropdown.Item key={g._id} as={Link} to={`/groups/${g._id}`} content={g.name} />
|
{groups.map(g =>
|
||||||
)}
|
<Dropdown.Item key={g._id} as={Link} to={`/groups/${g._id}`} content={g.name} />
|
||||||
<Dropdown.Divider />
|
)}
|
||||||
<Dropdown.Item as={Link} to='/groups/new' icon='plus' content='Create a new group' />
|
<Dropdown.Divider />
|
||||||
</Dropdown.Menu>
|
<Dropdown.Item as={Link} to='/groups/new' icon='plus' content='Create a new group' />
|
||||||
</Dropdown>
|
</Dropdown.Menu>
|
||||||
</Menu.Item>
|
</Dropdown>
|
||||||
|
</Menu.Item>}
|
||||||
{user && !isSupporter && (import.meta.env.VITE_PATREON_URL || import.meta.env.VITE_KOFI_URL) &&
|
{user && !isSupporter && (import.meta.env.VITE_PATREON_URL || import.meta.env.VITE_KOFI_URL) &&
|
||||||
<Menu.Item className='above-mobile'>
|
<Menu.Item className='above-mobile'>
|
||||||
<Popup
|
<Popup
|
||||||
|
29
web/src/components/includes/ReportLink.jsx
Normal file
29
web/src/components/includes/ReportLink.jsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
export const LinkContainer = styled.span`
|
||||||
|
display: inline-block;
|
||||||
|
border: 1px solid rgb(240,240,240);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 5px;
|
||||||
|
${p => p.marginTop ? 'margin-top: 10px;' : ''}
|
||||||
|
${p => p.marginLeft ? 'margin-left: 10px;' : ''}
|
||||||
|
${p => p.marginBottom ? 'margin-bottom: 10px;' : ''}
|
||||||
|
color: #1e70bf;
|
||||||
|
.emoji{
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
function ReportLink ({ className, text, referrer, marginTop, marginLeft, marginBottom, style }) {
|
||||||
|
return (
|
||||||
|
<LinkContainer style={style} marginTop={marginTop} marginLeft={marginLeft} marginBottom={marginBottom}>
|
||||||
|
<Link to={`/report?referrer=${referrer}`} className={className}>
|
||||||
|
<span className='emoji'>📢</span>
|
||||||
|
{text || 'Report'}
|
||||||
|
</Link>
|
||||||
|
</LinkContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReportLink
|
@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
import { Popup, Loader, Grid, List, Input, Icon } from 'semantic-ui-react'
|
import { Popup, Loader, Grid, List, Input } from 'semantic-ui-react'
|
||||||
import { useDispatch, useSelector } from 'react-redux'
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useDebouncedCallback } from 'use-debounce'
|
import { useDebouncedCallback } from 'use-debounce'
|
||||||
@ -10,6 +10,8 @@ import api from '../../api'
|
|||||||
|
|
||||||
import UserChip from './UserChip'
|
import UserChip from './UserChip'
|
||||||
|
|
||||||
|
const USER_DISCOVERY_ENABLED = import.meta.env.VITE_USER_DISCOVERY_ENABLED === 'true'
|
||||||
|
|
||||||
const StyledSearchBar = styled.div`
|
const StyledSearchBar = styled.div`
|
||||||
background-color:rgba(0,0,0,0.1);
|
background-color:rgba(0,0,0,0.1);
|
||||||
padding:5px;
|
padding:5px;
|
||||||
@ -106,7 +108,7 @@ export default function SearchBar () {
|
|||||||
})}
|
})}
|
||||||
</List>
|
</List>
|
||||||
</Grid.Column>}
|
</Grid.Column>}
|
||||||
{searchResults?.users?.length > 0 &&
|
{USER_DISCOVERY_ENABLED && searchResults?.users?.length > 0 &&
|
||||||
<Grid.Column width={6}>
|
<Grid.Column width={6}>
|
||||||
<h4>Users</h4>
|
<h4>Users</h4>
|
||||||
{searchResults?.users?.map(u =>
|
{searchResults?.users?.map(u =>
|
||||||
@ -115,7 +117,7 @@ export default function SearchBar () {
|
|||||||
</Grid.Column>}
|
</Grid.Column>}
|
||||||
{(searchResults?.projects.length > 0 || searchResults.groups.length > 0) &&
|
{(searchResults?.projects.length > 0 || searchResults.groups.length > 0) &&
|
||||||
<Grid.Column width={10}>
|
<Grid.Column width={10}>
|
||||||
<h4>Projects & groups</h4>
|
<h4>Projects</h4>
|
||||||
<List>
|
<List>
|
||||||
{searchResults?.projects?.map(p =>
|
{searchResults?.projects?.map(p =>
|
||||||
<List.Item key={p._id}>
|
<List.Item key={p._id}>
|
||||||
@ -126,15 +128,6 @@ export default function SearchBar () {
|
|||||||
</List.Content>
|
</List.Content>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
{searchResults?.groups?.map(g =>
|
|
||||||
<List.Item key={g._id}>
|
|
||||||
<List.Icon name='users' size='large' verticalAlign='middle' />
|
|
||||||
<List.Content>
|
|
||||||
<List.Header as={Link} to={`/groups/${g._id}`}>{g.name}</List.Header>
|
|
||||||
<List.Description><small>{g.closed ? <span><Icon name='lock' /> Closed group</span> : <span>Open group</span>}</small></List.Description>
|
|
||||||
</List.Content>
|
|
||||||
</List.Item>
|
|
||||||
)}
|
|
||||||
</List>
|
</List>
|
||||||
</Grid.Column>}
|
</Grid.Column>}
|
||||||
</Grid>)}
|
</Grid>)}
|
||||||
|
@ -17,6 +17,8 @@ import PatternLoader from '../includes/PatternLoader'
|
|||||||
import Tour from '../includes/Tour'
|
import Tour from '../includes/Tour'
|
||||||
import Feed from '../includes/Feed'
|
import Feed from '../includes/Feed'
|
||||||
|
|
||||||
|
const GROUPS_ENABLED = import.meta.env.VITE_GROUPS_ENABLED === 'true'
|
||||||
|
|
||||||
function Home () {
|
function Home () {
|
||||||
const [runJoyride, setRunJoyride] = useState(false)
|
const [runJoyride, setRunJoyride] = useState(false)
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
@ -90,38 +92,39 @@ function Home () {
|
|||||||
|
|
||||||
<Feed />
|
<Feed />
|
||||||
|
|
||||||
<Card fluid className='joyride-groups' style={{ opacity: 0.8 }}>
|
{GROUPS_ENABLED &&
|
||||||
<Card.Content>
|
<Card fluid className='joyride-groups' style={{ opacity: 0.8 }}>
|
||||||
<Card.Header>Your groups</Card.Header>
|
<Card.Content>
|
||||||
|
<Card.Header>Your groups</Card.Header>
|
||||||
|
|
||||||
{(loadingGroups && !groups?.length)
|
{(loadingGroups && !groups?.length)
|
||||||
? (
|
? (
|
||||||
<div>
|
<div>
|
||||||
<BulletList />
|
<BulletList />
|
||||||
<BulletList />
|
<BulletList />
|
||||||
</div>)
|
</div>)
|
||||||
: (groups?.length > 0
|
: (groups?.length > 0
|
||||||
? (
|
? (
|
||||||
<List relaxed>
|
<List relaxed>
|
||||||
{groups.map(g =>
|
{groups.map(g =>
|
||||||
<List.Item key={g._id}>
|
<List.Item key={g._id}>
|
||||||
<List.Icon name='users' size='large' verticalAlign='middle' />
|
<List.Icon name='users' size='large' verticalAlign='middle' />
|
||||||
<List.Content>
|
<List.Content>
|
||||||
<List.Header as={Link} to={`/groups/${g._id}`}>{g.name}</List.Header>
|
<List.Header as={Link} to={`/groups/${g._id}`}>{g.name}</List.Header>
|
||||||
<List.Description>{utils.isGroupAdmin(user, g) ? 'Administrator' : 'Member'}</List.Description>
|
<List.Description>{utils.isGroupAdmin(user, g) ? 'Administrator' : 'Member'}</List.Description>
|
||||||
</List.Content>
|
</List.Content>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
</List>)
|
</List>)
|
||||||
: (
|
: (
|
||||||
<Card.Description>
|
<Card.Description>
|
||||||
Groups enable you to join or build communities of weavers and makers with similar interests.
|
Groups enable you to join or build communities of weavers and makers with similar interests.
|
||||||
</Card.Description>))}
|
</Card.Description>))}
|
||||||
<Divider hidden />
|
<Divider hidden />
|
||||||
<Button className='joyride-createGroup' fluid size='small' icon='plus' content='Create a new group' as={Link} to='/groups/new' />
|
<Button className='joyride-createGroup' fluid size='small' icon='plus' content='Create a new group' as={Link} to='/groups/new' />
|
||||||
<HelpLink link='/docs/groups' text='Learn more about groups' marginTop />
|
<HelpLink link='/docs/groups' text='Learn more about groups' marginTop />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card>
|
</Card>}
|
||||||
|
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
|
|
||||||
|
67
web/src/components/main/Report.jsx
Normal file
67
web/src/components/main/Report.jsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Helmet } from 'react-helmet'
|
||||||
|
import { Container, Message, Divider, Button, Form, FormField } from 'semantic-ui-react'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import api from '../../api'
|
||||||
|
|
||||||
|
const APP_NAME = import.meta.env.VITE_APP_NAME
|
||||||
|
|
||||||
|
export default function Report () {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const referrerString = searchParams?.get('referrer') || ''
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
const [referrer] = useState(referrerString)
|
||||||
|
const [url, setUrl] = useState(referrerString)
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
setLoading(true)
|
||||||
|
api.users.submitReport({ referrer, url, description }, () => {
|
||||||
|
setLoading(false)
|
||||||
|
setSuccess(true)
|
||||||
|
}, (err) => {
|
||||||
|
setLoading(false)
|
||||||
|
toast.error(err.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container style={{ marginTop: '40px' }}>
|
||||||
|
<Helmet title='Report Content' />
|
||||||
|
<h2>Make a report or complaint</h2>
|
||||||
|
<p>Online safety is hugely important for communities like {APP_NAME}. If you see something (e.g. user-generated content) that you consider to be potentially harmful or abusive to you or to others, please report it to us using the form below.</p>
|
||||||
|
<p>You can also use the form to submit a complaint about content, our duty of care, our handling of previous complaints, or anything else relevant to online safety or the operation of {APP_NAME}.</p>
|
||||||
|
<p>We review all reports and will take appropriate action as soon as possible.</p>
|
||||||
|
|
||||||
|
{import.meta.env.VITE_ONLINE_SAFETY_URL &&
|
||||||
|
<Message>Read more about <a href={import.meta.env.VITE_ONLINE_SAFETY_URL} target='_blank' rel='noopener noreferrer'>Online Safety on {import.meta.env.VITE_APP_NAME}</a>.</Message>}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{success
|
||||||
|
? (
|
||||||
|
<div>
|
||||||
|
<h3>Thank you for your report</h3>
|
||||||
|
<p>Your report has been submitted. We will review it as soon as possible.</p>
|
||||||
|
</div>)
|
||||||
|
: (
|
||||||
|
<Form>
|
||||||
|
<FormField>
|
||||||
|
<label>URL or description of the content you saw</label>
|
||||||
|
<p><small>If the information here is too vague, we may not be able to find what you are referring to. If we have auto-filled it for you, you can leave it as it is.</small></p>
|
||||||
|
<input placeholder='https://www...' value={url} onChange={e => setUrl(e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField>
|
||||||
|
<label>Please describe the nature of your report or complaint</label>
|
||||||
|
<p><small>For example: What is the issue? Why do you think the content is harmful or abusive? What is your complaint?</small></p>
|
||||||
|
<textarea placeholder='Please provide as much detail as possible.' value={description} onChange={e => setDescription(e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<p>By submitting, you acknowledge that we have not asked for your contact details and that we therefore will not be able to provide any updates about this complaint to you.</p>
|
||||||
|
<Button loading={loading} color='teal' onClick={submit}>Submit</Button>
|
||||||
|
</Form>)}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
@ -9,6 +9,7 @@ import utils from '../../../utils/utils'
|
|||||||
import UserChip from '../../includes/UserChip'
|
import UserChip from '../../includes/UserChip'
|
||||||
import NewFeedMessage from '../../includes/NewFeedMessage'
|
import NewFeedMessage from '../../includes/NewFeedMessage'
|
||||||
import FormattedMessage from '../../includes/FormattedMessage'
|
import FormattedMessage from '../../includes/FormattedMessage'
|
||||||
|
import ReportLink from '../../includes/ReportLink'
|
||||||
import StartChatImage from '../../../images/startchat.png'
|
import StartChatImage from '../../../images/startchat.png'
|
||||||
|
|
||||||
export default function ForumTopic () {
|
export default function ForumTopic () {
|
||||||
@ -101,10 +102,11 @@ export default function ForumTopic () {
|
|||||||
<Segment key={reply._id}>
|
<Segment key={reply._id}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||||
<UserChip user={reply.author} compact />
|
<UserChip user={reply.author} compact />
|
||||||
<div style={{ fontSize: 12, color: 'rgb(150,150,150)', display: 'flex' }}>
|
<div style={{ fontSize: 12, color: 'rgb(150,150,150)', display: 'flex', alignItems: 'center' }}>
|
||||||
<div>{new Date(reply.createdAt).toLocaleString()}</div>
|
<div>{new Date(reply.createdAt).toLocaleString()}</div>
|
||||||
{(utils.isGroupAdmin(user, group) || user._id === reply.author._id) &&
|
{(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 role='button' style={{ textDecoration: 'underline', cursor: 'pointer', marginLeft: 10 }} onClick={() => handleDeleteReply(reply._id)}>Delete</div>}
|
||||||
|
<ReportLink referrer={`/topicReplies/${reply._id}`} style={{ fontSize: 11 }} marginLeft='10px' />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FormattedMessage content={reply.content} />
|
<FormattedMessage content={reply.content} />
|
||||||
|
@ -10,6 +10,7 @@ import api from '../../../api'
|
|||||||
|
|
||||||
import UserChip from '../../includes/UserChip'
|
import UserChip from '../../includes/UserChip'
|
||||||
import HelpLink from '../../includes/HelpLink'
|
import HelpLink from '../../includes/HelpLink'
|
||||||
|
import ReportLink from '../../includes/ReportLink'
|
||||||
|
|
||||||
function Group () {
|
function Group () {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
@ -131,6 +132,7 @@ function Group () {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<HelpLink link='/docs/groups#a-tour-around-your-new-group' />
|
<HelpLink link='/docs/groups#a-tour-around-your-new-group' />
|
||||||
|
<ReportLink referrer={`/groups/${group._id}`} text='Report this group' marginTop='5px' />
|
||||||
|
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
<Grid.Column computer={12}>
|
<Grid.Column computer={12}>
|
||||||
|
@ -18,6 +18,8 @@ const PERMISSIONS = [
|
|||||||
{ name: 'postForumTopicReplies', label: 'Allow members to reply to forum topics' }
|
{ name: 'postForumTopicReplies', label: 'Allow members to reply to forum topics' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const GROUP_DISCOVERY_ENABLED = import.meta.env.VITE_GROUP_DISCOVERY_ENABLED === 'true'
|
||||||
|
|
||||||
function Settings () {
|
function Settings () {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
@ -87,7 +89,8 @@ function Settings () {
|
|||||||
<div style={{ marginLeft: 63, color: 'rgb(150,150,150)', marginBottom: 20 }}>
|
<div style={{ marginLeft: 63, color: 'rgb(150,150,150)', marginBottom: 20 }}>
|
||||||
<p>In a closed group, new members must be invited or approved to join.</p>
|
<p>In a closed group, new members must be invited or approved to join.</p>
|
||||||
</div>
|
</div>
|
||||||
<Form.Checkbox toggle checked={group.advertised} label='Publicise this group' onChange={(e, c) => dispatch(actions.groups.updateGroup(group._id, { advertised: c.checked }))} />
|
{GROUP_DISCOVERY_ENABLED &&
|
||||||
|
<Form.Checkbox toggle checked={group.advertised} label='Publicise this group' onChange={(e, c) => dispatch(actions.groups.updateGroup(group._id, { advertised: c.checked }))} />}
|
||||||
<div style={{ marginLeft: 63, color: 'rgb(150,150,150)' }}>
|
<div style={{ marginLeft: 63, color: 'rgb(150,150,150)' }}>
|
||||||
<p>If a group is publicised, it may allow other people to discover the group.</p>
|
<p>If a group is publicised, it may allow other people to discover the group.</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,6 +14,7 @@ import RichTextViewer from '../../includes/RichTextViewer'
|
|||||||
import DraftPreview from './objects/DraftPreview'
|
import DraftPreview from './objects/DraftPreview'
|
||||||
import NewFeedMessage from '../../includes/NewFeedMessage'
|
import NewFeedMessage from '../../includes/NewFeedMessage'
|
||||||
import FeedMessage from '../../includes/FeedMessage'
|
import FeedMessage from '../../includes/FeedMessage'
|
||||||
|
import ReportLink from '../../includes/ReportLink'
|
||||||
|
|
||||||
function ObjectViewer () {
|
function ObjectViewer () {
|
||||||
const [editingName, setEditingName] = useState(false)
|
const [editingName, setEditingName] = useState(false)
|
||||||
@ -96,7 +97,8 @@ function ObjectViewer () {
|
|||||||
<>
|
<>
|
||||||
<Helmet title={`${object.name || 'Project Item'} | ${project?.name || 'Project'}`} />
|
<Helmet title={`${object.name || 'Project Item'} | ${project?.name || 'Project'}`} />
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'end' }}>
|
<div style={{ display: 'flex', justifyContent: 'end', alignItems: 'center' }}>
|
||||||
|
|
||||||
{object.type === 'pattern' && (utils.canEditProject(user, project) || project.openSource || object.previewUrl) &&
|
{object.type === 'pattern' && (utils.canEditProject(user, project) || project.openSource || object.previewUrl) &&
|
||||||
<>
|
<>
|
||||||
<Dropdown direction='left' icon={null} trigger={<Button size='tiny' secondary icon='download' content='Download pattern' loading={downloading} disabled={downloading} />}>
|
<Dropdown direction='left' icon={null} trigger={<Button size='tiny' secondary icon='download' content='Download pattern' loading={downloading} disabled={downloading} />}>
|
||||||
@ -138,6 +140,9 @@ function ObjectViewer () {
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|
||||||
</>}
|
</>}
|
||||||
|
|
||||||
|
{object.type === 'file' &&
|
||||||
|
<ReportLink referrer={`/objects/${object._id}`} marginLeft='5px' />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editingName
|
{editingName
|
||||||
|
@ -9,6 +9,7 @@ import api from '../../../api'
|
|||||||
|
|
||||||
import UserChip from '../../includes/UserChip'
|
import UserChip from '../../includes/UserChip'
|
||||||
import HelpLink from '../../includes/HelpLink'
|
import HelpLink from '../../includes/HelpLink'
|
||||||
|
import ReportLink from '../../includes/ReportLink'
|
||||||
import ObjectCreator from '../../includes/ObjectCreator'
|
import ObjectCreator from '../../includes/ObjectCreator'
|
||||||
import FormattedMessage from '../../includes/FormattedMessage'
|
import FormattedMessage from '../../includes/FormattedMessage'
|
||||||
|
|
||||||
@ -139,6 +140,8 @@ function Project () {
|
|||||||
</Card>}
|
</Card>}
|
||||||
|
|
||||||
<HelpLink link='/docs/projects' />
|
<HelpLink link='/docs/projects' />
|
||||||
|
{!utils.canEditProject(user, project) &&
|
||||||
|
<ReportLink referrer={`/projects/${project._id}`} text='Report this project' marginTop='5px' />}
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { Table, Container } from 'semantic-ui-react'
|
import { Table, Container, Button } from 'semantic-ui-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
@ -7,6 +7,7 @@ import api from '../../../api'
|
|||||||
|
|
||||||
function Root () {
|
function Root () {
|
||||||
const [users, setUsers] = useState([])
|
const [users, setUsers] = useState([])
|
||||||
|
const [toModerate, setToModerate] = useState([])
|
||||||
|
|
||||||
const { user } = useSelector(state => {
|
const { user } = useSelector(state => {
|
||||||
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0]
|
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0]
|
||||||
@ -18,6 +19,16 @@ function Root () {
|
|||||||
api.root.getUsers(({ users }) => {
|
api.root.getUsers(({ users }) => {
|
||||||
setUsers(users)
|
setUsers(users)
|
||||||
})
|
})
|
||||||
|
api.root.getToBeModerated(({ objects, comments, users, groups, groupEntries, groupForumTopicReplies }) => {
|
||||||
|
const newToModerate = []
|
||||||
|
objects.forEach(o => newToModerate.push({ ...o, itemType: 'objects' }))
|
||||||
|
comments.forEach(c => newToModerate.push({ ...c, itemType: 'comments' }))
|
||||||
|
users.forEach(u => newToModerate.push({ ...u, itemType: 'users' }))
|
||||||
|
groups.forEach(g => newToModerate.push({ ...g, itemType: 'groups' }))
|
||||||
|
groupEntries.forEach(e => newToModerate.push({ ...e, itemType: 'groupEntries' }))
|
||||||
|
groupForumTopicReplies.forEach(r => newToModerate.push({ ...r, itemType: 'groupForumTopicReplies' }))
|
||||||
|
setToModerate(newToModerate)
|
||||||
|
})
|
||||||
}, [user])
|
}, [user])
|
||||||
|
|
||||||
const testSentry = () => {
|
const testSentry = () => {
|
||||||
@ -25,9 +36,75 @@ function Root () {
|
|||||||
console.log(x.y.z)
|
console.log(x.y.z)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const moderate = (itemType, itemId, allowed) => {
|
||||||
|
api.root.moderate(itemType, itemId, allowed, () => {
|
||||||
|
setToModerate(toModerate.filter(o => o._id !== itemId))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container style={{ marginTop: '40px' }}>
|
<Container style={{ marginTop: '40px' }}>
|
||||||
<button onClick={testSentry}>Test Sentry</button>
|
<button onClick={testSentry}>Test Sentry</button>
|
||||||
|
|
||||||
|
<h2>Moderation</h2>
|
||||||
|
{toModerate?.length > 0
|
||||||
|
? (
|
||||||
|
<Table compact basic>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell>Type</Table.Cell>
|
||||||
|
<Table.Cell>Item</Table.Cell>
|
||||||
|
<Table.Cell>Action</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{toModerate.map(o =>
|
||||||
|
<Table.Row key={o._id}>
|
||||||
|
<Table.Cell>{o.itemType}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{o.itemType === 'comments' &&
|
||||||
|
<span>{o.content}</span>}
|
||||||
|
{o.itemType === 'objects' &&
|
||||||
|
<span>
|
||||||
|
<Link to={o.url} target='_blank' rel='noopener noreferrer'>{o.name}</Link>
|
||||||
|
{o.isImage &&
|
||||||
|
<img src={o.url} style={{ width: '100px' }} />}
|
||||||
|
</span>}
|
||||||
|
{o.itemType === 'users' &&
|
||||||
|
<span><Link to={`/${o.username}`}>{o.username}</Link></span>}
|
||||||
|
{o.itemType === 'groups' &&
|
||||||
|
<span><Link to={`/groups/${o._id}`}>{o.name}</Link></span>}
|
||||||
|
{o.itemType === 'groupEntries' &&
|
||||||
|
<span>
|
||||||
|
{o.content}
|
||||||
|
{o.attachments?.map(a =>
|
||||||
|
<span key={a._id}>
|
||||||
|
<a href={a.url} target='_blank' rel='noopener noreferrer'>{a.name}</a>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>}
|
||||||
|
{o.itemType === 'groupForumTopicReplies' &&
|
||||||
|
<span>
|
||||||
|
{o.content}
|
||||||
|
{o.attachments?.map(a =>
|
||||||
|
<span key={a._id}>
|
||||||
|
<a href={a.url} target='_blank' rel='noopener noreferrer'>{a.name}</a>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Button onClick={() => moderate(o.itemType, o._id, true)}>Allow</Button>
|
||||||
|
<Button onClick={() => moderate(o.itemType, o._id, false)}>Deny</Button>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>)
|
||||||
|
: (
|
||||||
|
<p>Nothing to moderate</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
<Table compact basic>
|
<Table compact basic>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
|
@ -12,6 +12,9 @@ import api from '../../../api'
|
|||||||
import BlurrableImage from '../../includes/BlurrableImage'
|
import BlurrableImage from '../../includes/BlurrableImage'
|
||||||
import SupporterBadge from '../../includes/SupporterBadge'
|
import SupporterBadge from '../../includes/SupporterBadge'
|
||||||
import FollowButton from '../../includes/FollowButton'
|
import FollowButton from '../../includes/FollowButton'
|
||||||
|
import ReportLink from '../../includes/ReportLink'
|
||||||
|
|
||||||
|
const FOLLOWING_ENABLED = import.meta.env.VITE_FOLLOWING_ENABLED === 'true'
|
||||||
|
|
||||||
function Profile () {
|
function Profile () {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@ -84,11 +87,11 @@ function Profile () {
|
|||||||
{profileUser.isSilverSupporter && !profileUser.isGoldSupporter &&
|
{profileUser.isSilverSupporter && !profileUser.isGoldSupporter &&
|
||||||
<div style={{ marginTop: 10 }}><SupporterBadge type='silver' /></div>}
|
<div style={{ marginTop: 10 }}><SupporterBadge type='silver' /></div>}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
{profileUser._id !== user?._id &&
|
{FOLLOWING_ENABLED && profileUser._id !== user?._id &&
|
||||||
<Card.Content style={{ marginTop: 10 }}>
|
<Card.Content style={{ marginTop: 10 }}>
|
||||||
<FollowButton targetUser={profileUser} />
|
<FollowButton targetUser={profileUser} />
|
||||||
</Card.Content>}
|
</Card.Content>}
|
||||||
{profileUser._id === user?._id &&
|
{FOLLOWING_ENABLED && profileUser._id === user?._id &&
|
||||||
<Card.Content extra textAlign='right'>
|
<Card.Content extra textAlign='right'>
|
||||||
<p><Icon name='users' /> You have {user?.followerCount || 0} followers</p>
|
<p><Icon name='users' /> You have {user?.followerCount || 0} followers</p>
|
||||||
</Card.Content>}
|
</Card.Content>}
|
||||||
@ -108,6 +111,8 @@ function Profile () {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<ReportLink referrer={`/users/${profileUser._id}`} text='Report this user' />
|
||||||
|
|
||||||
{(profileUser.bio || profileUser.website || profileUser.twitter || profileUser.facebook) &&
|
{(profileUser.bio || profileUser.website || profileUser.twitter || profileUser.facebook) &&
|
||||||
<Card fluid>
|
<Card fluid>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
|
@ -13,6 +13,12 @@ function TermsOfUse () {
|
|||||||
<p>This policy is designed to be accessible, understandable, and easy to read without legal and other jargon. If you have any comments, questions, or concerns about this policy, please get in touch with us by emailing {EMAIL}.</p>
|
<p>This policy is designed to be accessible, understandable, and easy to read without legal and other jargon. If you have any comments, questions, or concerns about this policy, please get in touch with us by emailing {EMAIL}.</p>
|
||||||
<p>This document will have slight changes made to it occasionally. Please refer back to it from time to time.</p>
|
<p>This document will have slight changes made to it occasionally. Please refer back to it from time to time.</p>
|
||||||
|
|
||||||
|
{import.meta.env.VITE_ONLINE_SAFETY_URL &&
|
||||||
|
<div>
|
||||||
|
<h2>Online Safety</h2>
|
||||||
|
<p>For information on {APP_NAME}'s online safety processes and notice, please see our <a href={import.meta.env.VITE_ONLINE_SAFETY_URL}>Online Safety</a> page.</p>
|
||||||
|
</div>}
|
||||||
|
|
||||||
<h2>Terms</h2>
|
<h2>Terms</h2>
|
||||||
<p>{APP_NAME} does not guarantee constant availability of service access and accepts no liability for downtime or access failure due to circumstances beyond its reasonable control (including any failure by ISP or system provider).</p>
|
<p>{APP_NAME} does not guarantee constant availability of service access and accepts no liability for downtime or access failure due to circumstances beyond its reasonable control (including any failure by ISP or system provider).</p>
|
||||||
|
|
||||||
@ -26,7 +32,7 @@ function TermsOfUse () {
|
|||||||
|
|
||||||
<p>Our services are provided on an “as is”, “as available” basis without warranties of any kind, express or implied, including, but not limited to, those of TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE or NON-INFRINGEMENT or any warranty arising from a course of dealing, usage, or trade practice. No advice or written information provided shall create a warranty; nor shall members or visitors to our services rely on any such information or advice.</p>
|
<p>Our services are provided on an “as is”, “as available” basis without warranties of any kind, express or implied, including, but not limited to, those of TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE or NON-INFRINGEMENT or any warranty arising from a course of dealing, usage, or trade practice. No advice or written information provided shall create a warranty; nor shall members or visitors to our services rely on any such information or advice.</p>
|
||||||
|
|
||||||
<p>We reserve the right to permanently ban any user from our services for any reason related to mis-behaviour. We will be the sole judge of behavior and we do not offer appeals or refunds in those cases.</p>
|
<p>We reserve the right to permanently ban any user from our services for any reason related to mis-behaviour. We will be the sole judge of behavior and we do not offer appeals in those cases. We also reserve the right to remove any content at any time and for any reason, if we assess it to be misaligned with the service's goals (i.e. not weaving or art related), harmful, abusive, or illegal.</p>
|
||||||
|
|
||||||
<p>All users must follow the {APP_NAME} code of conduct:</p>
|
<p>All users must follow the {APP_NAME} code of conduct:</p>
|
||||||
|
|
||||||
|
@ -16,7 +16,8 @@ import Home from './components/main/Home'
|
|||||||
|
|
||||||
import MarketingHome from './components/marketing/Home'
|
import MarketingHome from './components/marketing/Home'
|
||||||
import PrivacyPolicy from './components/marketing/PrivacyPolicy'
|
import PrivacyPolicy from './components/marketing/PrivacyPolicy'
|
||||||
import TermsOfUse from './components//marketing/TermsOfUse'
|
import TermsOfUse from './components/marketing/TermsOfUse'
|
||||||
|
import Report from './components/main/Report'
|
||||||
|
|
||||||
import ForgottenPassword from './components/ForgottenPassword'
|
import ForgottenPassword from './components/ForgottenPassword'
|
||||||
import ResetPassword from './components/ResetPassword'
|
import ResetPassword from './components/ResetPassword'
|
||||||
@ -80,6 +81,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: 'explore', element: <Explore /> },
|
{ path: 'explore', element: <Explore /> },
|
||||||
{ path: 'privacy', element: <PrivacyPolicy /> },
|
{ path: 'privacy', element: <PrivacyPolicy /> },
|
||||||
{ path: 'terms-of-use', element: <TermsOfUse /> },
|
{ path: 'terms-of-use', element: <TermsOfUse /> },
|
||||||
|
{ path: 'report', element: <Report /> },
|
||||||
{ path: 'password/forgotten', element: <ForgottenPassword /> },
|
{ path: 'password/forgotten', element: <ForgottenPassword /> },
|
||||||
{ path: 'password/reset', element: <ResetPassword /> },
|
{ path: 'password/reset', element: <ResetPassword /> },
|
||||||
{
|
{
|
||||||
|
@ -35,7 +35,7 @@ const utils = {
|
|||||||
return group?.admins?.indexOf(user?._id) > -1 || user?.roles?.indexOf('root') > -1
|
return group?.admins?.indexOf(user?._id) > -1 || user?.roles?.indexOf('root') > -1
|
||||||
},
|
},
|
||||||
hasGroupPermission (user, group, permission) {
|
hasGroupPermission (user, group, permission) {
|
||||||
return utils.isInGroup(user, group._id) && (utils.isGroupAdmin(user, group) || group?.memberPermissions?.indexOf(permission) > -1)
|
return utils.isInGroup(user, group?._id) && (utils.isGroupAdmin(user, group) || group?.memberPermissions?.indexOf(permission) > -1)
|
||||||
},
|
},
|
||||||
ensureHttp (s) {
|
ensureHttp (s) {
|
||||||
if (s && s.toLowerCase().indexOf('http') === -1) return `http://${s}`
|
if (s && s.toLowerCase().indexOf('http') === -1) return `http://${s}`
|
||||||
|
Loading…
Reference in New Issue
Block a user