bubble/feeds.py

1041 lines
46 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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