bubble/feeds.py

1041 lines
46 KiB
Python
Raw Permalink Normal View History

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
2023-05-10 10:01:08 +00:00
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.
2023-11-05 16:43:16 +00:00
# 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)
2023-06-05 16:20:50 +00:00
if not found and not path.startswith('tag'):
return 59, 'Bad request'
# Set up the feed parameters.
2023-06-05 16:20:50 +00:00
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]
2023-05-10 12:20:32 +00:00
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
2023-06-05 16:20:50 +00:00
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
2023-05-10 12:20:32 +00:00
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':
2023-05-10 10:01:08 +00:00
return subspace_admin_actions(session, arg)
if session.is_context_tracker and action == 'issues':
return 30, f"/s/{session.context.name}/{arg}"
2023-05-10 10:01:08 +00:00
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:
2023-06-05 16:20:50 +00:00
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):
2023-06-05 16:20:50 +00:00
if tag == Post.TAG_CLOSED:
continue
2023-06-05 16:20:50 +00:00
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:
2023-10-29 04:52:44 +00:00
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:
2023-10-29 04:52:44 +00:00
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:
2024-01-10 06:51:57 +00:00
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:
2023-11-06 18:13:09 +00:00
# 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')
2024-01-10 06:51:57 +00:00
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')
2024-01-14 16:15:46 +00:00
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:
2024-01-10 06:51:57 +00:00
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
2023-05-12 03:30:50 +00:00
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 = ''
2023-06-23 15:11:36 +00:00
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))
2024-01-10 06:51:57 +00:00
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 \
2024-01-10 06:51:57 +00:00
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')
2024-01-14 16:15:46 +00:00
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}]"
2024-01-10 06:51:57 +00:00
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)
2024-01-10 06:51:57 +00:00
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'
2024-01-10 06:51:57 +00:00
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)
2023-06-20 12:22:38 +00:00
if not session.is_archive:
2024-01-10 06:51:57 +00:00
src = f'=> /u/{cmt.poster_name}/{cmt.id} {cmt.poster_avatar} {cmt.poster_name}{cmt_flair} · {comment_age}:\n'
2023-06-20 12:22:38 +00:00
else:
2024-01-10 06:51:57 +00:00
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
2024-01-10 06:51:57 +00:00
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):
2023-11-05 11:36:53 +00:00
# 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
2023-05-10 12:20:32 +00:00
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:
2023-05-11 19:51:36 +00:00
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.
2023-05-09 19:48:35 +00:00
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:
2023-05-09 19:48:35 +00:00
topinfo += f'=> {c_user.url}\n'
2024-01-16 13:52:10 +00:00
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:
2023-05-09 19:48:35 +00:00
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:
2023-05-09 19:48:35 +00:00
topinfo += '=> /help/locked 🔒 Locked\n'
if topinfo:
2024-01-16 13:52:10 +00:00
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:
2023-05-10 18:46:55 +00:00
if is_issue_tracker:
if session.user.role != User.LIMITED:
page += f'=> /{context.title()}/post 🐞 New issue in s/{context.name}\n'
2023-05-10 18:46:55 +00:00
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,
2023-05-10 12:20:32 +00:00
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:
2023-05-10 12:20:32 +00:00
if is_issue_tracker:
if session.feed_mode == 'open':
page += "All clear! "
2023-05-21 12:30:47 +00:00
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.
2023-06-15 16:25:48 +00:00
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:
2023-06-15 16:25:48 +00:00
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:
2023-06-15 16:25:48 +00:00
page += f'=> ?p{page_index + 2}{pager_feed_mode} Next page\n'
if num_pages > 1:
2023-06-17 10:50:09 +00:00
page += f'Page {page_index + 1} of {num_pages}\n\n'
# Footer.
if is_atom_feed:
2023-07-18 05:41:47 +00:00
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()}" />
2023-07-18 05:41:47 +00:00
<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"
2023-05-12 03:30:50 +00:00
# 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'
2023-11-06 18:13:09 +00:00
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