mirror of
https://git.skyjake.fi/gemini/bubble.git
synced 2024-06-22 16:47:09 +00:00
Option for a unified timeline; mark user active on certain actions
Unified timeline combines posts and comments in a single feed. The user is marked active when reacting to posts, thanking someone, or interacting with notifications.
This commit is contained in:
parent
8cf782b697
commit
bcdf1e7962
96
50_bubble.py
96
50_bubble.py
|
@ -232,6 +232,7 @@ Bubble is open source:
|
|||
|
||||
def feed_entry(self, post, context=None, omit_rotate_info=False, is_activity_feed=False):
|
||||
is_issue_tracker = self.is_context_tracker
|
||||
is_comment = post.parent != 0 # Flat feeds intermingle comments with posts.
|
||||
|
||||
# Collect the metadata first.
|
||||
tag = ' · ' + post.tags if post.tags else ''
|
||||
|
@ -248,7 +249,7 @@ Bubble is open source:
|
|||
if post.num_cmts > 0:
|
||||
cmt += f'{post.num_cmts} comment{plural_s(post.num_cmts)}'
|
||||
else:
|
||||
cmt = 'View post' if post.num_cmts == 0 and is_user_post else \
|
||||
cmt = 'View post' if post.num_cmts == 0 and is_user_post and not is_comment else \
|
||||
'' if post.num_cmts == 0 else \
|
||||
f'{post.num_cmts} comment{plural_s(post.num_cmts)}'
|
||||
if is_activity_feed:
|
||||
|
@ -257,9 +258,6 @@ Bubble is open source:
|
|||
age = post.age(tz=self.tz)
|
||||
bell = ' 🔔' if post.num_notifs else ''
|
||||
|
||||
author = '🌒 ' + sub if sub else (post.poster_avatar + ' ' + post.poster_name)
|
||||
author_link = f'/{sub}' if sub else f'/u/{post.poster_name}'
|
||||
|
||||
SHORT_PREVIEW_LEN = 160
|
||||
|
||||
if is_issue_tracker:
|
||||
|
@ -268,41 +266,79 @@ Bubble is open source:
|
|||
src = f'=> {post.page_url()} 🐞 [#{post.issueid if post.issueid else 0}] {post.title}{tag}\n'
|
||||
src += shorten_text(post.summary,
|
||||
200 if not self.is_short_preview else SHORT_PREVIEW_LEN) + '\n'
|
||||
parts = []
|
||||
parts.append(post.poster_name)
|
||||
meta = []
|
||||
meta.append(post.poster_name)
|
||||
if cmt:
|
||||
parts.append(cmt)
|
||||
meta.append(cmt)
|
||||
if likes:
|
||||
parts.append(likes)
|
||||
parts.append(age)
|
||||
src += f'{post.poster_avatar} {" · ".join(parts)}{bell}\n'
|
||||
meta.append(likes)
|
||||
meta.append(age)
|
||||
src += f'{post.poster_avatar} {" · ".join(meta)}{bell}\n'
|
||||
else:
|
||||
# Regular feeds may have subspace posts, user posts, and comments.
|
||||
is_deleted = False
|
||||
reply_label, reply_path = None, None
|
||||
if is_comment:
|
||||
post_icon = '🌒' if not post.sub_owner else ''
|
||||
post_label = ("s/" if not post.sub_owner else "u/") + post.sub_name
|
||||
post_path = '/' + post_label
|
||||
meta_icon = post.poster_avatar
|
||||
parent_post = self.db.get_post(id=post.parent)
|
||||
if parent_post:
|
||||
reply_label = f"Re: {parent_post.quoted_title(max_len=60)}"
|
||||
reply_path = parent_post.page_url()
|
||||
else:
|
||||
reply_label = "Re: (deleted post)"
|
||||
reply_path = post.page_url()
|
||||
is_deleted = True
|
||||
elif sub:
|
||||
post_icon = '🌒'
|
||||
post_label = sub
|
||||
post_path = f'/{sub}'
|
||||
meta_icon = '💬'
|
||||
else:
|
||||
post_icon = post.poster_avatar
|
||||
post_label = post.poster_name
|
||||
post_path = f'/u/{post.poster_name}'
|
||||
meta_icon = '💬'
|
||||
|
||||
# Grouped posts are shown in a rotating group.
|
||||
rotation = ''
|
||||
if not omit_rotate_info:
|
||||
# Posts may be rotating per day.
|
||||
if not is_comment and not omit_rotate_info:
|
||||
per_day = post.num_per_day
|
||||
if per_day and per_day > 1:
|
||||
n = per_day - 1
|
||||
rotation = f" (+{n} other post{plural_s(n)})"
|
||||
author_link = post.page_url() + "/group"
|
||||
if is_activity_feed:
|
||||
if sub:
|
||||
author += f' · {post.poster_avatar} {post.poster_name}'
|
||||
# First line is the author.
|
||||
src = f'=> {author_link} {author}{rotation}\n'
|
||||
src += post.summary if not self.is_short_preview \
|
||||
else shorten_text(post.summary, SHORT_PREVIEW_LEN) + '\n'
|
||||
parts = []
|
||||
if sub and not is_activity_feed:
|
||||
parts.append(post.poster_name)
|
||||
post_path = post.page_url() + "/group"
|
||||
|
||||
# Activity feeds use ts_comment timestamps, so the post author's name is at the top
|
||||
# because the meta line is associated with the latest comment instead.
|
||||
if is_activity_feed and sub:
|
||||
post_label += f' · {post.poster_avatar} {post.poster_name}'
|
||||
|
||||
src = f'=> {post_path} {post_icon} {post_label}{rotation}\n'
|
||||
|
||||
if reply_label:
|
||||
src += f'=> {reply_path} {INNER_LINK_PREFIX} {reply_label}\n'
|
||||
|
||||
if not is_deleted:
|
||||
src += post.summary if not self.is_short_preview \
|
||||
else shorten_text(post.summary, SHORT_PREVIEW_LEN) + '\n'
|
||||
else:
|
||||
src += "(only visible to author)\n"
|
||||
|
||||
# Last line in the metadata.
|
||||
meta = []
|
||||
if is_comment or (sub and not is_activity_feed):
|
||||
meta.append(post.poster_name)
|
||||
if cmt:
|
||||
parts.append(cmt)
|
||||
meta.append(cmt)
|
||||
if likes:
|
||||
parts.append(likes)
|
||||
if len(parts) == 0:
|
||||
parts.append('View post')
|
||||
parts.append(age)
|
||||
src += f'=> {post.page_url()} 💬 {" · ".join(parts)}{bell}{tag}\n'
|
||||
meta.append(likes)
|
||||
if len(meta) == 0:
|
||||
meta.append('View post')
|
||||
meta.append(age)
|
||||
src += f'=> {post.page_url()} {meta_icon} {" · ".join(meta)}{bell}{tag}\n'
|
||||
|
||||
return src
|
||||
|
||||
|
@ -801,7 +837,7 @@ when the administrator assigns at least one moderator to it.
|
|||
|
||||
## Deleted Posts
|
||||
|
||||
Deleting a post does not delete its discussion thread, too, because the post author does not have the authority to delete other users' content. After a post has been deleted, comments about it are still accessible through the Dashboard comment index. You can find your orphaned comments in the index by searching for comments about a "Deleted post". Comments about deleted posts are not included in any feed.
|
||||
Deleting a post does not delete its discussion thread because users cannot delete other users' content. After a post has been deleted, comments you have made about it are still visible to you through the Dashboard comment index. You can find your orphaned comments in the index by searching for comments about a "Deleted post". Comments about deleted posts are not included in any feed.
|
||||
|
||||
Deleting a subspace will delete all posts and comments in the subspace, i.e., the full discussion threads will be deleted.
|
||||
|
||||
|
|
35
feeds.py
35
feeds.py
|
@ -331,7 +331,7 @@ def make_post_page(session, post):
|
|||
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'
|
||||
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:
|
||||
|
@ -638,7 +638,13 @@ def make_feed_page(session):
|
|||
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
|
||||
|
@ -710,16 +716,22 @@ def make_feed_page(session):
|
|||
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
|
||||
|
||||
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 context
|
||||
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,
|
||||
|
@ -794,7 +806,7 @@ def make_feed_page(session):
|
|||
page += f'avatar: {c_user.avatar}\n\n'
|
||||
|
||||
posts = db.get_posts(subspace=context,
|
||||
comment=False,
|
||||
comment=None if is_flat_feed else False,
|
||||
draft=False,
|
||||
sort=feed_sort_mode,
|
||||
notifs_for_user_id=(user.id if user else 0),
|
||||
|
@ -865,12 +877,13 @@ def make_feed_page(session):
|
|||
elif not is_tinylog:
|
||||
if not is_gemini_feed:
|
||||
page += "## Options\n"
|
||||
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 is_flat_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'
|
||||
|
@ -887,7 +900,7 @@ def make_feed_page(session):
|
|||
page += f'=> /{context.title()}/search 🔍 Search in {context.title()}\n'
|
||||
page += f'=> /{context.title()}/tag 🏷️ Tags\n'
|
||||
else:
|
||||
page += '=> /search 🔍 Search\n'
|
||||
page += '\n=> /search 🔍 Search\n'
|
||||
page += '=> /tag 🏷️ Tags\n'
|
||||
|
||||
# Settings.
|
||||
|
@ -897,7 +910,6 @@ def make_feed_page(session):
|
|||
|
||||
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
|
||||
|
||||
|
@ -915,7 +927,8 @@ def make_feed_page(session):
|
|||
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:
|
||||
page += '\n'
|
||||
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:
|
||||
|
|
20
model.py
20
model.py
|
@ -227,6 +227,7 @@ class User:
|
|||
SORT_POST_RECENT = 'r'
|
||||
SORT_POST_HOTNESS = 'h'
|
||||
SORT_POST_ACTIVITY = 'a'
|
||||
SORT_POST_FLAT = 'f'
|
||||
SORT_COMMENT_OLDEST = 'o'
|
||||
SORT_COMMENT_NEWEST = 'n'
|
||||
|
||||
|
@ -376,6 +377,8 @@ class Post:
|
|||
self.num_per_day = num_per_day
|
||||
|
||||
def title_text(self):
|
||||
"""Title shown in the composer."""
|
||||
|
||||
if len(self.title):
|
||||
return self.title
|
||||
elif self.parent:
|
||||
|
@ -386,6 +389,9 @@ class Post:
|
|||
else:
|
||||
return '(untitled issue)' if self.issueid else '(untitled post)'
|
||||
|
||||
def quoted_title(self, max_len=60):
|
||||
return f'"{self.title if self.title else shorten_text(strip_links(clean_title(self.summary)), max_len)}"'
|
||||
|
||||
def ymd_date(self, tz=None):
|
||||
dt = datetime.datetime.fromtimestamp(self.ts_created, UTC)
|
||||
if tz:
|
||||
|
@ -1839,7 +1845,7 @@ class Database:
|
|||
UNIX_TIMESTAMP(p.ts_comment) AS ts_comment,
|
||||
p.summary,
|
||||
sub1.name AS sub_name,
|
||||
sub2.owner,
|
||||
sub1.owner, -- sub2.owner,
|
||||
u.avatar,
|
||||
u.name AS u_name,
|
||||
(SELECT COUNT(notifs.id)
|
||||
|
@ -1851,7 +1857,7 @@ class Database:
|
|||
FROM posts p
|
||||
JOIN users u ON p.user=u.id
|
||||
JOIN subspaces sub1 ON p.subspace=sub1.id
|
||||
LEFT JOIN subspaces sub2 ON p.subspace=sub2.id AND p.user=sub2.owner
|
||||
-- LEFT JOIN subspaces sub2 ON p.subspace=sub2.id AND p.user=sub2.owner
|
||||
{filter}
|
||||
WHERE {' AND '.join(where_stm)}
|
||||
"""
|
||||
|
@ -1945,7 +1951,7 @@ class Database:
|
|||
subspace=None,
|
||||
parent_id=None,
|
||||
draft=False,
|
||||
is_comment=None,
|
||||
is_comment=False,
|
||||
ignore_omit_flags=False,
|
||||
omit_user_subspaces=False,
|
||||
omit_nonuser_subspaces=False,
|
||||
|
@ -1959,10 +1965,10 @@ class Database:
|
|||
grouping = ''
|
||||
filter = ''
|
||||
|
||||
if is_comment:
|
||||
if is_comment != None:
|
||||
cond.append('p.parent!=0' if is_comment else 'p.parent=0')
|
||||
elif not parent_id and not draft:
|
||||
cond.append('p.parent=0') # no comments
|
||||
# elif not parent_id and not draft:
|
||||
# cond.append('p.parent=0') # no comments
|
||||
if filter_by_followed:
|
||||
filter = Database.FOLLOW_FILTER_JOIN
|
||||
values.append(filter_by_followed.id)
|
||||
|
@ -2170,6 +2176,7 @@ class Database:
|
|||
END IF
|
||||
""", (user.id, post.user, post.id,
|
||||
user.id, post.user, post.id))
|
||||
self.update_user(user, active=True)
|
||||
self.commit()
|
||||
|
||||
def notify_new_poll(self, post: Post):
|
||||
|
@ -2236,6 +2243,7 @@ class Database:
|
|||
(post.id, user.id, reaction))
|
||||
cur.execute("INSERT IGNORE INTO notifs (type, src, dst, post) VALUES (?, ?, ?, ?)",
|
||||
(Notification.REACTION, user.id, post.user, post.id))
|
||||
self.update_user(user, active=True)
|
||||
self.commit()
|
||||
|
||||
def get_reactions(self, post, user_mutes=set()) -> dict:
|
||||
|
|
23
settings.py
23
settings.py
|
@ -28,7 +28,8 @@ def make_settings_page(session):
|
|||
SORT_POST = {
|
||||
'r': '🕑 Most recent',
|
||||
'a': '🗣️ Activity',
|
||||
'h': '🔥 Hotness'
|
||||
'h': '🔥 Hotness',
|
||||
'f': '🗪 Unified timeline'
|
||||
}
|
||||
|
||||
if req.path == session.path + 'settings/avatar/' + token:
|
||||
|
@ -253,7 +254,7 @@ def make_settings_page(session):
|
|||
page = 'Sort posts by:\n\n'
|
||||
for key, label in SORT_POST.items():
|
||||
page += f"=> ?{key} {label}\n"
|
||||
page += '\n"Most recent" sorts posts by their original creation time. "Activity" is affected by the time of the latest comment: posts will be bumped to the top of a feed whenever a new comment is added. "Hotness" sorts posts by a score calculated based on time of latest comment, number of people in the discussion thread, number of likes, and the age of the post.'
|
||||
page += '\n"Most recent" sorts posts by their original creation time. "Activity" is affected by the time of the latest comment: posts will be bumped to the top of a feed whenever a new comment is added. "Hotness" sorts posts by a score calculated based on time of latest comment, number of people in the discussion thread, number of likes, and the age of the post. "Unified timeline" combines posts and comments in one feed in reverse chronological order.'
|
||||
return page
|
||||
if not arg in SORT_POST:
|
||||
return 50, 'Invalid sort order'
|
||||
|
@ -268,11 +269,15 @@ def make_settings_page(session):
|
|||
return 30, '/settings'
|
||||
|
||||
elif req.path == session.path + 'settings/ascii':
|
||||
db.update_user(session.user, flags=(session.user.flags ^ User.ASCII_ICONS_FLAG))
|
||||
db.update_user(user, flags=(user.flags ^ User.ASCII_ICONS_FLAG))
|
||||
return 30, './display'
|
||||
|
||||
elif req.path == session.path + 'settings/short-preview':
|
||||
db.update_user(session.user, flags=(session.user.flags ^ User.SHORT_PREVIEW_FLAG))
|
||||
db.update_user(user, flags=(user.flags ^ User.SHORT_PREVIEW_FLAG))
|
||||
return 30, './display'
|
||||
|
||||
elif req.path == session.path + 'settings/flat':
|
||||
db.update_user(user, flags=(user.flags ^ User.HOME_FLAT_FEED_FLAG))
|
||||
return 30, './display'
|
||||
|
||||
elif req.path == session.path + 'settings/all-rotation':
|
||||
|
@ -609,14 +614,14 @@ def make_settings_page(session):
|
|||
ICON_MODE = [ 'Unicode/Emoji' , 'ASCII' ]
|
||||
|
||||
page += '## Display\n\n'
|
||||
page += f'=> /settings/short-preview {CHECKS[nonzero(session.user.flags & User.SHORT_PREVIEW_FLAG)]} Short post previews\n'
|
||||
page += f"=> /settings/all-rotation {CHECKS[is_zero(session.user.flags & User.DISABLE_ROTATION_FLAG)]} Rotate posts by subspace in All Posts\n"
|
||||
if session.user.flags & User.DISABLE_ROTATION_FLAG:
|
||||
page += f'=> /settings/short-preview {CHECKS[nonzero(user.flags & User.SHORT_PREVIEW_FLAG)]} Short post previews\n'
|
||||
page += f"\n=> /settings/all-rotation {CHECKS[is_zero(user.flags & User.DISABLE_ROTATION_FLAG)]} Rotate posts by subspace in All Posts\n"
|
||||
if user.flags & User.DISABLE_ROTATION_FLAG:
|
||||
page += 'The All Posts feed shows every post individually, even when one subspace has several posts per day.\n'
|
||||
else:
|
||||
page += 'The All Posts feed groups posts by subspace and rotates them throughout the day.\n'
|
||||
page += f'\n=> /settings/timezone Time zone: {user.timezone}\n'
|
||||
page += f'=> /settings/ascii Display icons as: {ICON_MODE[nonzero(session.user.flags & User.ASCII_ICONS_FLAG)]}\n'
|
||||
page += f'=> /settings/ascii Display icons as: {ICON_MODE[nonzero(user.flags & User.ASCII_ICONS_FLAG)]}\n'
|
||||
return page
|
||||
|
||||
elif req.path == session.path + 'settings' or \
|
||||
|
@ -633,7 +638,7 @@ def make_settings_page(session):
|
|||
0: 'All Posts',
|
||||
User.HOME_NO_USERS_FEED_FLAG: 'All Posts (excluding userspaces)',
|
||||
User.HOME_USERS_FEED_FLAG: 'Userspaces only',
|
||||
User.HOME_FOLLOWED_FEED_FLAG: 'Followed'
|
||||
User.HOME_FOLLOWED_FEED_FLAG: 'Followed',
|
||||
}
|
||||
user_space = db.get_subspace(owner=user.id)
|
||||
|
||||
|
|
|
@ -92,7 +92,7 @@ def make_subspaces_page(session):
|
|||
def sub_latest_post(sub):
|
||||
latest = db.get_post(id=sub.latest_post_id) if sub.latest_post_id else None
|
||||
if latest:
|
||||
title = f'"{latest.title}"' if latest.title else f'"{shorten_text(latest.summary, 60)}"'
|
||||
title = latest.quoted_title()
|
||||
age = latest.age(tz=session.tz)
|
||||
return f"{title} by {latest.poster_avatar} {latest.poster_name} · {age}\n"
|
||||
return ''
|
||||
|
|
3
user.py
3
user.py
|
@ -190,6 +190,7 @@ def user_actions(session):
|
|||
|
||||
if action == 'clear':
|
||||
db.get_notifications(session.user, clear=True)
|
||||
db.update_user(session.user, active=True)
|
||||
return 30, '/dashboard'
|
||||
|
||||
if action == 'history':
|
||||
|
@ -218,6 +219,8 @@ def user_actions(session):
|
|||
return page
|
||||
|
||||
notif = db.get_notification(session.user, notif_id, clear=True)
|
||||
db.update_user(session.user, active=True)
|
||||
|
||||
if not notif:
|
||||
return 30, '/dashboard'
|
||||
if notif.comment:
|
||||
|
|
Loading…
Reference in New Issue
Block a user