bubble/feeds.py

898 lines
38 KiB
Python
Raw 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 session.context.owner:
return 30, '/u' + req.path[2:]
if not session.context:
return 51, 'Not found: s/' + url_name
session.context_mods = db.get_mods(session.context)
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
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:
db.create_segment(post, Segment.TEXT, content=body)
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:
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_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 post.sub_owner == post.user:
actions.append('\n')
antenna_feed = f"gemini://{session.bubble.hostname}{session.path}u/{session.user.name}/{post.id}/antenna"
actions += session.bubble.antenna_links('post', antenna_feed)
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 == '/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
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:
page += f'=> {post.page_url()} Re: "{post.title if post.title else shorten_text(strip_links(clean_title(post.summary)), 60)}"\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)
page += f'\n=> /u/{focused_cmt.poster_name} {focused_cmt.poster_avatar} {focused_cmt.poster_name}\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:
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')
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'
poster_link = f'=> /u/{post.poster_name} {post.poster_avatar} {post.poster_name}\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)
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'
elif session.is_title_editable(post):
page += f'=> /edit/{post.id}/mod-title ✏️ Edit {kind} title\n'
if session.user.role != User.LIMITED:
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 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,
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
comment_age = cmt.age() if not session.is_archive and ts_now - cmt.ts_created < 24 * 3600 else \
cmt.ymd_hm(tz=session.tz, time_prefix='at ')
if not session.is_archive:
src = f'=> /u/{cmt.poster_name}/{cmt.id} {cmt.poster_avatar} {cmt.poster_name} · {comment_age}:\n'
else:
src = f'=> /u/{cmt.poster_name} {cmt.poster_avatar} {cmt.poster_name} · {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 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
sort_hotness = 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):
if c_user.info:
topinfo += c_user.info + '\n'
if c_user.url:
topinfo += f'=> {c_user.url}\n'
elif context:
if context.info:
topinfo += context.info + '\n'
if context.url:
topinfo += f'=> {context.url}\n'
# Users moderating this subspace.
for mod in context_mods:
topinfo += f'=> /u/{mod.name} {mod.avatar} Moderated by: {mod.name}\n'
if session.is_context_locked:
topinfo += '=> /help/locked 🔒 Locked\n'
if topinfo:
page += topinfo if not is_tinylog else 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.
sort_hotness = (user and user.sort_post == User.SORT_POST_HOTNESS)
if not is_empty_query(req):
for param in query_params:
if param == 'sort=hot':
sort_hotness = True
elif param == 'sort=new':
sort_hotness = False
elif re.match(r'p\d+', param):
page_index = int(param[1:]) - 1
sort_mode = ' 🔥' if sort_hotness else ''
rotate_per_day = (session.is_rotation_enabled()
and not context
and not sort_hotness
and not filter_by_followed)
# Pagination.
num_total = db.count_posts(subspace=context,
draft=False,
filter_by_followed=filter_by_followed,
filter_issue_status=filter_issue_status,
filter_tag=session.feed_tag_filter,
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
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'
else:
page_title = 'All Posts' if not context else 'Posts'
elif session.feed_mode in ('open', 'closed'):
page_title = 'Open Issues' if session.feed_mode == 'open' else 'Closed Issues'
else:
page_title = 'Followed'
if is_bubble_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'
posts = db.get_posts(subspace=context,
comment=False,
draft=False,
sort_hotness=sort_hotness,
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,
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)
if len(posts) == 0 and page_index == 0:
if is_issue_tracker:
if session.feed_mode == 'open':
page += "All clear! "
page += "There are no issues.\n\n"
else:
page += "There are no posts.\n\n\n" + \
f"> {session.EMPTY_FEED_PLACEHOLDER}\n\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:
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) + '\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>"
return 20, 'application/atom+xml', atom_header + page + atom_footer
elif not is_tinylog:
if not is_gemini_feed:
page += "## Options\n"
if sort_hotness:
page += "=> ?sort=new 🕑 Sort by most recent\n"
else:
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'
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:
if context:
page += f'=> /{context.title()}/search 🔍 Search in {context.title()}\n'
page += f'=> /{context.title()}/tag 🏷️ Tags\n'
else:
page += '=> /search 🔍 Search\n'
page += '=> /tag 🏷️ Tags\n'
# Settings.
if context and not c_user and session.is_user_mod:
page += f'=> /{context.title()}/admin 🌒 Subspace admin\n'
page += "=> /settings ⚙️ Settings\n\n"
if session.is_antenna_enabled() and c_user and user.id == c_user.id:
antenna_feed = f"{session.server_root()}{session.path}u/{user.name}/antenna"
#page += f'=> {session.bubble.antenna_url}?{urlparse.quote(antenna_feed)} Submit feed to 📡 Antenna\n'
for link in session.bubble.antenna_links('feed', antenna_feed):
page += link
# 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 (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'
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