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)
|
||||
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)
|
||||
return get_one(user, id)
|
||||
|
||||
@ -137,6 +142,7 @@ def create_entry(user, id, data):
|
||||
"group": id,
|
||||
"user": user["_id"],
|
||||
"content": data["content"],
|
||||
"moderationRequired": True,
|
||||
}
|
||||
if "attachments" in data:
|
||||
entry["attachments"] = data["attachments"]
|
||||
@ -161,12 +167,24 @@ def create_entry(user, id, data):
|
||||
entry["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
||||
"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(
|
||||
{
|
||||
"_id": {"$ne": user["_id"]},
|
||||
"groups": id,
|
||||
"subscriptions.email": "groupFeed-" + str(id),
|
||||
"groups": group["_id"],
|
||||
"subscriptions.email": "groupFeed-" + str(group["_id"]),
|
||||
},
|
||||
{"email": 1, "username": 1},
|
||||
):
|
||||
@ -178,18 +196,17 @@ def create_entry(user, id, data):
|
||||
u["username"],
|
||||
user["username"],
|
||||
group["name"],
|
||||
data["content"],
|
||||
"{}/groups/{}".format(APP_URL, str(id)),
|
||||
entry["content"],
|
||||
"{}/groups/{}".format(APP_URL, str(group["_id"])),
|
||||
APP_NAME,
|
||||
),
|
||||
}
|
||||
)
|
||||
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"]),
|
||||
data["content"][:30] + "...",
|
||||
entry["content"][:30] + "...",
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
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")
|
||||
if not has_group_permission(user, group, "viewNoticeboard"):
|
||||
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(
|
||||
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(
|
||||
db.users.find(
|
||||
@ -266,6 +289,7 @@ def create_entry_reply(user, id, entry_id, data):
|
||||
"inReplyTo": entry_id,
|
||||
"user": user["_id"],
|
||||
"content": data["content"],
|
||||
"moderationRequired": True,
|
||||
}
|
||||
if "attachments" in data:
|
||||
reply["attachments"] = data["attachments"]
|
||||
@ -290,9 +314,22 @@ def create_entry_reply(user, id, entry_id, data):
|
||||
reply["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
||||
"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(
|
||||
{
|
||||
"$and": [{"_id": entry.get("user")}, {"_id": {"$ne": user["_id"]}}],
|
||||
"$and": [
|
||||
{"_id": original_entry.get("user")},
|
||||
{"_id": {"$ne": user["_id"]}},
|
||||
],
|
||||
"subscriptions.email": "messages.replied",
|
||||
}
|
||||
)
|
||||
@ -305,13 +342,12 @@ def create_entry_reply(user, id, entry_id, data):
|
||||
op["username"],
|
||||
user["username"],
|
||||
group["name"],
|
||||
data["content"],
|
||||
"{}/groups/{}".format(APP_URL, str(id)),
|
||||
reply["content"],
|
||||
"{}/groups/{}".format(APP_URL, str(group["_id"])),
|
||||
APP_NAME,
|
||||
),
|
||||
}
|
||||
)
|
||||
return reply
|
||||
|
||||
|
||||
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"],
|
||||
"content": data["content"],
|
||||
"attachments": data.get("attachments", []),
|
||||
"moderationRequired": True,
|
||||
}
|
||||
result = db.groupForumTopicReplies.insert_one(reply)
|
||||
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(
|
||||
{
|
||||
"_id": {"$ne": user["_id"]},
|
||||
"groups": id,
|
||||
"subscriptions.email": "groupForumTopic-" + str(topic_id),
|
||||
"_id": {"$ne": reply["user"]},
|
||||
"groups": topic["group"],
|
||||
"subscriptions.email": "groupForumTopic-" + str(topic["_id"]),
|
||||
},
|
||||
{"email": 1, "username": 1},
|
||||
):
|
||||
@ -648,17 +695,15 @@ def create_forum_topic_reply(user, id, topic_id, data):
|
||||
user["username"],
|
||||
topic["title"],
|
||||
group["name"],
|
||||
data["content"],
|
||||
reply["content"],
|
||||
"{}/groups/{}/forum/topics/{}".format(
|
||||
APP_URL, str(id), str(topic_id)
|
||||
APP_URL, str(group["_id"]), str(topic["_id"])
|
||||
),
|
||||
APP_NAME,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return reply
|
||||
|
||||
|
||||
def get_forum_topic_replies(user, id, topic_id, data):
|
||||
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})
|
||||
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)
|
||||
.skip((page - 1) * REPLIES_PER_PAGE)
|
||||
.limit(REPLIES_PER_PAGE)
|
||||
|
@ -34,7 +34,9 @@ def get(user, id):
|
||||
raise util.errors.NotFound("Project not found")
|
||||
is_owner = user and (user.get("_id") == proj["user"])
|
||||
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})
|
||||
if obj["type"] == "file" and "storedName" in obj:
|
||||
obj["url"] = uploads.get_presigned_url(
|
||||
@ -169,12 +171,12 @@ def create_comment(user, id, data):
|
||||
obj = db.objects.find_one({"_id": ObjectId(id)})
|
||||
if not obj:
|
||||
raise util.errors.NotFound("We could not find the specified object")
|
||||
project = db.projects.find_one({"_id": obj["project"]})
|
||||
comment = {
|
||||
"content": data.get("content", ""),
|
||||
"object": ObjectId(id),
|
||||
"user": user["_id"],
|
||||
"createdAt": datetime.datetime.now(),
|
||||
"moderationRequired": True,
|
||||
}
|
||||
result = db.comments.insert_one(comment)
|
||||
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"))
|
||||
),
|
||||
}
|
||||
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(
|
||||
{"_id": project["user"], "subscriptions.email": "projects.commented"}
|
||||
)
|
||||
@ -209,7 +221,6 @@ def create_comment(user, id, data):
|
||||
),
|
||||
}
|
||||
)
|
||||
return comment
|
||||
|
||||
|
||||
def get_comments(user, id):
|
||||
@ -224,7 +235,14 @@ def get_comments(user, id):
|
||||
is_owner = user and (user.get("_id") == proj["user"])
|
||||
if not is_owner and proj["visibility"] != "public":
|
||||
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))
|
||||
users = list(
|
||||
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):
|
||||
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(
|
||||
db.objects.find(
|
||||
{"project": project["_id"]},
|
||||
query,
|
||||
{
|
||||
"createdAt": 1,
|
||||
"name": 1,
|
||||
@ -295,6 +298,7 @@ def create_object(user, username, path, data):
|
||||
"storedName": data["storedName"],
|
||||
"createdAt": datetime.datetime.now(),
|
||||
"type": "file",
|
||||
"moderationRequired": True,
|
||||
}
|
||||
if re.search(r"(.jpg)|(.png)|(.jpeg)|(.gif)$", data["storedName"].lower()):
|
||||
obj["isImage"] = True
|
||||
@ -313,6 +317,7 @@ def create_object(user, username, path, data):
|
||||
uploads.blur_image(
|
||||
"projects/" + str(project["_id"]) + "/" + data["storedName"], handle_cb
|
||||
)
|
||||
util.send_moderation_request(user, "object", obj)
|
||||
return obj
|
||||
if data["type"] == "pattern":
|
||||
obj = {
|
||||
|
@ -1,5 +1,7 @@
|
||||
import datetime
|
||||
from bson.objectid import ObjectId
|
||||
from util import database, util
|
||||
from api import uploads
|
||||
from api import uploads, objects, groups
|
||||
|
||||
|
||||
def get_users(user):
|
||||
@ -52,3 +54,82 @@ def get_groups(user):
|
||||
for group in groups:
|
||||
group["memberCount"] = db.users.count_documents({"groups": group["_id"]})
|
||||
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", {}
|
||||
): # Also unset blurhash if removing avatar
|
||||
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)
|
||||
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)))
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import os
|
||||
import json
|
||||
import datetime
|
||||
from flask import request, Response
|
||||
@ -7,7 +8,7 @@ from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from bson.objectid import ObjectId
|
||||
from api import accounts
|
||||
from util import util
|
||||
from util import util, mail
|
||||
|
||||
errors = werkzeug.exceptions
|
||||
|
||||
@ -92,6 +93,34 @@ def build_updater(obj, allowed_keys):
|
||||
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():
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
|
||||
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_CONTACT_EMAIL="hello@treadl.com"
|
||||
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) {
|
||||
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) {
|
||||
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 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 }) {
|
||||
const [highlightProjects, setHighlightProjects] = useState([])
|
||||
const [highlightUsers, setHighlightUsers] = useState([])
|
||||
@ -24,8 +28,8 @@ export default function ExploreCard ({ count, asCard }) {
|
||||
setLoading(true)
|
||||
api.search.discover(count || 3, ({ highlightProjects, highlightUsers, highlightGroups }) => {
|
||||
setHighlightProjects(highlightProjects)
|
||||
setHighlightUsers(highlightUsers)
|
||||
setHighlightGroups(highlightGroups)
|
||||
if (USER_DISCOVERY_ENABLED) { setHighlightUsers(highlightUsers) }
|
||||
if (GROUP_DISCOVERY_ENABLED) { setHighlightGroups(highlightGroups) }
|
||||
setLoading(false)
|
||||
})
|
||||
}, [userId])
|
||||
@ -57,6 +61,8 @@ export default function ExploreCard ({ count, asCard }) {
|
||||
</List>
|
||||
</>}
|
||||
|
||||
{USER_DISCOVERY_ENABLED &&
|
||||
<>
|
||||
<h4>Find others on {utils.appName()}</h4>
|
||||
{loading && <BulletList />}
|
||||
{highlightUsers?.length > 0 &&
|
||||
@ -68,7 +74,8 @@ export default function ExploreCard ({ count, asCard }) {
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<UserChip user={u} className='umami--click--discover-user' />
|
||||
<div>
|
||||
<FollowButton compact targetUser={u} onChange={f => updateFollowing(u._id, f)} />
|
||||
{FOLLOWING_ENABLED &&
|
||||
<FollowButton compact targetUser={u} onChange={f => updateFollowing(u._id, f)} />}
|
||||
</div>
|
||||
</div>
|
||||
</List.Content>
|
||||
@ -76,7 +83,10 @@ export default function ExploreCard ({ count, asCard }) {
|
||||
)}
|
||||
</List>
|
||||
</>}
|
||||
</>}
|
||||
|
||||
{GROUP_DISCOVERY_ENABLED &&
|
||||
<>
|
||||
<h4>Discover a group</h4>
|
||||
{loading && <BulletList />}
|
||||
{highlightGroups?.length > 0 &&
|
||||
@ -90,6 +100,7 @@ export default function ExploreCard ({ count, asCard }) {
|
||||
</div>
|
||||
)}
|
||||
</>}
|
||||
</>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -7,6 +7,8 @@ import api from '../../api'
|
||||
import UserChip from './UserChip'
|
||||
import DiscoverCard from './DiscoverCard'
|
||||
|
||||
const FOLLOWING_ENABLED = import.meta.env.VITE_FOLLOWING_ENABLED === 'true'
|
||||
|
||||
export default function Feed () {
|
||||
const [feed, setFeed] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@ -17,7 +19,7 @@ export default function Feed () {
|
||||
const username = user?.username
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) return
|
||||
if (!username || !FOLLOWING_ENABLED) return
|
||||
setLoading(true)
|
||||
api.users.getFeed(username, result => {
|
||||
setFeed(result.feed)
|
||||
@ -28,6 +30,8 @@ export default function Feed () {
|
||||
return (
|
||||
<Card fluid>
|
||||
<Card.Content style={{ maxHeight: 500, overflowY: 'scroll' }}>
|
||||
{FOLLOWING_ENABLED &&
|
||||
<>
|
||||
<Card.Header style={{ marginBottom: 10 }}>Recent activity</Card.Header>
|
||||
{loading &&
|
||||
<div>
|
||||
@ -36,7 +40,6 @@ export default function Feed () {
|
||||
{!loading && !feed?.length &&
|
||||
<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>
|
||||
<DiscoverCard />
|
||||
</div>}
|
||||
{!loading && feed?.map(item =>
|
||||
<div key={item._id} style={{ display: 'flex', alignItems: 'center', marginBottom: 10 }}>
|
||||
@ -63,6 +66,8 @@ export default function Feed () {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>}
|
||||
<DiscoverCard />
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)
|
||||
|
@ -11,6 +11,7 @@ import api from '../../api'
|
||||
import UserChip from './UserChip'
|
||||
import NewFeedMessage from './NewFeedMessage'
|
||||
import FormattedMessage from './FormattedMessage'
|
||||
import ReportLink from './ReportLink'
|
||||
|
||||
const StyledMessage = styled.div`
|
||||
padding: 6px;
|
||||
@ -58,6 +59,8 @@ const FeedMessage = connect(
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'end', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<UserChip user={post.authorUser} meta={moment(post.createdAt).fromNow()} />
|
||||
{canDelete() &&
|
||||
<Dropdown icon='ellipsis horizontal' style={{ marginLeft: 10 }}>
|
||||
@ -65,6 +68,11 @@ const FeedMessage = connect(
|
||||
<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 }}>
|
||||
<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 }} />
|
||||
)}
|
||||
</div>}
|
||||
|
||||
{!post.inReplyTo &&
|
||||
<div style={{ padding: 10 }}>
|
||||
{utils.hasGroupPermission(user, group, 'postNoticeboard') && replyingTo !== post._id && !post.inReplyTo && onReplyPosted &&
|
||||
|
@ -41,8 +41,13 @@ export default function Footer () {
|
||||
</p>
|
||||
<p>
|
||||
<Icon name='file alternate outline' />
|
||||
<Link to='terms-of-use'>Terms of Use</Link>
|
||||
<Link to='/terms-of-use'>Terms of Use</Link>
|
||||
</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 &&
|
||||
<p>
|
||||
<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 SearchBar from './SearchBar'
|
||||
|
||||
const GROUPS_ENABLED = import.meta.env.VITE_GROUPS_ENABLED === 'true'
|
||||
|
||||
const StyledNavBar = styled.div`
|
||||
height:60px;
|
||||
background: linen;
|
||||
@ -61,8 +63,9 @@ export default function NavBar () {
|
||||
<Link to={user ? '/projects' : '/'}><img alt={`${utils.appName()} logo`} src={logo} className='logo' /></Link>
|
||||
<div style={{ flex: 1 }}>
|
||||
<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'} />
|
||||
{GROUPS_ENABLED &&
|
||||
<Menu.Item className='above-mobile' active={location.pathname.startsWith('/groups')} name='Groups'>
|
||||
<Dropdown
|
||||
pointing='top left' icon={null}
|
||||
@ -77,7 +80,7 @@ export default function NavBar () {
|
||||
<Dropdown.Item as={Link} to='/groups/new' icon='plus' content='Create a new group' />
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</Menu.Item>
|
||||
</Menu.Item>}
|
||||
{user && !isSupporter && (import.meta.env.VITE_PATREON_URL || import.meta.env.VITE_KOFI_URL) &&
|
||||
<Menu.Item className='above-mobile'>
|
||||
<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 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 { Link } from 'react-router-dom'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
@ -10,6 +10,8 @@ import api from '../../api'
|
||||
|
||||
import UserChip from './UserChip'
|
||||
|
||||
const USER_DISCOVERY_ENABLED = import.meta.env.VITE_USER_DISCOVERY_ENABLED === 'true'
|
||||
|
||||
const StyledSearchBar = styled.div`
|
||||
background-color:rgba(0,0,0,0.1);
|
||||
padding:5px;
|
||||
@ -106,7 +108,7 @@ export default function SearchBar () {
|
||||
})}
|
||||
</List>
|
||||
</Grid.Column>}
|
||||
{searchResults?.users?.length > 0 &&
|
||||
{USER_DISCOVERY_ENABLED && searchResults?.users?.length > 0 &&
|
||||
<Grid.Column width={6}>
|
||||
<h4>Users</h4>
|
||||
{searchResults?.users?.map(u =>
|
||||
@ -115,7 +117,7 @@ export default function SearchBar () {
|
||||
</Grid.Column>}
|
||||
{(searchResults?.projects.length > 0 || searchResults.groups.length > 0) &&
|
||||
<Grid.Column width={10}>
|
||||
<h4>Projects & groups</h4>
|
||||
<h4>Projects</h4>
|
||||
<List>
|
||||
{searchResults?.projects?.map(p =>
|
||||
<List.Item key={p._id}>
|
||||
@ -126,15 +128,6 @@ export default function SearchBar () {
|
||||
</List.Content>
|
||||
</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>
|
||||
</Grid.Column>}
|
||||
</Grid>)}
|
||||
|
@ -17,6 +17,8 @@ import PatternLoader from '../includes/PatternLoader'
|
||||
import Tour from '../includes/Tour'
|
||||
import Feed from '../includes/Feed'
|
||||
|
||||
const GROUPS_ENABLED = import.meta.env.VITE_GROUPS_ENABLED === 'true'
|
||||
|
||||
function Home () {
|
||||
const [runJoyride, setRunJoyride] = useState(false)
|
||||
const dispatch = useDispatch()
|
||||
@ -90,6 +92,7 @@ function Home () {
|
||||
|
||||
<Feed />
|
||||
|
||||
{GROUPS_ENABLED &&
|
||||
<Card fluid className='joyride-groups' style={{ opacity: 0.8 }}>
|
||||
<Card.Content>
|
||||
<Card.Header>Your groups</Card.Header>
|
||||
@ -121,7 +124,7 @@ function Home () {
|
||||
<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 />
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</Card>}
|
||||
|
||||
</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 NewFeedMessage from '../../includes/NewFeedMessage'
|
||||
import FormattedMessage from '../../includes/FormattedMessage'
|
||||
import ReportLink from '../../includes/ReportLink'
|
||||
import StartChatImage from '../../../images/startchat.png'
|
||||
|
||||
export default function ForumTopic () {
|
||||
@ -101,10 +102,11 @@ export default function ForumTopic () {
|
||||
<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 style={{ fontSize: 12, color: 'rgb(150,150,150)', display: 'flex', alignItems: 'center' }}>
|
||||
<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>}
|
||||
<ReportLink referrer={`/topicReplies/${reply._id}`} style={{ fontSize: 11 }} marginLeft='10px' />
|
||||
</div>
|
||||
</div>
|
||||
<FormattedMessage content={reply.content} />
|
||||
|
@ -10,6 +10,7 @@ import api from '../../../api'
|
||||
|
||||
import UserChip from '../../includes/UserChip'
|
||||
import HelpLink from '../../includes/HelpLink'
|
||||
import ReportLink from '../../includes/ReportLink'
|
||||
|
||||
function Group () {
|
||||
const { id } = useParams()
|
||||
@ -131,6 +132,7 @@ function Group () {
|
||||
</Card>
|
||||
|
||||
<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 computer={12}>
|
||||
|
@ -18,6 +18,8 @@ const PERMISSIONS = [
|
||||
{ name: 'postForumTopicReplies', label: 'Allow members to reply to forum topics' }
|
||||
]
|
||||
|
||||
const GROUP_DISCOVERY_ENABLED = import.meta.env.VITE_GROUP_DISCOVERY_ENABLED === 'true'
|
||||
|
||||
function Settings () {
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useDispatch()
|
||||
@ -87,7 +89,8 @@ function Settings () {
|
||||
<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>
|
||||
</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)' }}>
|
||||
<p>If a group is publicised, it may allow other people to discover the group.</p>
|
||||
</div>
|
||||
|
@ -14,6 +14,7 @@ import RichTextViewer from '../../includes/RichTextViewer'
|
||||
import DraftPreview from './objects/DraftPreview'
|
||||
import NewFeedMessage from '../../includes/NewFeedMessage'
|
||||
import FeedMessage from '../../includes/FeedMessage'
|
||||
import ReportLink from '../../includes/ReportLink'
|
||||
|
||||
function ObjectViewer () {
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
@ -96,7 +97,8 @@ function ObjectViewer () {
|
||||
<>
|
||||
<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) &&
|
||||
<>
|
||||
<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>
|
||||
|
||||
</>}
|
||||
|
||||
{object.type === 'file' &&
|
||||
<ReportLink referrer={`/objects/${object._id}`} marginLeft='5px' />}
|
||||
</div>
|
||||
|
||||
{editingName
|
||||
|
@ -9,6 +9,7 @@ import api from '../../../api'
|
||||
|
||||
import UserChip from '../../includes/UserChip'
|
||||
import HelpLink from '../../includes/HelpLink'
|
||||
import ReportLink from '../../includes/ReportLink'
|
||||
import ObjectCreator from '../../includes/ObjectCreator'
|
||||
import FormattedMessage from '../../includes/FormattedMessage'
|
||||
|
||||
@ -139,6 +140,8 @@ function Project () {
|
||||
</Card>}
|
||||
|
||||
<HelpLink link='/docs/projects' />
|
||||
{!utils.canEditProject(user, project) &&
|
||||
<ReportLink referrer={`/projects/${project._id}`} text='Report this project' marginTop='5px' />}
|
||||
</Grid.Column>
|
||||
)}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 { useSelector } from 'react-redux'
|
||||
import moment from 'moment'
|
||||
@ -7,6 +7,7 @@ import api from '../../../api'
|
||||
|
||||
function Root () {
|
||||
const [users, setUsers] = useState([])
|
||||
const [toModerate, setToModerate] = useState([])
|
||||
|
||||
const { user } = useSelector(state => {
|
||||
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0]
|
||||
@ -18,6 +19,16 @@ function Root () {
|
||||
api.root.getUsers(({ 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])
|
||||
|
||||
const testSentry = () => {
|
||||
@ -25,9 +36,75 @@ function Root () {
|
||||
console.log(x.y.z)
|
||||
}
|
||||
|
||||
const moderate = (itemType, itemId, allowed) => {
|
||||
api.root.moderate(itemType, itemId, allowed, () => {
|
||||
setToModerate(toModerate.filter(o => o._id !== itemId))
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Container style={{ marginTop: '40px' }}>
|
||||
<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>
|
||||
<Table compact basic>
|
||||
<Table.Header>
|
||||
|
@ -12,6 +12,9 @@ import api from '../../../api'
|
||||
import BlurrableImage from '../../includes/BlurrableImage'
|
||||
import SupporterBadge from '../../includes/SupporterBadge'
|
||||
import FollowButton from '../../includes/FollowButton'
|
||||
import ReportLink from '../../includes/ReportLink'
|
||||
|
||||
const FOLLOWING_ENABLED = import.meta.env.VITE_FOLLOWING_ENABLED === 'true'
|
||||
|
||||
function Profile () {
|
||||
const [loading, setLoading] = useState(false)
|
||||
@ -84,11 +87,11 @@ function Profile () {
|
||||
{profileUser.isSilverSupporter && !profileUser.isGoldSupporter &&
|
||||
<div style={{ marginTop: 10 }}><SupporterBadge type='silver' /></div>}
|
||||
</Card.Content>
|
||||
{profileUser._id !== user?._id &&
|
||||
{FOLLOWING_ENABLED && profileUser._id !== user?._id &&
|
||||
<Card.Content style={{ marginTop: 10 }}>
|
||||
<FollowButton targetUser={profileUser} />
|
||||
</Card.Content>}
|
||||
{profileUser._id === user?._id &&
|
||||
{FOLLOWING_ENABLED && profileUser._id === user?._id &&
|
||||
<Card.Content extra textAlign='right'>
|
||||
<p><Icon name='users' /> You have {user?.followerCount || 0} followers</p>
|
||||
</Card.Content>}
|
||||
@ -108,6 +111,8 @@ function Profile () {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<ReportLink referrer={`/users/${profileUser._id}`} text='Report this user' />
|
||||
|
||||
{(profileUser.bio || profileUser.website || profileUser.twitter || profileUser.facebook) &&
|
||||
<Card fluid>
|
||||
<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 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>
|
||||
<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>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>
|
||||
|
||||
|
@ -16,7 +16,8 @@ import Home from './components/main/Home'
|
||||
|
||||
import MarketingHome from './components/marketing/Home'
|
||||
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 ResetPassword from './components/ResetPassword'
|
||||
@ -80,6 +81,7 @@ const router = createBrowserRouter([
|
||||
{ path: 'explore', element: <Explore /> },
|
||||
{ path: 'privacy', element: <PrivacyPolicy /> },
|
||||
{ path: 'terms-of-use', element: <TermsOfUse /> },
|
||||
{ path: 'report', element: <Report /> },
|
||||
{ path: 'password/forgotten', element: <ForgottenPassword /> },
|
||||
{ path: 'password/reset', element: <ResetPassword /> },
|
||||
{
|
||||
|
@ -35,7 +35,7 @@ const utils = {
|
||||
return group?.admins?.indexOf(user?._id) > -1 || user?.roles?.indexOf('root') > -1
|
||||
},
|
||||
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) {
|
||||
if (s && s.toLowerCase().indexOf('http') === -1) return `http://${s}`
|
||||
|
Loading…
Reference in New Issue
Block a user