bubble/feeds.py

618 lines
24 KiB
Python
Raw Normal View History

import re
from model import User, Segment, Subspace, Commit, Crossref, \
FOLLOW_SUBSPACE, FOLLOW_USER, FOLLOW_POST
2023-05-10 10:01:08 +00:00
from subspace import subspace_admin_actions
from utils import *
def make_post_page_or_configure_feed(session):
# This may return None, in which case should make a feed page.
db = session.db
req = session.req
# User or subspace feed.
path = req.path[len(session.path):]
2023-05-12 03:30:50 +00:00
found = re.match(r'(u|s)/([\w-]+)(/(post|compose|image|file|issues|admin))?(/([\w\d-]+)(.*))?', path)
if not found:
return 59, 'Bad request'
# Set up the feed parameters.
session.feed_type = found.group(1)
url_name = found.group(2)
action = found.group(4)
arg = found.group(6)
2023-05-12 03:30:50 +00:00
arg2 = found.group(7)
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
else:
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
2023-05-10 12:20:32 +00:00
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':
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 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"
draft_id = db.create_post(session.user, session.context.id)
return 30, '/edit/%d' % draft_id
if 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.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)
return 30, session.server_root() + post.page_url()
if session.user:
session.user_follows = db.get_follows(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.subspace != session.context.id:
#return 51, 'Not found'
# 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 post.parent != 0:
# Comments cannot be viewed individually.
# TODO: Make this possible.
return 30, db.get_post(id=post.parent).page_url()
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()
page = make_post_page(session, post)
return page
return None
def make_post_page(session, post):
db = session.db
user = session.user
page = session.render_post(post)
commits = []
incoming_xrefs = []
outgoing_xrefs = {}
if post.issueid:
repo = db.get_repository(subspace=session.context)
commits = db.get_commits(repo, issueid=post.issueid)
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.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
# Poll.
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
# 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
last_age = post.age()
page += f'{last_age}'
liked = []
if post.num_likes:
liked = db.get_likes(post)
page += ' · 👍 ' + ', '.join(liked)
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.user.id == post.user or session.is_user_mod:
if session.is_context_tracker:
if '✔︎' in post.tags:
page += f'=> /edit-tags/{post.id}/open 🐞 Reopen issue\n'
else:
page += f'=> /edit-tags/{post.id}/close ✔︎ Mark as closed\n'
if session.is_editable(post):
page += f'=> /edit/{post.id} ✏️ Edit {kind}\n'
page += f'=> /comment/{post.id} 💬 Comment\n'
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.user.id == post.user or session.is_user_mod:
page += f'=> /edit-tags/{post.id} 🏷️ Add/remove tags\n'
if session.user.id != post.user:
if (FOLLOW_POST, post.id) in session.user_follows:
page += f'=> /unfollow/post/{post.id} Unfollow {kind}\n'
else:
page += f'=> /follow/post/{post.id} Follow {kind}\n'
# Moderator actions on a post.
mod_actions = []
if session.is_title_editable(post) and not session.is_editable(post):
mod_actions.append(f'=> /edit/{post.id}/mod-title ✏️ Edit {kind} title\n')
if session.is_movable(post):
mod_actions.append(f'=> /edit/{post.id}/move/{session.get_token()} Move to subspace\n')
if session.is_deletable(post) and not session.is_editable(post):
mod_actions.append(f'=> /edit/{post.id}/delete/{session.get_token()} ❌ Delete {kind}\n')
if session.user.id == post.user and post.sub_owner == post.user:
antenna_feed = f"gemini://{session.bubble.hostname}{session.path}u/{session.user.name}/{post.id}/antenna"
mod_actions.append(f'=> {session.bubble.antenna_url}?{urlparse.quote(antenna_feed)} Submit post to 📡 Antenna\n')
if mod_actions:
page += '\n' + ''.join(mod_actions)
page += '\n' + session.dashboard_link()
notifs = db.get_notifications(user=user, post_id=post.id)
if notifs:
2023-05-21 20:37:27 +00:00
page += f'{len(notifs)} notification{plural_s(len(notifs))} on this page:\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.
display_order_desc = not session.user or \
session.user.sort_cmt == User.SORT_COMMENT_NEWEST
comments = db.get_posts(parent=post.id, draft=False, sort_descending=False, limit=None)
n = len(comments)
if n > 0 or commits or incoming_xrefs:
if n > 1:
dir_icon = '' if display_order_desc else ''
else:
dir_icon = ''
page += f'\n## {n} Comment{plural_s(n)}{dir_icon}'
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 = []
for cmt in comments:
# Commits are shown as links to the Git viewer.
if isinstance(cmt, Commit):
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
src = f'=> /u/{cmt.poster_name} {cmt.poster_avatar} {cmt.poster_name}\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 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):
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 session.user and not session.is_context_locked and \
len(comments) > 1 and not display_order_desc:
page += f'\n=> /comment/{post.id} 💬 Comment\n'
return page
def make_feed_page(session):
# NOTE: Some parameters were configured in the function above.
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
page = ''
is_gemini_feed = req.query == 'feed'
is_tinylog = req.query == 'tinylog'
sort_hotness = 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_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 not context:
2023-05-09 19:48:35 +00:00
topinfo += f"{session.bubble.site_info}\n"
else:
if c_user and (c_user.info or c_user.url):
if c_user.info:
2023-05-09 19:48:35 +00:00
topinfo += c_user.info + '\n'
if c_user.url:
2023-05-09 19:48:35 +00:00
topinfo += f'=> {c_user.url}\n'
elif context:
if context.info:
2023-05-09 19:48:35 +00:00
topinfo += context.info + '\n'
if context.url:
2023-05-09 19:48:35 +00:00
topinfo += f'=> {context.url}\n'
# Users moderating this subspace.
for mod in context_mods:
2023-05-09 19:48:35 +00:00
topinfo += f'=> /u/{mod.name} {mod.avatar} Moderated by: {mod.name}\n'
if session.is_context_locked:
2023-05-09 19:48:35 +00:00
topinfo += '=> /help/locked 🔒 Locked\n'
page += topinfo if not is_tinylog else clean_tinylog(topinfo)
page += '\n'
# Navigation menu.
if not is_gemini_feed and not is_tinylog:
if not user:
page += f'=> /s/ {session.bubble.site_icon} Subspaces\n'
page += session.FOOTER_MENU
else:
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 💬 New post\n'
page += f'=> /u/{user.name}/compose ✏️ Compose draft\n'
elif context and context.owner == 0:
2023-05-10 18:46:55 +00:00
if is_issue_tracker:
page += f'=> /{context.title()}/post 🐞 New issue in s/{context.name}\n'
else:
page += f'=> /{context.title()}/post 💬 New post in s/{context.name}\n'
page += f'=> /{context.title()}/compose ✏️ Compose draft in s/{context.name}\n'
else:
page += f'=> /u/{user.name}/post 💬 New post in u/{user.name}\n'
page += f'=> /u/{user.name}/compose ✏️ 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'
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'
# The feed.
sort_hotness = (user and user.sort_post == User.SORT_POST_HOTNESS)
if not is_empty_query(req):
if req.query == 'sort=hot':
sort_hotness = True
elif req.query == 'sort=new':
sort_hotness = False
elif re.match(r'p\d+', req.query):
page_index = int(req.query[1:]) - 1
sort_mode = ' 🔥' if sort_hotness else ''
if session.feed_mode == 'all':
2023-05-10 12:20:32 +00:00
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'
page += f'\n## {page_title}{sort_mode}\n'
elif is_tinylog:
page += f'author: @{c_user.name}@{session.bubble.hostname}\n'
page += f'avatar: {c_user.avatar}\n\n'
filter_by_followed = user if session.feed_mode == 'followed' else None
2023-05-10 12:20:32 +00:00
filter_issue_status = True if session.feed_mode == 'open' else \
False if session.feed_mode == 'closed' else None
posts = db.get_posts(subspace=context,
comment=False,
draft=False,
sort_hotness=sort_hotness,
notifs_for_user=(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,
gemini_feed=is_gemini_feed,
limit=page_size,
page=page_index)
if not is_gemini_feed and not is_tinylog:
num_total = db.count_posts(subspace=context,
draft=False,
filter_by_followed=filter_by_followed,
filter_issue_status=filter_issue_status)
num_pages = int((num_total + page_size - 1) / page_size)
if len(posts) == 0 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"
2023-05-10 12:20:32 +00:00
else:
page += "There are no posts.\n\n\n" + \
"> Emptiness is a boundless canvas, an unconstrained beginning: " + \
"an opportunity for the courageous to create and the curious to explore.\n\n\n"
elif is_gemini_feed:
for post in posts:
page += session.gemini_feed_entry(post, context)
elif is_tinylog:
for post in posts:
page += session.tinylog_entry(post) + '\n'
else:
def page_range(n):
return f'{n + 1} / {num_pages}'
if page_index > 0:
page += f'=> ?p{page_index} 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} Next page\n'
if num_pages > 1:
page += f'Page {page_index + 1} of {num_pages}\n\n'
# Footer.
if 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"
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'
else:
page += '=> /search 🔍 Search\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"
2023-05-12 03:30:50 +00:00
if c_user and user.id == c_user.id:
2023-05-13 11:55:30 +00:00
antenna_feed = f"{session.server_root()}{session.path}u/{user.name}/antenna"
2023-05-12 03:30:50 +00:00
page += f'=> {session.bubble.antenna_url}?{urlparse.quote(antenna_feed)} Submit feed to 📡 Antenna\n'
# Following.
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'
else:
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 context and context.owner != user.id and not session.is_context_locked:
if (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 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