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. session.cleanup() db = session.db req = session.req path = req.path[len(session.path):] found = re.fullmatch(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) if found.group(8) == '/': # Redirect to the correct URL. return 31, session.path + path[:-1] + ("?" + req.query if req.query != None else "") 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': return session.create_draft(arg) 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 req.content_mime and 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: title, body = extract_title_heading(session, body) 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: if part: 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 session.is_titan: return 59, 'Wrong protocol' 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: try: post = db.get_post(id=int(arg), draft=False) except ValueError: pass if not post: # Maybe it is a draft? if session.user: try: post = db.get_post(id=int(arg), draft=True) except ValueError: pass 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): lock_flags = post.flags & (Post.LOCKED_FLAG | Post.ADMIN_LOCKED_FLAG) if session.user.role == User.ADMIN or (session.user.role == User.BASIC and not lock_flags & Post.ADMIN_LOCKED_FLAG): if lock_flags: 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'# {post.poster_name}\n' page += session.gemini_feed_entry(post, session.context, plain=True) 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 session.is_titan: return 59, 'Wrong protocol' 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)) if post else '' 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 db.is_mergeable(focused_cmt.id, session.user): actions.append(f'=> /edit/{focused_cmt.id}/merge/{session.get_token()} Merge into previous\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') 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') actions.append(f'=> /remind/{focused_cmt.id} ๐Ÿ”” Remind me\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.ADMIN_LOCKED_FLAG: page += '\n๐Ÿ”’ Locked by Admin' elif 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## {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 = strip_multibreaks(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 + '\n' # 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()`. session.cleanup() 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" if session.is_titan: return 59, "Wrong protocol" # 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 = '' if session.user.role == User.ADMIN and c_user: token = session.get_token() if c_user.role == User.LIMITED: page += f'=> {session.path}admin/review-users/set-basic/{token}?{c_user.name} ๐Ÿ‘ Promote to Basic role\n\n' elif c_user.role == User.BASIC: page += f'=> {session.path}admin/review-users/set-limited/{token}?{c_user.name} Demote to Limited role\n\n' 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""" {session.feed_title()} {session.req.url()} {atom_timestamp(ts_last_updated if ts_last_updated else time.time())} Bubble """ atom_footer = "\n\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