mirror of
https://git.sr.ht/~tsileo/microblog.pub
synced 2024-12-22 13:14:28 +00:00
Question/poll support
This commit is contained in:
parent
4c0e81224f
commit
be7648c9ed
7 changed files with 361 additions and 41 deletions
|
@ -1,3 +1,4 @@
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
@ -73,6 +74,12 @@ def ensure_it_is_me(f):
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def _answer_key(choice: str) -> str:
|
||||||
|
h = hashlib.new("sha1")
|
||||||
|
h.update(choice.encode())
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
class Box(Enum):
|
class Box(Enum):
|
||||||
INBOX = "inbox"
|
INBOX = "inbox"
|
||||||
OUTBOX = "outbox"
|
OUTBOX = "outbox"
|
||||||
|
@ -106,13 +113,17 @@ class MicroblogPubBackend(Backend):
|
||||||
|
|
||||||
def save(self, box: Box, activity: ap.BaseActivity) -> None:
|
def save(self, box: Box, activity: ap.BaseActivity) -> None:
|
||||||
"""Custom helper for saving an activity to the DB."""
|
"""Custom helper for saving an activity to the DB."""
|
||||||
|
is_public = True
|
||||||
|
if activity.has_type(ap.ActivityType.CREATE) and not activity.is_public():
|
||||||
|
is_public = False
|
||||||
|
|
||||||
DB.activities.insert_one(
|
DB.activities.insert_one(
|
||||||
{
|
{
|
||||||
"box": box.value,
|
"box": box.value,
|
||||||
"activity": activity.to_dict(),
|
"activity": activity.to_dict(),
|
||||||
"type": _to_list(activity.type),
|
"type": _to_list(activity.type),
|
||||||
"remote_id": activity.id,
|
"remote_id": activity.id,
|
||||||
"meta": {"undo": False, "deleted": False},
|
"meta": {"undo": False, "deleted": False, "public": is_public},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -453,6 +464,46 @@ class MicroblogPubBackend(Backend):
|
||||||
{"$inc": {"meta.count_reply": -1, "meta.count_direct_reply": -1}},
|
{"$inc": {"meta.count_reply": -1, "meta.count_direct_reply": -1}},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _process_question_reply(self, create: ap.Create, question: ap.Question) -> None:
|
||||||
|
choice = create.get_object().name
|
||||||
|
|
||||||
|
# Ensure it's a valid choice
|
||||||
|
if choice not in [
|
||||||
|
c["name"] for c in question._data.get("oneOf", question.anyOf)
|
||||||
|
]:
|
||||||
|
logger.info("invalid choice")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check for duplicate votes
|
||||||
|
if DB.activities.find_one(
|
||||||
|
{
|
||||||
|
"activity.object.actor": create.get_actor().id,
|
||||||
|
"meta.answer_to": question.id,
|
||||||
|
}
|
||||||
|
):
|
||||||
|
logger.info("duplicate response")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update the DB
|
||||||
|
answer_key = _answer_key(choice)
|
||||||
|
|
||||||
|
DB.activities.update_one(
|
||||||
|
{"activity.object.id": question.id},
|
||||||
|
{
|
||||||
|
"$inc": {
|
||||||
|
"meta.question_replies": 1,
|
||||||
|
f"meta.question_answers.{answer_key}": 1,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
DB.activities.update_one(
|
||||||
|
{"remote_id": create.id},
|
||||||
|
{"$set": {"meta.answer_to": question.id, "meta.stream": False}},
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
@ensure_it_is_me
|
@ensure_it_is_me
|
||||||
def _handle_replies(self, as_actor: ap.Person, create: ap.Create) -> None:
|
def _handle_replies(self, as_actor: ap.Person, create: ap.Create) -> None:
|
||||||
"""Go up to the root reply, store unknown replies in the `threads` DB and set the "meta.thread_root_parent"
|
"""Go up to the root reply, store unknown replies in the `threads` DB and set the "meta.thread_root_parent"
|
||||||
|
@ -465,6 +516,26 @@ class MicroblogPubBackend(Backend):
|
||||||
root_reply = in_reply_to
|
root_reply = in_reply_to
|
||||||
reply = ap.fetch_remote_activity(root_reply)
|
reply = ap.fetch_remote_activity(root_reply)
|
||||||
|
|
||||||
|
# Ensure the this is a local reply, of a question, with a direct "to" addressing
|
||||||
|
if (
|
||||||
|
reply.id.startswith(BASE_URL)
|
||||||
|
and reply.has_type(ap.ActivityType.QUESTION.value)
|
||||||
|
and _to_list(create.get_object().to)[0].startswith(BASE_URL)
|
||||||
|
and not create.is_public()
|
||||||
|
):
|
||||||
|
return self._process_question_reply(create, reply)
|
||||||
|
elif (
|
||||||
|
create.id.startswith(BASE_URL)
|
||||||
|
and reply.has_type(ap.ActivityType.QUESTION.value)
|
||||||
|
and not create.is_public()
|
||||||
|
):
|
||||||
|
# Keep track of our own votes
|
||||||
|
DB.activities.update_one(
|
||||||
|
{"activity.object.id": reply.id, "box": "inbox"},
|
||||||
|
{"$set": {"meta.voted_for": create.get_object().name}},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
creply = DB.activities.find_one_and_update(
|
creply = DB.activities.find_one_and_update(
|
||||||
{"activity.object.id": in_reply_to},
|
{"activity.object.id": in_reply_to},
|
||||||
{"$inc": {"meta.count_reply": 1, "meta.count_direct_reply": 1}},
|
{"$inc": {"meta.count_reply": 1, "meta.count_direct_reply": 1}},
|
||||||
|
|
161
app.py
161
app.py
|
@ -42,6 +42,7 @@ from little_boxes import activitypub as ap
|
||||||
from little_boxes.activitypub import ActivityType
|
from little_boxes.activitypub import ActivityType
|
||||||
from little_boxes.activitypub import _to_list
|
from little_boxes.activitypub import _to_list
|
||||||
from little_boxes.activitypub import clean_activity
|
from little_boxes.activitypub import clean_activity
|
||||||
|
from little_boxes.activitypub import format_datetime
|
||||||
from little_boxes.activitypub import get_backend
|
from little_boxes.activitypub import get_backend
|
||||||
from little_boxes.content_helper import parse_markdown
|
from little_boxes.content_helper import parse_markdown
|
||||||
from little_boxes.linked_data_sig import generate_signature
|
from little_boxes.linked_data_sig import generate_signature
|
||||||
|
@ -65,6 +66,7 @@ import config
|
||||||
|
|
||||||
from activitypub import Box
|
from activitypub import Box
|
||||||
from activitypub import embed_collection
|
from activitypub import embed_collection
|
||||||
|
from activitypub import _answer_key
|
||||||
from config import USER_AGENT
|
from config import USER_AGENT
|
||||||
from config import ADMIN_API_KEY
|
from config import ADMIN_API_KEY
|
||||||
from config import BASE_URL
|
from config import BASE_URL
|
||||||
|
@ -129,16 +131,21 @@ def verify_pass(pwd):
|
||||||
def inject_config():
|
def inject_config():
|
||||||
q = {
|
q = {
|
||||||
"type": "Create",
|
"type": "Create",
|
||||||
"activity.object.type": "Note",
|
|
||||||
"activity.object.inReplyTo": None,
|
"activity.object.inReplyTo": None,
|
||||||
"meta.deleted": False,
|
"meta.deleted": False,
|
||||||
|
"meta.public": True,
|
||||||
}
|
}
|
||||||
notes_count = DB.activities.find(
|
notes_count = DB.activities.find(
|
||||||
{"box": Box.OUTBOX.value, "$or": [q, {"type": "Announce", "meta.undo": False}]}
|
{"box": Box.OUTBOX.value, "$or": [q, {"type": "Announce", "meta.undo": False}]}
|
||||||
).count()
|
).count()
|
||||||
q = {"type": "Create", "activity.object.type": "Note", "meta.deleted": False}
|
|
||||||
with_replies_count = DB.activities.find(
|
with_replies_count = DB.activities.find(
|
||||||
{"box": Box.OUTBOX.value, "$or": [q, {"type": "Announce", "meta.undo": False}]}
|
{
|
||||||
|
"box": Box.OUTBOX.value,
|
||||||
|
"type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
|
||||||
|
"meta.undo": False,
|
||||||
|
"meta.deleted": False,
|
||||||
|
"meta.public": True,
|
||||||
|
}
|
||||||
).count()
|
).count()
|
||||||
liked_count = DB.activities.count(
|
liked_count = DB.activities.count(
|
||||||
{
|
{
|
||||||
|
@ -169,6 +176,7 @@ def inject_config():
|
||||||
liked_count=liked_count,
|
liked_count=liked_count,
|
||||||
with_replies_count=with_replies_count,
|
with_replies_count=with_replies_count,
|
||||||
me=ME,
|
me=ME,
|
||||||
|
base_url=config.BASE_URL,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -233,6 +241,16 @@ def _get_file_url(url, size, kind):
|
||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
@app.template_filter()
|
||||||
|
def gtone(n):
|
||||||
|
return n > 1
|
||||||
|
|
||||||
|
|
||||||
|
@app.template_filter()
|
||||||
|
def gtnow(dtstr):
|
||||||
|
return format_datetime(datetime.now().astimezone()) > dtstr
|
||||||
|
|
||||||
|
|
||||||
@app.template_filter()
|
@app.template_filter()
|
||||||
def remove_mongo_id(dat):
|
def remove_mongo_id(dat):
|
||||||
if isinstance(dat, list):
|
if isinstance(dat, list):
|
||||||
|
@ -805,6 +823,9 @@ def index():
|
||||||
DB.activities, q, limit=25 - len(pinned)
|
DB.activities, q, limit=25 - len(pinned)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# FIXME(tsileo): add it on permakink too
|
||||||
|
[_add_answers_to_questions(item) for item in outbox_data]
|
||||||
|
|
||||||
resp = render_template(
|
resp = render_template(
|
||||||
"index.html",
|
"index.html",
|
||||||
outbox_data=outbox_data,
|
outbox_data=outbox_data,
|
||||||
|
@ -823,6 +844,7 @@ def with_replies():
|
||||||
"box": Box.OUTBOX.value,
|
"box": Box.OUTBOX.value,
|
||||||
"type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
|
"type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
|
||||||
"meta.deleted": False,
|
"meta.deleted": False,
|
||||||
|
"meta.public": True,
|
||||||
"meta.undo": False,
|
"meta.undo": False,
|
||||||
}
|
}
|
||||||
outbox_data, older_than, newer_than = paginated_query(DB.activities, q)
|
outbox_data, older_than, newer_than = paginated_query(DB.activities, q)
|
||||||
|
@ -914,6 +936,10 @@ def note_by_id(note_id):
|
||||||
abort(404)
|
abort(404)
|
||||||
if data["meta"].get("deleted", False):
|
if data["meta"].get("deleted", False):
|
||||||
abort(410)
|
abort(410)
|
||||||
|
|
||||||
|
# If it's a Question, add the answers from meta
|
||||||
|
_add_answers_to_questions(data)
|
||||||
|
|
||||||
thread = _build_thread(data)
|
thread = _build_thread(data)
|
||||||
app.logger.info(f"thread={thread!r}")
|
app.logger.info(f"thread={thread!r}")
|
||||||
|
|
||||||
|
@ -1084,9 +1110,31 @@ def remove_context(activity: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
return activity
|
return activity
|
||||||
|
|
||||||
|
|
||||||
|
def _add_answers_to_questions(raw_doc: Dict[str, Any]) -> None:
|
||||||
|
activity = raw_doc["activity"]
|
||||||
|
if (
|
||||||
|
"object" in activity
|
||||||
|
and _to_list(activity["object"]["type"])[0] == ActivityType.QUESTION.value
|
||||||
|
):
|
||||||
|
for choice in activity["object"].get("oneOf", activity["object"].get("anyOf")):
|
||||||
|
choice["replies"] = {
|
||||||
|
"type": ActivityType.COLLECTION.value,
|
||||||
|
"totalItems": raw_doc["meta"]
|
||||||
|
.get("question_answers", {})
|
||||||
|
.get(_answer_key(choice["name"]), 0),
|
||||||
|
}
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
if format_datetime(now) > activity["object"]["endTime"]:
|
||||||
|
activity["object"]["closed"] = activity["object"]["endTime"]
|
||||||
|
|
||||||
|
|
||||||
def activity_from_doc(raw_doc: Dict[str, Any], embed: bool = False) -> Dict[str, Any]:
|
def activity_from_doc(raw_doc: Dict[str, Any], embed: bool = False) -> Dict[str, Any]:
|
||||||
raw_doc = add_extra_collection(raw_doc)
|
raw_doc = add_extra_collection(raw_doc)
|
||||||
activity = clean_activity(raw_doc["activity"])
|
activity = clean_activity(raw_doc["activity"])
|
||||||
|
|
||||||
|
# Handle Questions
|
||||||
|
# TODO(tsileo): what about object embedded by ID/URL?
|
||||||
|
_add_answers_to_questions(raw_doc)
|
||||||
if embed:
|
if embed:
|
||||||
return remove_context(activity)
|
return remove_context(activity)
|
||||||
return activity
|
return activity
|
||||||
|
@ -1101,6 +1149,8 @@ def outbox():
|
||||||
q = {
|
q = {
|
||||||
"box": Box.OUTBOX.value,
|
"box": Box.OUTBOX.value,
|
||||||
"meta.deleted": False,
|
"meta.deleted": False,
|
||||||
|
"meta.undo": False,
|
||||||
|
"meta.public": True,
|
||||||
"type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
|
"type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
|
||||||
}
|
}
|
||||||
return jsonify(
|
return jsonify(
|
||||||
|
@ -1150,6 +1200,7 @@ def outbox_activity(item_id):
|
||||||
)
|
)
|
||||||
if not data:
|
if not data:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
obj = activity_from_doc(data)
|
obj = activity_from_doc(data)
|
||||||
if data["meta"].get("deleted", False):
|
if data["meta"].get("deleted", False):
|
||||||
obj = ap.parse_activity(data["activity"])
|
obj = ap.parse_activity(data["activity"])
|
||||||
|
@ -1487,19 +1538,7 @@ def _user_api_arg(key: str, **kwargs):
|
||||||
def _user_api_get_note(from_outbox: bool = False):
|
def _user_api_get_note(from_outbox: bool = False):
|
||||||
oid = _user_api_arg("id")
|
oid = _user_api_arg("id")
|
||||||
app.logger.info(f"fetching {oid}")
|
app.logger.info(f"fetching {oid}")
|
||||||
try:
|
note = ap.parse_activity(get_backend().fetch_iri(oid))
|
||||||
note = ap.parse_activity(
|
|
||||||
get_backend().fetch_iri(oid), expected=ActivityType.NOTE
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
note = ap.parse_activity(
|
|
||||||
get_backend().fetch_iri(oid), expected=ActivityType.VIDEO
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
raise ActivityNotFoundError(
|
|
||||||
"Expected Note or Video ActivityType, but got something else"
|
|
||||||
)
|
|
||||||
if from_outbox and not note.id.startswith(ID):
|
if from_outbox and not note.id.startswith(ID):
|
||||||
raise NotFromOutboxError(
|
raise NotFromOutboxError(
|
||||||
f"cannot load {note.id}, id must be owned by the server"
|
f"cannot load {note.id}, id must be owned by the server"
|
||||||
|
@ -1542,6 +1581,30 @@ def api_boost():
|
||||||
return _user_api_response(activity=announce_id)
|
return _user_api_response(activity=announce_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/vote", methods=["POST"])
|
||||||
|
@api_required
|
||||||
|
def api_vote():
|
||||||
|
oid = _user_api_arg("id")
|
||||||
|
app.logger.info(f"fetching {oid}")
|
||||||
|
note = ap.parse_activity(get_backend().fetch_iri(oid))
|
||||||
|
choice = _user_api_arg("choice")
|
||||||
|
|
||||||
|
raw_note = dict(
|
||||||
|
attributedTo=MY_PERSON.id,
|
||||||
|
cc=[],
|
||||||
|
to=note.get_actor().id,
|
||||||
|
name=choice,
|
||||||
|
tag=[],
|
||||||
|
inReplyTo=note.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
note = ap.Note(**raw_note)
|
||||||
|
create = note.build_create()
|
||||||
|
create_id = post_to_outbox(create)
|
||||||
|
|
||||||
|
return _user_api_response(activity=create_id)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/like", methods=["POST"])
|
@app.route("/api/like", methods=["POST"])
|
||||||
@api_required
|
@api_required
|
||||||
def api_like():
|
def api_like():
|
||||||
|
@ -1780,6 +1843,57 @@ def api_new_note():
|
||||||
return _user_api_response(activity=create_id)
|
return _user_api_response(activity=create_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/new_question", methods=["POST"])
|
||||||
|
@api_required
|
||||||
|
def api_new_question():
|
||||||
|
source = _user_api_arg("content")
|
||||||
|
if not source:
|
||||||
|
raise ValueError("missing content")
|
||||||
|
|
||||||
|
content, tags = parse_markdown(source)
|
||||||
|
cc = [ID + "/followers"]
|
||||||
|
|
||||||
|
for tag in tags:
|
||||||
|
if tag["type"] == "Mention":
|
||||||
|
cc.append(tag["href"])
|
||||||
|
|
||||||
|
answers = []
|
||||||
|
for i in range(4):
|
||||||
|
a = _user_api_arg(f"answer{i}", default=None)
|
||||||
|
if not a:
|
||||||
|
break
|
||||||
|
answers.append({"type": ActivityType.NOTE.value, "name": a})
|
||||||
|
|
||||||
|
choices = {
|
||||||
|
"endTime": ap.format_datetime(
|
||||||
|
datetime.now().astimezone()
|
||||||
|
+ timedelta(minutes=int(_user_api_arg("open_for")))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
of = _user_api_arg("of")
|
||||||
|
if of == "anyOf":
|
||||||
|
choices["anyOf"] = answers
|
||||||
|
else:
|
||||||
|
choices["oneOf"] = answers
|
||||||
|
|
||||||
|
raw_question = dict(
|
||||||
|
attributedTo=MY_PERSON.id,
|
||||||
|
cc=list(set(cc)),
|
||||||
|
to=[ap.AS_PUBLIC],
|
||||||
|
content=content,
|
||||||
|
tag=tags,
|
||||||
|
source={"mediaType": "text/markdown", "content": source},
|
||||||
|
inReplyTo=None,
|
||||||
|
**choices,
|
||||||
|
)
|
||||||
|
|
||||||
|
question = ap.Question(**raw_question)
|
||||||
|
create = question.build_create()
|
||||||
|
create_id = post_to_outbox(create)
|
||||||
|
|
||||||
|
return _user_api_response(activity=create_id)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/stream")
|
@app.route("/api/stream")
|
||||||
@api_required
|
@api_required
|
||||||
def api_stream():
|
def api_stream():
|
||||||
|
@ -2583,6 +2697,7 @@ def task_process_new_activity():
|
||||||
if not note.inReplyTo or note.inReplyTo.startswith(ID):
|
if not note.inReplyTo or note.inReplyTo.startswith(ID):
|
||||||
tag_stream = True
|
tag_stream = True
|
||||||
|
|
||||||
|
# FIXME(tsileo): check for direct addressing in the to, cc, bcc... fields
|
||||||
if (note.inReplyTo and note.inReplyTo.startswith(ID)) or note.has_mention(
|
if (note.inReplyTo and note.inReplyTo.startswith(ID)) or note.has_mention(
|
||||||
ID
|
ID
|
||||||
):
|
):
|
||||||
|
@ -2734,8 +2849,6 @@ def task_cleanup():
|
||||||
task = p.parse(request)
|
task = p.parse(request)
|
||||||
app.logger.info(f"task={task!r}")
|
app.logger.info(f"task={task!r}")
|
||||||
p.push({}, "/task/cleanup_part_1")
|
p.push({}, "/task/cleanup_part_1")
|
||||||
p.push({}, "/task/cleanup_part_2")
|
|
||||||
p.push({}, "/task/cleanup_part_3")
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@ -2847,6 +2960,17 @@ def task_cleanup_part_1():
|
||||||
},
|
},
|
||||||
{"$set": {"meta.keep": False}},
|
{"$set": {"meta.keep": False}},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DB.activities.update_many(
|
||||||
|
{
|
||||||
|
"box": Box.OUTBOX.value,
|
||||||
|
"type": ActivityType.CREATE.value,
|
||||||
|
"meta.public": {"$exists": False},
|
||||||
|
},
|
||||||
|
{"$set": {"meta.public": True}},
|
||||||
|
)
|
||||||
|
|
||||||
|
p.push({}, "/task/cleanup_part_2")
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
|
@ -2870,6 +2994,7 @@ def task_cleanup_part_2():
|
||||||
MEDIA_CACHE.fs.delete(grid_item._id)
|
MEDIA_CACHE.fs.delete(grid_item._id)
|
||||||
DB.activities.delete_one({"_id": data["_id"]})
|
DB.activities.delete_one({"_id": data["_id"]})
|
||||||
|
|
||||||
|
p.push({}, "/task/cleanup_part_3")
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
|
|
13
config.py
13
config.py
|
@ -144,6 +144,19 @@ def create_indexes():
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DB.activities.create_index([("box", pymongo.ASCENDING)])
|
||||||
|
|
||||||
|
# Outbox query
|
||||||
|
DB.activities.create_index(
|
||||||
|
[
|
||||||
|
("box", pymongo.ASCENDING),
|
||||||
|
("type", pymongo.ASCENDING),
|
||||||
|
("meta.undo", pymongo.ASCENDING),
|
||||||
|
("meta.deleted", pymongo.ASCENDING),
|
||||||
|
("meta.public", pymongo.ASCENDING),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
DB.activities.create_index(
|
DB.activities.create_index(
|
||||||
[
|
[
|
||||||
("type", pymongo.ASCENDING),
|
("type", pymongo.ASCENDING),
|
||||||
|
|
|
@ -329,3 +329,25 @@ input[type=submit] {
|
||||||
.note-video {
|
.note-video {
|
||||||
margin: 30px 0 10px 0;
|
margin: 30px 0 10px 0;
|
||||||
}
|
}
|
||||||
|
li.answer {
|
||||||
|
height:30px;
|
||||||
|
margin-bottom:10px;
|
||||||
|
position:relative;
|
||||||
|
}
|
||||||
|
.answer .answer-bar {
|
||||||
|
position:absolute;
|
||||||
|
height:30px;
|
||||||
|
border-radius:2px;
|
||||||
|
}
|
||||||
|
.answer .answer-text {
|
||||||
|
position:relative;
|
||||||
|
top:6px;
|
||||||
|
padding-left:10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.answer .answer-text > span {
|
||||||
|
width:70px;
|
||||||
|
display:inline-block;
|
||||||
|
}
|
||||||
|
|
|
@ -12,9 +12,13 @@
|
||||||
<h3 style="padding-bottom: 30px">Replying to {{ content }}</h3>
|
<h3 style="padding-bottom: 30px">Replying to {{ content }}</h3>
|
||||||
{{ utils.display_thread(thread) }}
|
{{ utils.display_thread(thread) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<h3 style="padding-bottom:20px;">New note</h3>
|
{% if request.args.get("question") == "1" %}
|
||||||
|
<h3 style="padding-bottom:20px;">New question <small><a href="/admin/new">make it a note?</a></small></h3>
|
||||||
|
{% else %}
|
||||||
|
<h3 style="padding-bottom:20px;">New note <small><a href="/admin/new?question=1">make it a question?</a></small></h3>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form action="/api/new_note" method="POST" enctype="multipart/form-data">
|
{% endif %}
|
||||||
|
<form action="/api/new_{% if request.args.get("question") == "1" %}question{%else%}note{%endif%}" method="POST" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="redirect" value="/">
|
<input type="hidden" name="redirect" value="/">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
{% if reply %}<input type="hidden" name="reply" value="{{reply}}">{% endif %}
|
{% if reply %}<input type="hidden" name="reply" value="{{reply}}">{% endif %}
|
||||||
|
@ -27,7 +31,32 @@
|
||||||
|
|
||||||
<textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on">{{ content }}</textarea>
|
<textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on">{{ content }}</textarea>
|
||||||
<input type="file" name="file">
|
<input type="file" name="file">
|
||||||
|
|
||||||
|
{% if request.args.get("question") == "1" %}
|
||||||
<div style="margin-top:20px;">
|
<div style="margin-top:20px;">
|
||||||
|
<p>Open for: <select name="open_for">
|
||||||
|
<option value="30">30 minutes</option>
|
||||||
|
<option value="60">1 hour</option>
|
||||||
|
<option value="360">6 hour</option>
|
||||||
|
<option value="1440" selected>1 day</option>
|
||||||
|
<option value="4320">3 days</option>
|
||||||
|
<option value="10080">7 days</option>
|
||||||
|
</select></p>
|
||||||
|
|
||||||
|
<input type="hidden" name="of" value="oneOf" />
|
||||||
|
<!--
|
||||||
|
<p><select name="of">
|
||||||
|
<option value="oneOf">Single choice</option>
|
||||||
|
<option value="anyOf">Multiple choices</option>
|
||||||
|
</select></p>-->
|
||||||
|
|
||||||
|
{% for i in range(4) %}
|
||||||
|
<p><input type="text" name="answer{{i}}" placeholder="Answer #{{i+1}}"></p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<input type="submit" value="post">
|
<input type="submit" value="post">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -15,8 +15,6 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% macro display_note(obj, perma=False, ui=False, likes=[], shares=[], meta={}, no_color=False) -%}
|
{% macro display_note(obj, perma=False, ui=False, likes=[], shares=[], meta={}, no_color=False) -%}
|
||||||
{% if meta.actor %}
|
{% if meta.actor %}
|
||||||
{% set actor = meta.actor %}
|
{% set actor = meta.actor %}
|
||||||
|
@ -24,6 +22,20 @@
|
||||||
{% set actor = obj.attributedTo | get_actor %}
|
{% set actor = obj.attributedTo | get_actor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
{% if session.logged_in %}
|
||||||
|
{% set perma_id = obj.id | permalink_id %}
|
||||||
|
|
||||||
|
{% if request.args.get('older_than') %}
|
||||||
|
{% set redir = request.path + "?older_than=" + request.args.get('older_than') + "#activity-" + perma_id %}
|
||||||
|
{% elif request.args.get('newer_than') %}
|
||||||
|
{% set redir = request.path + "?newer_than=" + request.args.get('newer_than') + "#activity-" + perma_id %}
|
||||||
|
{% else %}
|
||||||
|
{% set redir = request.path + "#activity-" + perma_id %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="note-box">
|
<div class="note-box">
|
||||||
<div class="note h-entry" id="activity-{{ obj.id | permalink_id }}">
|
<div class="note h-entry" id="activity-{{ obj.id | permalink_id }}">
|
||||||
|
|
||||||
|
@ -58,11 +70,67 @@
|
||||||
{{ obj.name }} <a href="{{ obj | url_or_id | get_url }}">{{ obj | url_or_id | get_url }}</a>
|
{{ obj.name }} <a href="{{ obj | url_or_id | get_url }}">{{ obj | url_or_id | get_url }}</a>
|
||||||
{% elif obj | has_type('Question') %}
|
{% elif obj | has_type('Question') %}
|
||||||
{{ obj.content | clean | safe }}
|
{{ obj.content | clean | safe }}
|
||||||
<ul>
|
|
||||||
|
{% if obj.id | is_from_outbox %}
|
||||||
|
<ul style="list-style:none;padding:0;">
|
||||||
|
{% set total_votes = [0] %}
|
||||||
{% for oneOf in obj.oneOf %}
|
{% for oneOf in obj.oneOf %}
|
||||||
<li>{{ oneOf.name }} ({{ oneOf.replies.totalItems }})</li>
|
{% if oneOf.replies %}
|
||||||
|
{% if total_votes.append(total_votes.pop() + oneOf.replies.totalItems) %}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for oneOf in obj.oneOf %}
|
||||||
|
{% set pct = 0 %}
|
||||||
|
{% if total_votes[0] > 0 and oneOf.replies %}
|
||||||
|
{% set pct = oneOf.replies.totalItems * 100.0 / total_votes[0] %}
|
||||||
|
{% endif %}
|
||||||
|
<li class="answer">
|
||||||
|
<span class="answer-bar color-menu-background" style="width:{{pct}}%;"></span>
|
||||||
|
<span class="answer-text">
|
||||||
|
<span>{{ '%0.0f'| format(pct) }}%</span>
|
||||||
|
{{ oneOf.name }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
<p><small>
|
||||||
|
{% if obj.closed %}
|
||||||
|
Ended {{ obj.endTime | format_timeago }} with <strong>{{ total_votes[0] }}</strong> vote{% if total_votes[0] | gtone %}s{% endif %}.
|
||||||
|
{% else %}
|
||||||
|
Ends {{ obj.endTime | format_timeago }} (<strong>{{ total_votes[0] }}</strong> vote{% if total_votes[0] | gtone %}s{% endif %} as of now).
|
||||||
|
{% endif %}
|
||||||
|
</small></p>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<ul style="list-style:none;padding:0;">
|
||||||
|
{% for oneOf in obj.oneOf %}
|
||||||
|
<li class="answer">
|
||||||
|
<span class="answer-text">
|
||||||
|
|
||||||
|
{% if not meta.voted_for and not obj.endTime | gtnow %}
|
||||||
|
<span><form action="/api/vote" class="action-form" method="POST">
|
||||||
|
<input type="hidden" name="redirect" value="{{ redir }}">
|
||||||
|
<input type="hidden" name="id" value="{{ obj.id }}">
|
||||||
|
<input type="hidden" name="choice" value="{{ oneOf.name }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="bar-item">vote</button>
|
||||||
|
</form></span>
|
||||||
|
{% else %}
|
||||||
|
<span>???</span>
|
||||||
|
{% endif %}
|
||||||
|
{{ oneOf.name }} {% if oneOf.name == meta.voted_for %}(your vote){% endif %}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
<p><small>{% if obj.endTime | gtnow %}This question ended {{ obj.endTime | format_timeago }}.</small></p>
|
||||||
|
{% else %}This question ends {{ obj.endTime | format_timeago }}{% endif %}
|
||||||
|
</small></p>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ obj.content | clean | safe }}
|
{{ obj.content | clean | safe }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -120,17 +188,6 @@
|
||||||
<a class ="bar-item" href="{{ obj | url_or_id | get_url }}">permalink</a>
|
<a class ="bar-item" href="{{ obj | url_or_id | get_url }}">permalink</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if session.logged_in %}
|
|
||||||
{% set perma_id = obj.id | permalink_id %}
|
|
||||||
|
|
||||||
{% if request.args.get('older_than') %}
|
|
||||||
{% set redir = request.path + "?older_than=" + request.args.get('older_than') + "#activity-" + perma_id %}
|
|
||||||
{% elif request.args.get('newer_than') %}
|
|
||||||
{% set redir = request.path + "?newer_than=" + request.args.get('newer_than') + "#activity-" + perma_id %}
|
|
||||||
{% else %}
|
|
||||||
{% set redir = request.path + "#activity-" + perma_id %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% set aid = obj.id | quote_plus %}
|
{% set aid = obj.id | quote_plus %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
|
@ -20,11 +20,14 @@ def links_from_note(note):
|
||||||
tags_href.add(h)
|
tags_href.add(h)
|
||||||
|
|
||||||
links = set()
|
links = set()
|
||||||
soup = BeautifulSoup(note["content"])
|
if "content" in note:
|
||||||
for link in soup.find_all("a"):
|
soup = BeautifulSoup(note["content"])
|
||||||
h = link.get("href")
|
for link in soup.find_all("a"):
|
||||||
if h.startswith("http") and h not in tags_href and is_url_valid(h):
|
h = link.get("href")
|
||||||
links.add(h)
|
if h.startswith("http") and h not in tags_href and is_url_valid(h):
|
||||||
|
links.add(h)
|
||||||
|
|
||||||
|
# FIXME(tsileo): support summary and name fields
|
||||||
|
|
||||||
return links
|
return links
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue