mirror of https://git.skyjake.fi/gemini/bubble.git
1041 lines
46 KiB
Python
1041 lines
46 KiB
Python
import re
|
||
import urllib.parse as urlparse
|
||
from model import User, Post, Segment, Subspace, LogEntry, Commit, Crossref, \
|
||
FOLLOW_SUBSPACE, FOLLOW_USER, FOLLOW_POST, \
|
||
MUTE_SUBSPACE, MUTE_USER, MUTE_POST
|
||
from subspace import subspace_admin_actions
|
||
from user import make_user_index_page
|
||
from utils import *
|
||
|
||
|
||
def make_post_page_or_configure_feed(session):
|
||
# This function may return None, in which case should make a feed page.
|
||
# TODO: Should refactor this to make the session configuration a separate step.
|
||
|
||
db = session.db
|
||
req = session.req
|
||
path = req.path[len(session.path):]
|
||
found = re.match(r'(u|s)/([\w%-]+)(/(post|compose|image|file|issues|admin|tag|index))?(/([\w\d-]+)(.*))?', path)
|
||
if not found and not path.startswith('tag'):
|
||
return 59, 'Bad request'
|
||
|
||
# Set up the feed parameters.
|
||
if found:
|
||
session.feed_type = found.group(1)
|
||
url_name = urlparse.unquote(found.group(2))
|
||
action = found.group(4)
|
||
arg = found.group(6)
|
||
arg2 = found.group(7)
|
||
else:
|
||
# Tag filtering All Posts.
|
||
found = re.match(r'(tag)(/([\w\d-]+)(.*))?', path)
|
||
action = found[1]
|
||
arg = found[3]
|
||
arg2 = found[4]
|
||
|
||
if session.feed_type == 'u':
|
||
session.c_user = db.get_user(name=url_name)
|
||
if not session.c_user:
|
||
return 51, 'Not found: u/' + url_name
|
||
session.context = db.get_subspace(owner=session.c_user.id)
|
||
session.is_context_locked = False
|
||
session.is_user_mod = (session.user.id == session.c_user.id or \
|
||
session.user.role == User.ADMIN) if session.user else False
|
||
elif session.feed_type == 's':
|
||
session.c_user = None
|
||
session.context = db.get_subspace(name=url_name)
|
||
if not session.context:
|
||
return 51, 'Not found: s/' + url_name
|
||
if session.context.owner:
|
||
return 30, '/u' + req.path[2:]
|
||
session.get_mods(session.context.id)
|
||
session.is_user_mod = \
|
||
(session.user.role == User.ADMIN or \
|
||
session.user.id in map(lambda m: m.id, session.context_mods)) \
|
||
if session.user else False
|
||
if session.user and session.user.role == User.ADMIN:
|
||
session.is_context_locked = False # admin is unaffected by locks
|
||
else:
|
||
session.is_context_locked = len(session.context_mods) == 0
|
||
session.is_context_tracker = (session.context.flags & Subspace.ISSUE_TRACKER) != 0
|
||
|
||
if session.is_context_tracker:
|
||
session.feed_mode = 'open'
|
||
if req.query != None:
|
||
params = req.query.split('&')
|
||
if 'open' in params and 'closed' in params:
|
||
session.feed_mode = 'all'
|
||
elif 'open' in params:
|
||
session.feed_mode = 'open'
|
||
elif 'closed' in params:
|
||
session.feed_mode = 'closed'
|
||
|
||
if session.feed_type == 's' and action == 'admin':
|
||
return subspace_admin_actions(session, arg)
|
||
|
||
if session.is_context_tracker and action == 'issues':
|
||
return 30, f"/s/{session.context.name}/{arg}"
|
||
|
||
if session.feed_type == 'u' and action in ('image', 'file'):
|
||
file = db.get_file(int(arg))
|
||
return 20, file.mimetype, file.data
|
||
|
||
if session.feed_type == 'u' and action == 'index':
|
||
return make_user_index_page(session, found[6])
|
||
|
||
if action == 'tag':
|
||
if not arg:
|
||
page = f'Choose a tag for filtering {session.context.title() if session.context else "All Posts"}:\n'
|
||
page += f'=> {session.path}{session.context.title() if session.context else ""} ❌ None\n\n'
|
||
for tag in sorted(db.get_popular_tags(session.context), key=str.lower):
|
||
if tag == Post.TAG_CLOSED:
|
||
continue
|
||
page += f'=> {session.path}{session.context.title() + "/" if session.context else ""}tag/{tag} 🏷️ #{tag}\n'
|
||
return page
|
||
session.feed_tag_filter = arg
|
||
arg = None # don't show a post page
|
||
|
||
elif action == 'compose':
|
||
if not session.user:
|
||
return 60, 'Login required'
|
||
if session.c_user and session.user.id != session.c_user.id:
|
||
return 61, "Cannot post to another user's subspace"
|
||
if session.is_context_locked:
|
||
return 61, "Subspace is locked"
|
||
if session.user.role == User.LIMITED and (not session.c_user or
|
||
session.c_user.id != session.user.id):
|
||
return 61, "Not authorized"
|
||
if session.user.role == User.LIMITED and not db.verify_token(session.user, arg):
|
||
return 61, "Expired"
|
||
if session.user.role == User.LIMITED and \
|
||
db.get_access_rate(3600, req.remote_address, LogEntry.POST_CREATED) >= session.bubble.rate_post:
|
||
return 44, "Rate limit exceeded"
|
||
draft_id = db.create_post(session.user, session.context.id)
|
||
db.add_log_entry(req.remote_address, LogEntry.POST_CREATED)
|
||
return 30, '/edit/%d' % draft_id
|
||
|
||
elif action == 'post':
|
||
if not session.user:
|
||
return 60, 'Login required'
|
||
if session.c_user and session.user.id != session.c_user.id:
|
||
return 61, "Cannot post to another user's subspace"
|
||
if session.is_context_locked:
|
||
return 61, "Subspace is locked"
|
||
if session.user.role == User.LIMITED and not session.c_user:
|
||
return 61, "Not authorized"
|
||
if session.user.role == User.LIMITED and not db.verify_token(session.user, arg):
|
||
return 61, "Expired"
|
||
if session.user.role == User.LIMITED and \
|
||
db.get_access_rate(3600, req.remote_address, LogEntry.POST_CREATED) >= session.bubble.rate_post:
|
||
return 44, "Rate limit exceeded"
|
||
|
||
if session.is_gemini:
|
||
if is_empty_query(req):
|
||
if session.is_context_tracker:
|
||
return 10, f'Title for new issue in {session.context.title()}:'
|
||
return 10, f'New post in {session.context.title()}: (see Help for special commands)'
|
||
seg_text = clean_query(req)
|
||
else:
|
||
if not req.content_mime.startswith('text/'):
|
||
return 50, 'Content must be text'
|
||
seg_text = req.content.decode('utf-8')
|
||
|
||
# Check special commands.
|
||
title = None
|
||
body = seg_text
|
||
url = None
|
||
special = None
|
||
if len(seg_text) == 0:
|
||
special = 'draft'
|
||
elif seg_text == '.' or seg_text == '/':
|
||
special = 'draft'
|
||
body = ''
|
||
elif seg_text == ':':
|
||
if session.is_context_tracker:
|
||
return 50, 'Not supported when posting issues'
|
||
return 30, session.server_root('titan') + req.path
|
||
elif seg_text.endswith('\\'):
|
||
body = seg_text[:-1].strip()
|
||
special = 'draft'
|
||
|
||
# Detect a solitary link, and a headline on the first line.
|
||
lines = body.split('\n')
|
||
|
||
if len(lines) == 1:
|
||
link = re.match(r'^\s*(=>\s*)?((gemini|gopher|finger|https?)://[^ ]+)(\s+(.*))?', lines[0])
|
||
if link:
|
||
url = link[2]
|
||
body = link[5] if link[5] else ''
|
||
title = ''
|
||
|
||
if not url:
|
||
found = re.match(r'^\s*#\s*(.+)$', lines[0])
|
||
if found:
|
||
title = found[1]
|
||
body = '\n'.join(lines[1:]).strip()
|
||
elif session.is_context_tracker:
|
||
title = lines[0]
|
||
body = '\n'.join(lines[1:]).strip()
|
||
else:
|
||
title = ''
|
||
|
||
post_id = db.create_post(session.user, session.context.id, title=title)
|
||
post = db.get_post(post_id, draft=True)
|
||
if url:
|
||
db.create_segment(post, Segment.LINK, url=url, content=body)
|
||
elif body:
|
||
if session.user.flags & User.COMPOSER_SPLIT_FLAG and session.is_gemini:
|
||
parts = split_paragraphs(body)
|
||
else:
|
||
parts = [body]
|
||
for part in parts:
|
||
db.create_segment(post, Segment.TEXT, content=part)
|
||
# If there are plain URIs in the content, make segments for them.
|
||
nonlinks = parse_nonlink_uris(body)
|
||
if nonlinks:
|
||
db.update_post(post, flags=post.flags | Post.DISABLE_FEATURED_LINK_FLAG)
|
||
for uri in nonlinks:
|
||
db.create_segment(post, Segment.LINK, url=uri, content='')
|
||
db.update_post_summary(post)
|
||
|
||
# Further content is required for issues.
|
||
if (session.is_context_tracker and not body) or special == 'draft':
|
||
return 30, f'{session.server_root()}/edit/{post.id}'
|
||
|
||
db.publish_post(post)
|
||
db.add_log_entry(req.remote_address, LogEntry.POST_CREATED)
|
||
return 30, session.server_root() + post.page_url()
|
||
|
||
if session.user:
|
||
session.user_follows = db.get_follows(session.user)
|
||
session.user_mutes = db.get_mutes(session.user)
|
||
|
||
if arg:
|
||
# Viewing a single post.
|
||
post = None
|
||
if session.is_context_tracker:
|
||
# In issue trackers, posts are identified by the issue numbers.
|
||
post = db.get_post_for_issueid(session.context, int(arg))
|
||
if not post:
|
||
post = db.get_post(id=int(arg), draft=False)
|
||
if not post:
|
||
# Maybe it is a draft?
|
||
if session.user:
|
||
post = db.get_post(id=int(arg), draft=True)
|
||
if post and post.user == session.user.id:
|
||
return 30, f'/edit/{post.id}'
|
||
return 51, 'Not found'
|
||
|
||
if post.parent != 0:
|
||
return make_post_page(session, post)
|
||
|
||
if post.subspace != session.context.id:
|
||
# Redirect to the correct subspace.
|
||
post_sub = db.get_subspace(id=post.subspace)
|
||
if post_sub.flags & Subspace.ISSUE_TRACKER:
|
||
return 31, f'{session.path}{post_sub.title()}/{post.issueid}'
|
||
else:
|
||
return 31, f'{session.path}{post_sub.title()}/{post.id}'
|
||
|
||
if arg2 == '/more':
|
||
if not session.user:
|
||
return 60, "Login required"
|
||
|
||
# Additional post actions.
|
||
page = session.gemini_feed_entry(post)
|
||
page += "\n## More Actions\n"
|
||
|
||
actions = []
|
||
kind = 'issue' if session.is_context_tracker else 'post'
|
||
|
||
if not session.is_context_tracker and (session.user.id == post.user or session.is_user_mod):
|
||
actions.append(f'=> /edit-tags/{post.id} 🏷️ Add/remove tags\n')
|
||
actions.append(f'=> /remind/{post.id} 🔔 Remind me\n')
|
||
if session.is_lockable(post):
|
||
if post.flags & Post.LOCKED_FLAG:
|
||
actions.append(f'=> /unlock/{post.id} 🔓 Unlock comments\n')
|
||
else:
|
||
actions.append(f'=> /lock/{post.id} 🔒 Lock comments\n')
|
||
if session.is_moderated(post) and post.user != session.user.id:
|
||
if session.user.role == User.ADMIN:
|
||
actions.append(f'=> /settings/flair/{post.poster_name}/add/ 📛 Set flair on {post.poster_name}\n')
|
||
else:
|
||
actions.append(f'=> /settings/flair/{post.poster_name}/add/-/{post.sub_name} 📛 Set subspace flair on {post.poster_name}\n')
|
||
if session.is_movable(post):
|
||
actions.append(f'=> /edit/{post.id}/move/{session.get_token()} Move to subspace\n')
|
||
if post.user != session.user.id and not session.is_user_mod and session.user.role != User.ADMIN:
|
||
actions.append(f'=> /report/{post.id} ⚠️ Report\n')
|
||
|
||
if actions:
|
||
actions.append('\n')
|
||
|
||
if session.user.id != post.user:
|
||
if (FOLLOW_POST, post.id) in session.user_follows:
|
||
actions.append(f'=> /unfollow/post/{post.id} ➖ Unfollow {kind}\n')
|
||
else:
|
||
if (MUTE_POST, post.id) in session.user_mutes:
|
||
actions.append(f'=> /unmute/post/{post.id} 🔈 Unmute {kind}\n')
|
||
else:
|
||
actions.append(f'=> /follow/post/{post.id} ➕ Follow {kind}\n')
|
||
actions.append(f'=> /mute/post/{post.id} 🔇 Mute {kind}\n')
|
||
else:
|
||
# Own posts can be muted.
|
||
if (MUTE_POST, post.id) in session.user_mutes:
|
||
actions.append(f'=> /unmute/post/{post.id} 🔈 Unmute {kind}\n')
|
||
else:
|
||
actions.append(f'=> /mute/post/{post.id} 🔇 Mute {kind}\n')
|
||
|
||
if session.is_antenna_enabled() and session.user.id == post.user and not post.parent:
|
||
actions.append('\n')
|
||
for i, label in enumerate(session.bubble.antenna_labels):
|
||
actions.append(f'=> /transmit/{i}/post/{post.id} Submit {kind} to {label}\n')
|
||
|
||
if session.is_deletable(post) and not session.is_editable(post):
|
||
actions.append('\n')
|
||
actions.append(f'=> /edit/{post.id}/delete/{session.get_token()} ❌ Delete {kind}\n')
|
||
|
||
if actions:
|
||
page += ''.join(actions)
|
||
return page
|
||
|
||
if arg2 == '/group':
|
||
subspace = db.get_subspace(id=post.subspace)
|
||
if session.c_user:
|
||
page = f'# {session.c_user.avatar} {session.c_user.name}\n'
|
||
if session.c_user.flair:
|
||
flair = User.render_flair(session.c_user.flair, session.context,
|
||
long_form=True, db=session.db)
|
||
if flair:
|
||
page += f"\n{flair}\n"
|
||
else:
|
||
page = f'# {subspace.title()}\n'
|
||
page += f'=> /{subspace.title()} {subspace.title()}\n'
|
||
page += '=> / Back to front page\n'
|
||
page += '\n## Grouped Posts\n'
|
||
user = session.user
|
||
for post in db.get_posts(rotation_group_of_post=post,
|
||
comment=False,
|
||
draft=False,
|
||
notifs_for_user_id=user.id if user else 0,
|
||
muted_by_user_id=user.id if user else 0):
|
||
page += '\n'
|
||
page += session.feed_entry(post, session.context, omit_rotate_info=True)
|
||
return page
|
||
|
||
if arg2 == '/antenna':
|
||
# Special viewing mode for Antenna submissions, with the bare minimum.
|
||
page = f'# {session.feed_title()}\n'
|
||
page += session.gemini_feed_entry(post, session.context)
|
||
return page
|
||
|
||
if arg2.startswith('/clear-notif/'):
|
||
token = arg2[arg2.rindex('/') + 1:]
|
||
if not db.verify_token(session.user, token):
|
||
return 61, "Not authorized"
|
||
db.get_notifications(session.user, post_id=post.id, clear=True)
|
||
return 30, post.page_url()
|
||
|
||
return make_post_page(session, post)
|
||
|
||
return None
|
||
|
||
|
||
def make_post_page(session, post):
|
||
"""Page containing a post and its discussion thread. If `post` is a comment, a partial
|
||
discussion thread is shown."""
|
||
|
||
db = session.db
|
||
user = session.user
|
||
post_id = post.id
|
||
is_comment_page = post.parent != 0
|
||
display_order_desc = session.user and \
|
||
session.user.sort_cmt == User.SORT_COMMENT_NEWEST
|
||
page = ''
|
||
focused_cmt = None
|
||
|
||
def commenter_flair(cmt, post, abbreviate, with_context=None):
|
||
flair = User.render_flair(cmt.poster_flair,
|
||
with_context if with_context else session.context,
|
||
abbreviate=abbreviate,
|
||
user_mod=cmt.user in session.context_mod_ids,
|
||
user_op=post and cmt.user == post.user)
|
||
return f' [{flair}]' if flair else ''
|
||
|
||
if is_comment_page:
|
||
# Switch to the parent post, but display it in preview mode.
|
||
focused_cmt = post
|
||
post_id = post.parent
|
||
post = db.get_post(id=post_id)
|
||
page += f'# Comment by {focused_cmt.poster_avatar} {focused_cmt.poster_name}\n\n'
|
||
if post:
|
||
session.get_mods(post.subspace)
|
||
page += f'=> {post.page_url()} Re: {post.quoted_title()}\n'
|
||
sub_name = ("u/" if post.sub_owner else "s/") + post.sub_name
|
||
page += f'=> /{sub_name} In: {sub_name}\n\n'
|
||
else:
|
||
if not session.user or session.user.id != focused_cmt.user:
|
||
# Can't view others' comments on deleted posts, just your own.
|
||
return 51, 'Not found'
|
||
page += f'=> /help/deleted-post 🔒 Comment on a deleted post (ID:{post_id})\n\n'
|
||
page += session.render_post(focused_cmt)
|
||
flair = commenter_flair(focused_cmt, post,
|
||
abbreviate=False,
|
||
with_context=db.get_subspace(post.subspace))
|
||
page += f'\n=> /u/{focused_cmt.poster_name} {focused_cmt.poster_avatar} {focused_cmt.poster_name}{flair}\n'
|
||
page += f'{focused_cmt.age()}\n'
|
||
|
||
# Comment actions.
|
||
if user:
|
||
actions = []
|
||
if post:
|
||
if session.is_editable(focused_cmt):
|
||
actions.append(f'=> /edit/{focused_cmt.id} ✏️ Edit\n')
|
||
if post and session.user and not session.is_context_locked and \
|
||
session.user.role != User.LIMITED and session.is_commenting_enabled(post):
|
||
actions.append(f'=> /comment/{post.id} 💬 Comment\n')
|
||
if session.is_thanks_enabled() and focused_cmt.user != user.id:
|
||
actions.append(f'=> /thanks/{focused_cmt.id} 🙏 Give thanks\n')
|
||
actions.append(f'=> /remind/{focused_cmt.id} 🔔 Remind me\n')
|
||
|
||
if session.is_moderated(focused_cmt) and focused_cmt.user != user.id:
|
||
if session.user.role == User.ADMIN:
|
||
actions.append(f'=> /settings/flair/{focused_cmt.poster_name}/add/ 📛 Set flair on {focused_cmt.poster_name}\n')
|
||
else:
|
||
actions.append(f'=> /settings/flair/{focused_cmt.poster_name}/add/-/{focused_cmt.sub_name} 📛 Set subspace flair on {focused_cmt.poster_name}\n')
|
||
|
||
actions.append(f'=> /report/{focused_cmt.id} ⚠️ Report\n')
|
||
if not session.is_editable(focused_cmt) and session.is_deletable(focused_cmt):
|
||
actions.append(f'=> /edit/{focused_cmt.id}/delete/{session.get_token()} ❌ Delete comment\n')
|
||
else:
|
||
if session.is_deletable(focused_cmt):
|
||
actions.append(f'=> /edit/{focused_cmt.id}/delete/{session.get_token()} ❌ Delete comment\n')
|
||
if actions:
|
||
page += '\n## Actions\n' + ''.join(actions)
|
||
page += '\n' + session.dashboard_link()
|
||
|
||
if post:
|
||
op_section = '\n# Original Post\n\n' + session.feed_entry(post)
|
||
else:
|
||
op_section = ''
|
||
|
||
else:
|
||
page += session.render_post(post)
|
||
|
||
commits = []
|
||
incoming_xrefs = []
|
||
outgoing_xrefs = {}
|
||
repo = None
|
||
|
||
# Poll options/results.
|
||
if post:
|
||
poll = session.render_poll(post, show_results=not session.user)
|
||
if poll:
|
||
# Ensure separation.
|
||
if len(page) and not page.endswith('\n\n'):
|
||
page += '\n'
|
||
page += poll
|
||
|
||
if post.issueid:
|
||
repo = db.get_repository(subspace=session.context)
|
||
commits = db.get_commits(repo, issueid=post.issueid)
|
||
if not is_comment_page:
|
||
incoming_xrefs = db.get_issue_crossrefs(session.context,
|
||
incoming_to_issueid=post.issueid)
|
||
outgoing_xrefs = db.get_issue_crossrefs(session.context,
|
||
outgoing_from_issueid=post.issueid)
|
||
|
||
# Issue and commit cross references outgoing from the post body.
|
||
if repo and repo.view_url:
|
||
first = True
|
||
for commit in db.find_commits_by_hash(repo, parse_likely_commit_hashes(page)):
|
||
if first:
|
||
page += '\n'
|
||
first = False
|
||
page += commit.entry(repo.view_url, outgoing=True)
|
||
if outgoing_xrefs and post_id in outgoing_xrefs:
|
||
page += '\n'
|
||
for xref in outgoing_xrefs[post_id]:
|
||
page += xref.outgoing_entry()
|
||
else:
|
||
repo = None
|
||
|
||
if not is_comment_page:
|
||
# Post metadata.
|
||
if len(page):
|
||
page += '\n'
|
||
if post.tags:
|
||
page += '### ' + post.tags + '\n'
|
||
#flair = f" [{post.poster_flair}]" if post.poster_flair else ""
|
||
|
||
flair = User.render_flair(post.poster_flair,
|
||
session.context,
|
||
user_mod=post.user in session.context_mod_ids)
|
||
if flair: flair = f" [{flair}]"
|
||
|
||
poster_link = f'=> /u/{post.poster_name} {post.poster_avatar} {post.poster_name}{flair}\n'
|
||
if session.is_context_tracker:
|
||
page += f'=> /{session.context.title()} 🐞 Issue #{post.issueid} in {session.context.title()}\n'
|
||
elif not session.c_user:
|
||
page += f'=> /{session.context.title()} Posted in: {session.context.title()}\n'
|
||
page += poster_link
|
||
post_age = post.age() if not session.is_archive else post.ymd_hm()
|
||
page += f'{post_age}'
|
||
if (MUTE_POST, post.id) in session.user_mutes:
|
||
page += ' 🔇'
|
||
if session.is_likes_enabled():
|
||
liked = []
|
||
if post.num_likes:
|
||
liked = db.get_likes(post, session.user_mutes)
|
||
page += ' · 👍 ' + ', '.join(liked)
|
||
if session.is_reactions_enabled():
|
||
reactions = db.get_reactions(post, session.user_mutes)
|
||
listed = []
|
||
for r in reactions:
|
||
listed.append(f'{r} {reactions[r]}')
|
||
if listed:
|
||
page += ' · ' + ' '.join(listed)
|
||
if post.flags & Post.LOCKED_FLAG:
|
||
page += '\n🔒 Locked'
|
||
page += '\n'
|
||
|
||
# Post actions.
|
||
kind = 'issue' if session.is_context_tracker else 'post'
|
||
if session.user and not session.is_context_locked:
|
||
page += '\n## Actions\n'
|
||
if session.is_editable(post):
|
||
page += f'=> /edit/{post.id} ✏️ Edit {kind}\n'
|
||
if session.user.role != User.LIMITED and session.is_commenting_enabled(post):
|
||
page += f'=> /comment/{post.id} 💬 Comment\n'
|
||
|
||
# Reactions.
|
||
if session.user.id != post.user:
|
||
if session.is_likes_enabled():
|
||
if session.user.name not in liked:
|
||
page += f'=> /like/{post.id} 👍 Like\n'
|
||
else:
|
||
page += f'=> /unlike/{post.id} 👎 Undo like\n'
|
||
if session.is_reactions_enabled():
|
||
reaction = db.get_user_reaction(post, session.user.id)
|
||
if reaction:
|
||
page += f'=> /react/{post.id} Change reaction: {reaction}\n'
|
||
else:
|
||
page += f'=> /react/{post.id} {session.bubble.user_reactions[0]} React\n'
|
||
if session.is_thanks_enabled():
|
||
page += f'=> /thanks/{post.id} 🙏 Give thanks\n'
|
||
|
||
# Moderator actions on a post.
|
||
mod_actions = []
|
||
if session.user.id == post.user or session.is_user_mod:
|
||
if not session.is_editable(post) and session.is_title_editable(post):
|
||
mod_actions.append(f'=> /edit/{post.id}/mod-title ✏️ Edit {kind} title\n')
|
||
if session.is_context_tracker:
|
||
mod_actions.append(f'=> /edit-tags/{post.id} 🏷️ Add/remove tags\n')
|
||
if '✔︎' in post.tags:
|
||
mod_actions.append(f'=> /edit-tags/{post.id}/open 🐞 Reopen issue\n')
|
||
else:
|
||
mod_actions.append(f'=> /edit-tags/{post.id}/close ✔︎ Mark as closed\n')
|
||
|
||
if mod_actions:
|
||
page += ''.join(mod_actions)
|
||
|
||
page += f'=> {post.id}/more More...\n'
|
||
|
||
page += '\n' + session.dashboard_link()
|
||
|
||
# Notification on this page.
|
||
if post and user:
|
||
notifs = db.get_notifications(user=user, post_id=post.id, sort_desc=True)
|
||
if notifs:
|
||
page += f'{len(notifs)} notification{plural_s(len(notifs))} about this post:\n'
|
||
for notif in notifs:
|
||
link, label = notif.entry(with_title=False)
|
||
page += f'=> {link} {label}\n'
|
||
if len(notifs) > 1:
|
||
page += f'=> {post.page_url()}/clear-notif/{session.get_token()} 🧹 Clear\n'
|
||
|
||
# Comments, repository commits, and issue cross-references.
|
||
comments = db.get_posts(parent=post_id,
|
||
subspace=(post.subspace if post else None),
|
||
draft=False,
|
||
sort_descending=False,
|
||
muted_by_user_id=(user.id if user else 0),
|
||
limit=None)
|
||
|
||
if is_comment_page:
|
||
if post:
|
||
# Omit comments older than the focused one.
|
||
comments = list(filter(lambda p: p.ts_created > focused_cmt.ts_created, comments))
|
||
else:
|
||
# Deleted post; only see your own comments.
|
||
comments = list(filter(lambda p: p.id != focused_cmt.id and p.user == focused_cmt.user,
|
||
comments))
|
||
|
||
have_other_comments = False
|
||
n = len(comments)
|
||
|
||
if n > 0 or commits or incoming_xrefs:
|
||
have_other_comments = True
|
||
if n > 1:
|
||
dir_icon = ' ↑' if display_order_desc else ' ↓'
|
||
else:
|
||
dir_icon = ''
|
||
page += f'\n## {n} {"Later " if is_comment_page and post else "Other " if is_comment_page else ""}Comment{plural_s(n)}{dir_icon}\n'
|
||
|
||
if commits or incoming_xrefs:
|
||
# Combine commits and commits into one list.
|
||
comments += commits
|
||
comments += incoming_xrefs
|
||
comments.sort(key=lambda c: c.ts if isinstance(c, Commit) else c.ts_created)
|
||
|
||
# TODO: This may need paging when there is a long thread.
|
||
rendered_comments = []
|
||
ts_now = time.time()
|
||
|
||
for cmt in comments:
|
||
|
||
# Commits are shown as links to the Git viewer.
|
||
if isinstance(cmt, Commit):
|
||
if not focused_cmt or cmt.ts > focused_cmt.ts_created:
|
||
rendered_comments.append(cmt.entry(repo.view_url))
|
||
continue
|
||
|
||
# Cross-references incoming from other issues.
|
||
if isinstance(cmt, Crossref):
|
||
rendered_comments.append(cmt.incoming_entry())
|
||
continue
|
||
|
||
if session.is_archive:
|
||
comment_age = cmt.ymd_hm(tz=session.tz, time_prefix='at ')
|
||
else:
|
||
elapsed_hours = (ts_now - cmt.ts_created) / 3600
|
||
comment_age = cmt.age() if elapsed_hours < 24 else \
|
||
cmt.ymd_hm(tz=session.tz, date_fmt='%b %d', time_prefix='at ') if elapsed_hours < 24 * 180 else \
|
||
cmt.ymd_hm(tz=session.tz, time_prefix='at ')
|
||
|
||
cmt_flair = commenter_flair(cmt, post, abbreviate=True)
|
||
if not session.is_archive:
|
||
src = f'=> /u/{cmt.poster_name}/{cmt.id} {cmt.poster_avatar} {cmt.poster_name}{cmt_flair} · {comment_age}:\n'
|
||
else:
|
||
src = f'=> /u/{cmt.poster_name} {cmt.poster_avatar} {cmt.poster_name}{cmt_flair} · {comment_age}:\n'
|
||
comment_body = session.render_post(cmt)
|
||
src += comment_body
|
||
|
||
# Commit references.
|
||
if repo and repo.view_url:
|
||
for commit in db.find_commits_by_hash(repo, parse_likely_commit_hashes(comment_body)):
|
||
src += commit.entry(repo.view_url, outgoing=True)
|
||
|
||
# Cross-references to other issues.
|
||
if outgoing_xrefs and cmt.id in outgoing_xrefs:
|
||
for xref in outgoing_xrefs[cmt.id]:
|
||
src += xref.outgoing_entry()
|
||
|
||
# Hide the `age` if it's the same as the previous entry (in reading order).
|
||
# comment_age = cmt.age() if not session.is_archive else cmt.ymd_hm()
|
||
# if comment_age != last_age:
|
||
# last_age = comment_age
|
||
# else:
|
||
# comment_age = ''
|
||
|
||
# if session.user and (cmt.user == session.user.id or session.is_user_mod) and \
|
||
# not session.is_context_locked:
|
||
# # Actions on your own comments.
|
||
# age_suffix = f" · {comment_age}" if len(comment_age) else comment_age
|
||
# if session.is_editable(cmt) and post:
|
||
# src += f'=> /edit/{cmt.id} ✏️ Edit{age_suffix}\n'
|
||
# elif session.is_deletable(cmt):
|
||
# src += f'=> /edit/{cmt.id}/delete/{session.get_token()} ❌ Delete{age_suffix}\n'
|
||
# elif len(comment_age):
|
||
# src += comment_age + '\n'
|
||
|
||
rendered_comments.append(src)
|
||
|
||
# Print in the appropriate order.
|
||
if display_order_desc:
|
||
rendered_comments.reverse()
|
||
for rendered in rendered_comments:
|
||
page += '\n' + rendered
|
||
|
||
# Show the Comment action at the appropriate place wrt reading direction.
|
||
if (have_other_comments
|
||
and session.user
|
||
and session.user.role != User.LIMITED
|
||
and post
|
||
and session.is_commenting_enabled(post)
|
||
and not session.is_context_locked
|
||
and not display_order_desc
|
||
and (is_comment_page or len(comments) >= 1)):
|
||
page += f'\n=> /comment/{post.id} 💬 Add comment\n'
|
||
|
||
if is_comment_page:
|
||
page += op_section
|
||
|
||
return page
|
||
|
||
|
||
def make_feed_page(session):
|
||
# NOTE: Some parameters were configured in `make_post_page_or_configure_feed()`.
|
||
|
||
req = session.req
|
||
db = session.db
|
||
c_user = session.c_user
|
||
context = session.context
|
||
context_mods = session.context_mods
|
||
is_issue_tracker = session.is_context_tracker
|
||
user = session.user
|
||
user_follows = session.user_follows
|
||
user_mutes = session.user_mutes
|
||
query_params = req.query.split('&') if req.query else []
|
||
page = ''
|
||
|
||
# Determine format of feed.
|
||
is_atom_feed = False
|
||
is_gemini_feed = False
|
||
is_tinylog = False
|
||
is_bubble_feed = False
|
||
|
||
if 'feed' in query_params:
|
||
is_gemini_feed = True
|
||
elif 'atom' in query_params:
|
||
is_atom_feed = True
|
||
elif 'tinylog' in query_params:
|
||
is_tinylog = True
|
||
else:
|
||
is_bubble_feed = True
|
||
|
||
is_flat_feed = (is_bubble_feed
|
||
and not is_issue_tracker
|
||
and user
|
||
and user.sort_post == User.SORT_POST_FLAT)
|
||
feed_sort_mode = Post.SORT_CREATED
|
||
omit_user_subspaces = False
|
||
omit_nonuser_subspaces = False
|
||
rotate_per_day = False
|
||
page_size = 50 if is_gemini_feed else 100 if is_tinylog else 25
|
||
page_index = 0
|
||
|
||
if is_tinylog and not c_user:
|
||
return 51, "Tinylogs are only for user feeds"
|
||
|
||
# Page title.
|
||
if is_atom_feed:
|
||
# Just print the entries, and add the header/footer in the end.
|
||
ts_last_updated = 0
|
||
elif is_gemini_feed or is_tinylog:
|
||
page += f'# {session.feed_title()}\n'
|
||
elif c_user:
|
||
page += f'# {c_user.avatar} {context.title()}\n'
|
||
elif context:
|
||
page += f'# {context.title()}\n'
|
||
else:
|
||
page += session.BANNER
|
||
|
||
# Subspace description.
|
||
topinfo = ''
|
||
if is_atom_feed:
|
||
pass
|
||
elif not context:
|
||
topinfo += f"{session.bubble.site_info if session.user else session.bubble.site_info_nouser}\n"
|
||
else:
|
||
if c_user and (c_user.info or c_user.url or c_user.flair):
|
||
if c_user.info:
|
||
topinfo += clean_description(c_user.info) + '\n'
|
||
if c_user.url:
|
||
topinfo += f'=> {c_user.url}\n'
|
||
if c_user.flair and not is_tinylog:
|
||
flair = User.render_flair(c_user.flair, context=None, long_form=True, db=session.db)
|
||
topinfo += f'\n{flair}'
|
||
elif context:
|
||
if context.info:
|
||
topinfo += clean_description(context.info) + '\n'
|
||
if context.url:
|
||
topinfo += f'=> {context.url}\n'
|
||
# Users moderating this subspace.
|
||
now = time.time()
|
||
for mod in context_mods:
|
||
dormant_days = (now - mod.ts_active) / 3600 / 24
|
||
dormant = f' · 😴 {int(dormant_days)} days' if dormant_days > 60 else ''
|
||
topinfo += f'=> /u/{mod.name} {mod.avatar} Moderated by: {mod.name}{dormant}\n'
|
||
if session.is_context_locked:
|
||
topinfo += '=> /help/locked 🔒 Locked\n'
|
||
|
||
if topinfo:
|
||
page += topinfo if not is_tinylog else re.sub(r"\n+", "\n", clean_tinylog(topinfo))
|
||
page += '\n'
|
||
|
||
filter_by_followed = user if session.feed_mode == 'followed' else None
|
||
filter_issue_status = True if session.feed_mode == 'open' else \
|
||
False if session.feed_mode == 'closed' else None
|
||
|
||
if is_bubble_feed:
|
||
# Sorting mode and current page.
|
||
if user:
|
||
if user.sort_post == User.SORT_POST_HOTNESS:
|
||
feed_sort_mode = Post.SORT_HOTNESS
|
||
elif user.sort_post == User.SORT_POST_ACTIVITY:
|
||
feed_sort_mode = Post.SORT_ACTIVE
|
||
if not is_empty_query(req):
|
||
for param in query_params:
|
||
if param == 'sort=hot':
|
||
feed_sort_mode = Post.SORT_HOTNESS
|
||
elif param == 'sort=new':
|
||
feed_sort_mode = Post.SORT_CREATED
|
||
elif param == 'sort=active':
|
||
feed_sort_mode = Post.SORT_ACTIVE
|
||
elif re.match(r'p\d+', param):
|
||
page_index = int(param[1:]) - 1
|
||
sort_mode = ' 🔥' if feed_sort_mode == Post.SORT_HOTNESS \
|
||
else ' 🗣️' if feed_sort_mode == Post.SORT_ACTIVE else ''
|
||
|
||
if is_flat_feed:
|
||
sort_mode = ' 💬'
|
||
feed_sort_mode = Post.SORT_CREATED
|
||
|
||
if not is_issue_tracker and not context:
|
||
if user:
|
||
omit_user_subspaces = (user.flags & User.HOME_NO_USERS_FEED_FLAG) != 0
|
||
omit_nonuser_subspaces = (user.flags & User.HOME_USERS_FEED_FLAG) != 0
|
||
rotate_per_day = (session.is_rotation_enabled()
|
||
and not is_flat_feed
|
||
and feed_sort_mode == Post.SORT_CREATED
|
||
and not filter_by_followed)
|
||
|
||
# Pagination.
|
||
num_total = db.count_posts(subspace=context,
|
||
draft=False,
|
||
is_comment=None if is_flat_feed else False,
|
||
filter_by_followed=filter_by_followed,
|
||
filter_issue_status=filter_issue_status,
|
||
filter_tag=session.feed_tag_filter,
|
||
omit_user_subspaces=omit_user_subspaces,
|
||
omit_nonuser_subspaces=omit_nonuser_subspaces,
|
||
muted_by_user_id=(user.id if user else 0),
|
||
rotate_per_day=rotate_per_day)
|
||
num_pages = int((num_total + page_size - 1) / page_size)
|
||
|
||
# Filter status.
|
||
filter_mode = ''
|
||
if session.feed_tag_filter:
|
||
filter_mode = f' [#{session.feed_tag_filter}]'
|
||
|
||
# Navigation menu.
|
||
if not user:
|
||
page += f'=> /s/ {session.bubble.site_icon} Subspaces\n'
|
||
page += session.FOOTER_MENU
|
||
page += '=> /?register Sign up\n'
|
||
else:
|
||
if session.user.role == User.LIMITED:
|
||
link_suffix = '/' + session.get_token()
|
||
else:
|
||
link_suffix = ''
|
||
page += session.dashboard_link()
|
||
if not session.is_context_locked:
|
||
if c_user and c_user.id == user.id:
|
||
page += f'=> /u/{user.name}/post{link_suffix} 💬 New post\n'
|
||
page += f'=> /u/{user.name}/compose{link_suffix} ✏️ Compose draft\n'
|
||
elif context and context.owner == 0:
|
||
if is_issue_tracker:
|
||
if session.user.role != User.LIMITED:
|
||
page += f'=> /{context.title()}/post 🐞 New issue in s/{context.name}\n'
|
||
else:
|
||
if session.user.role != User.LIMITED:
|
||
page += f'=> /{context.title()}/post 💬 New post in s/{context.name}\n'
|
||
page += f'=> /{context.title()}/compose{link_suffix} ✏️ Compose draft in s/{context.name}\n'
|
||
else:
|
||
page += f'=> /u/{user.name}/post{link_suffix} 💬 New post in u/{user.name}\n'
|
||
page += f'=> /u/{user.name}/compose{link_suffix} ✏️ Compose draft in u/{user.name}\n'
|
||
page += f'=> /s/ {session.bubble.site_icon} Subspaces\n'
|
||
|
||
if is_issue_tracker:
|
||
page += f'\n=> /{context.title()}/search 🔍 Search\n'
|
||
page += f'=> /{context.title()}/tag 🏷️ Tags\n'
|
||
if session.feed_mode in ('all', 'closed'):
|
||
page += f'=> ? 🐞 Show open\n'
|
||
if session.feed_mode in ('all', 'open'):
|
||
page += f'=> ?closed ✔︎ Show closed\n'
|
||
if session.feed_mode in ('open', 'closed'):
|
||
page += f'=> ?open&closed Show all\n'
|
||
|
||
# Page title.
|
||
if session.feed_mode == 'all':
|
||
if is_issue_tracker:
|
||
page_title = 'Issues'
|
||
elif not context:
|
||
if omit_nonuser_subspaces:
|
||
page_title = 'User Posts'
|
||
elif omit_user_subspaces:
|
||
page_title = 'Subspace Posts'
|
||
else:
|
||
page_title = 'All Posts'
|
||
else:
|
||
page_title ='Posts'
|
||
elif session.feed_mode in ('open', 'closed'):
|
||
page_title = 'Open Issues' if session.feed_mode == 'open' else 'Closed Issues'
|
||
else:
|
||
page_title = 'Followed'
|
||
|
||
posts = db.get_posts(subspace=context,
|
||
comment=None if is_flat_feed else False,
|
||
draft=False,
|
||
sort=feed_sort_mode,
|
||
notifs_for_user_id=(user.id if user else 0),
|
||
filter_by_followed=filter_by_followed,
|
||
filter_issue_status=filter_issue_status,
|
||
filter_tag=session.feed_tag_filter,
|
||
omit_user_subspaces=omit_user_subspaces,
|
||
omit_nonuser_subspaces=omit_nonuser_subspaces,
|
||
muted_by_user_id=(user.id if user else 0),
|
||
gemini_feed=is_gemini_feed or is_atom_feed,
|
||
rotate_per_day=rotate_per_day,
|
||
limit=page_size,
|
||
page=page_index)
|
||
is_empty_feed = len(posts) == 0
|
||
|
||
if is_bubble_feed and (is_issue_tracker or not is_empty_feed):
|
||
if is_issue_tracker:
|
||
title_count = f'{num_total} '
|
||
else:
|
||
title_count = ''
|
||
page += f'\n## {title_count}{page_title}{sort_mode}{filter_mode}\n\n'
|
||
elif is_tinylog:
|
||
page += f'author: @{c_user.name}@{session.bubble.hostname}\n'
|
||
page += f'avatar: {c_user.avatar}\n\n'
|
||
|
||
if is_empty_feed and page_index == 0:
|
||
if is_issue_tracker:
|
||
if session.feed_mode == 'open':
|
||
page += "All clear! "
|
||
page += "There are no issues.\n\n"
|
||
elif is_bubble_feed:
|
||
#page += "There are no posts.\n"
|
||
if user:
|
||
page += f"\n{session.EMPTY_FEED_PLACEHOLDER}\n\n"
|
||
elif is_gemini_feed:
|
||
for post in posts:
|
||
page += session.gemini_feed_entry(post, context)
|
||
elif is_atom_feed:
|
||
for post in posts:
|
||
page += session.atom_feed_entry(post, context)
|
||
ts_last_updated = max(ts_last_updated, post.ts_created)
|
||
elif is_tinylog:
|
||
for post in posts:
|
||
page += session.tinylog_entry(post) + '\n'
|
||
else:
|
||
# Render the Bubble feed as multiple pages.
|
||
pager_feed_mode = f'&{session.feed_mode}' if session.feed_mode != 'all' else ''
|
||
|
||
def page_range(n):
|
||
return f'{n + 1} / {num_pages}'
|
||
|
||
if page_index > 0:
|
||
page += f'=> ?p{page_index}{pager_feed_mode} Previous page\n\n'
|
||
|
||
for post in posts:
|
||
page += session.feed_entry(post, context,
|
||
is_activity_feed=(feed_sort_mode == Post.SORT_ACTIVE)) + '\n'
|
||
|
||
if len(posts) > 0 and page_index < num_pages - 1:
|
||
page += f'=> ?p{page_index + 2}{pager_feed_mode} Next page\n'
|
||
|
||
if num_pages > 1:
|
||
page += f'Page {page_index + 1} of {num_pages}\n\n'
|
||
|
||
# Footer.
|
||
if is_atom_feed:
|
||
origin_url = f"{session.server_root()}/{urlparse.quote(context.title()) if context else ''}"
|
||
atom_header = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||
<title>{session.feed_title()}</title>
|
||
<link href="{origin_url}" />
|
||
<link rel="self" type="application/atom+xml" href="{session.req.url()}" />
|
||
<id>{session.req.url()}</id>
|
||
<updated>{atom_timestamp(ts_last_updated if ts_last_updated else time.time())}</updated>
|
||
<generator uri="https://gmi.skyjake.fi/bubble/" version="{session.bubble.version}">Bubble</generator>
|
||
"""
|
||
atom_footer = "\n</feed>\n"
|
||
return 20, 'application/atom+xml', atom_header + page + atom_footer
|
||
|
||
elif not is_tinylog:
|
||
if not is_gemini_feed:
|
||
if user or not is_empty_feed:
|
||
page += "## Options\n"
|
||
if not is_flat_feed and not is_empty_feed:
|
||
if feed_sort_mode != Post.SORT_CREATED:
|
||
page += "=> ?sort=new 🕑 Sort by most recent\n"
|
||
if feed_sort_mode != Post.SORT_ACTIVE:
|
||
page += "=> ?sort=active 🗣️ Sort by activity\n"
|
||
if feed_sort_mode != Post.SORT_HOTNESS:
|
||
page += "=> ?sort=hot 🔥 Sort by hotness\n"
|
||
if not context:
|
||
if session.feed_mode == 'followed':
|
||
page += '=> /all All Posts\n'
|
||
else:
|
||
page += '=> /followed Followed\n'
|
||
if not is_empty_feed:
|
||
page += "=> ?feed Gemini feed\n"
|
||
page += "=> ?atom Atom feed\n"
|
||
if c_user:
|
||
page += "=> ?tinylog Tinylog\n"
|
||
if user:
|
||
# Search.
|
||
if not is_issue_tracker and not is_empty_feed:
|
||
if context:
|
||
page += f'=> /{context.title()}/search 🔍 Search in {context.title()}\n'
|
||
page += f'=> /{context.title()}/tag 🏷️ Tags\n'
|
||
else:
|
||
page += '\n=> /search 🔍 Search\n'
|
||
page += '=> /tag 🏷️ Tags\n'
|
||
|
||
# Settings.
|
||
page += "\n=> /settings ⚙️ Settings\n"
|
||
if user.role == User.ADMIN and c_user and user.id != c_user.id:
|
||
page += f'=> /settings/flair/{c_user.name} 📛 Flairs on {c_user.name}\n'
|
||
if context and (not c_user or user.id == c_user.id):
|
||
page += f'=> /settings/flair/{user.name}/add/-/{context.name} 📛 Set subspace flair\n'
|
||
if session.is_user_mod and not c_user:
|
||
page += f'=> /{context.title()}/admin 🌒 Subspace admin\n'
|
||
page += '\n'
|
||
|
||
if session.is_antenna_enabled() and c_user and user.id == c_user.id:
|
||
for i, label in enumerate(session.bubble.antenna_labels):
|
||
page += f"=> /transmit/{i}/feed/{user.name}{'/' + session.feed_tag_filter if session.feed_tag_filter else ''} Submit feed to {label}\n"
|
||
|
||
# Following and muting.
|
||
if c_user and user.id != c_user.id:
|
||
if (FOLLOW_USER, c_user.id) in user_follows:
|
||
page += f'=> /unfollow/{c_user.name} ➖ Unfollow {c_user.name}\n'
|
||
elif not (MUTE_USER, c_user.id) in user_mutes:
|
||
page += f'=> /follow/{c_user.name} ➕ Follow {c_user.name}\n'
|
||
page += f'You will be notified when {c_user.name} posts anywhere on {session.bubble.site_name}.\n'
|
||
if (MUTE_USER, c_user.id) in user_mutes:
|
||
page += f'=> /unmute/{c_user.name} 🔈 Unmute {c_user.name}\n'
|
||
elif not (FOLLOW_USER, c_user.id) in user_follows:
|
||
page += f'=> /mute/{c_user.name} 🔇 Mute {c_user.name}\n'
|
||
page += f'You will not see posts or comments by {c_user.name} anywhere on {session.bubble.site_name}.\n'
|
||
if context and context.owner != user.id and not session.is_context_locked:
|
||
if not c_user or not (MUTE_USER, c_user.id) in user_mutes:
|
||
if not page.endswith('\n\n'):
|
||
page += '\n'
|
||
if (MUTE_SUBSPACE, context.id) in user_mutes:
|
||
page += f'=> /unmute/{context.title()} 🔈 Unmute subspace {context.title()}\n'
|
||
elif (FOLLOW_SUBSPACE, context.id) in user_follows:
|
||
page += f'=> /unfollow/{context.title()} ➖ Unfollow subspace {context.title()}\n'
|
||
else:
|
||
page += f'=> /follow/{context.title()} ➕ Follow subspace {context.title()}\n'
|
||
if context.id not in user.moderated_subspace_ids:
|
||
page += f'=> /mute/{context.title()} 🔇 Mute subspace {context.title()}\n'
|
||
page += "Posts from the subspace will not appear in All Posts.\n"
|
||
|
||
if not page.endswith('\n\n'):
|
||
page += '\n'
|
||
|
||
page += session.FOOTER_MENU
|
||
else:
|
||
page += '\n'
|
||
if c_user:
|
||
page += c_user.subspace_link()
|
||
elif context:
|
||
page += context.subspace_link()
|
||
page += session.FOOTER_MENU
|
||
return page
|