[1mdiff --git a/50_bubble.py b/50_bubble.py[m
[1mindex e29a0b7..039def0 100644[m
[1m--- a/50_bubble.py[m
[1m+++ b/50_bubble.py[m
[36m@@ -109,10 +109,15 @@[m [mBubble is open source:[m
self.token = None[m
self.notif_count = None[m
self.is_short_preview = False[m
[32m+[m[32m self.tz = pytz.timezone('UTC')[m
[m
def set_user(self, user):[m
self.user = user[m
if user:[m
[32m+[m[32m try:[m
[32m+[m[32m self.tz = pytz.timezone(user.timezone)[m
[32m+[m[32m except:[m
[32m+[m[32m pass[m
if user.flags & User.ASCII_ICONS_FLAG:[m
self.CHECKS = [ '[_]', '[x]' ][m
# TODO: Add more of these.[m
[36m@@ -213,7 +218,7 @@[m [mBubble is open source:[m
cmt = 'View post' if post.num_cmts == 0 and is_user_post else \[m
'' if post.num_cmts == 0 else \[m
f'{post.num_cmts} comment{plural_s(post.num_cmts)}'[m
[31m- age = post.age()[m
[32m+[m[32m age = post.age(tz=self.tz)[m
bell = ' 馃敂' if post.num_notifs else ''[m
[m
author = '馃寬 ' + sub if sub else (post.poster_avatar + ' ' + post.poster_name)[m
[1mdiff --git a/db-migrate.sql b/db-migrate.sql[m
[1mindex 9262e7a..391761b 100644[m
[1m--- a/db-migrate.sql[m
[1m+++ b/db-migrate.sql[m
[36m@@ -20,4 +20,5 @@[m [mALTER TABLE posts ADD INDEX (user);[m
ALTER TABLE posts ADD INDEX (issueid);[m
[m
-- Migration from v4 to v5 --[m
[32m+[m[32mALTER TABLE users ADD COLUMN timezone VARCHAR(40) DEFAULT 'UTC';[m
ALTER TABLE users ADD COLUMN recovery VARCHAR(1000) DEFAULT '';[m
[1mdiff --git a/model.py b/model.py[m
[1mindex 7cb3ca2..c45808d 100644[m
[1m--- a/model.py[m
[1m+++ b/model.py[m
[36m@@ -96,14 +96,16 @@[m [mclass Notification:[m
self.subname = subname[m
self.reaction = reaction[m
[m
[31m- def ymd_date(self, fmt='%Y-%m-%d'):[m
[32m+[m[32m def ymd_date(self, fmt='%Y-%m-%d', tz=None):[m
dt = datetime.datetime.fromtimestamp(self.ts, UTC)[m
[32m+[m[32m if tz:[m
[32m+[m[32m dt = dt.astimezone(tz)[m
return dt.strftime(fmt)[m
[m
def age(self):[m
return ago_text(self.ts)[m
[m
[31m- def entry(self, show_age=True, with_time=False, with_title=True) -> tuple:[m
[32m+[m[32m def entry(self, show_age=True, with_time=False, with_title=True, tz=None) -> tuple:[m
"""Returns (link, label) to use in the notification list."""[m
[m
event = ''[m
[36m@@ -170,7 +172,7 @@[m [mclass Notification:[m
event += f' "{vis_title}"'[m
[m
age = f' 路 {self.age()}' if show_age else ''[m
[31m- hm_time = f" at {self.ymd_date('%H:%M')}" if with_time else ''[m
[32m+[m[32m hm_time = f" at {self.ymd_date('%H:%M', tz)}" if with_time else ''[m
return f'/notif/{self.id}', f'{icon}{self.src_name} {event}{hm_time}{age}'[m
[m
[m
[36m@@ -193,7 +195,8 @@[m [mclass User:[m
SHORT_PREVIEW_FLAG = 0x0020[m
[m
def __init__(self, id, name, info, url, recovery, avatar, role, flags, notif, email, email_inter, \[m
[31m- email_range, password, ts_password, ts_created, ts_active, sort_post, sort_cmt):[m
[32m+[m[32m email_range, password, ts_password, ts_created, ts_active, sort_post, sort_cmt,[m
[32m+[m[32m timezone):[m
self.id = id[m
self.name = name[m
self.info = info[m
[36m@@ -212,6 +215,7 @@[m [mclass User:[m
self.ts_active = ts_active[m
self.sort_post = sort_post[m
self.sort_cmt = sort_cmt[m
[32m+[m[32m self.timezone = timezone[m
[m
def subspace_link(self, prefix=''):[m
return f'=> /u/{self.name} {self.avatar} {prefix}u/{self.name}\n'[m
[36m@@ -265,8 +269,10 @@[m [mclass Commit:[m
self.msg = msg[m
self.ts = ts[m
[m
[31m- def ymd_date(self):[m
[32m+[m[32m def ymd_date(self, tz=None):[m
dt = datetime.datetime.fromtimestamp(self.ts, UTC)[m
[32m+[m[32m if tz:[m
[32m+[m[32m dt = dt.astimezone(tz)[m
return dt.strftime('%Y-%m-%d')[m
[m
def entry(self, view_url, outgoing=False):[m
[36m@@ -320,19 +326,23 @@[m [mclass Post:[m
else:[m
return '(untitled issue)' if self.issueid else '(untitled post)'[m
[m
[31m- def ymd_date(self):[m
[32m+[m[32m def ymd_date(self, tz=None):[m
dt = datetime.datetime.fromtimestamp(self.ts_created, UTC)[m
[32m+[m[32m if tz:[m
[32m+[m[32m dt = dt.astimezone(tz)[m
return dt.strftime('%Y-%m-%d')[m
[m
[31m- def ymd_hm_tz(self):[m
[32m+[m[32m def ymd_hm_tz(self, tz=None):[m
dt = datetime.datetime.fromtimestamp(self.ts_created, UTC)[m
[31m- return dt.strftime('%Y-%m-%d %H:%M UTC')[m
[32m+[m[32m if tz:[m
[32m+[m[32m dt = dt.astimezone(tz)[m
[32m+[m[32m return dt.strftime(f'%Y-%m-%d %H:%M %z')[m
[m
[31m- def age(self):[m
[32m+[m[32m def age(self, tz=None):[m
if self.is_draft:[m
return 'Now'[m
else:[m
[31m- return ago_text(self.ts_created)[m
[32m+[m[32m return ago_text(self.ts_created, tz=tz)[m
[m
def page_url(self):[m
if self.sub_owner:[m
[36m@@ -412,7 +422,8 @@[m [mclass Database:[m
ts_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,[m
ts_email TIMESTAMP DEFAULT '2000-01-01 00:00:00',[m
sort_post CHAR(1) DEFAULT 'r',[m
[31m- sort_cmt CHAR(1) DEFAULT 'n'[m
[32m+[m[32m sort_cmt CHAR(1) DEFAULT 'n',[m
[32m+[m[32m timezone VARCHAR(40) DEFAULT 'UTC'[m
)""", (0xffff,))[m
[m
db.execute("""CREATE TABLE IF NOT EXISTS certs ([m
[36m@@ -662,6 +673,7 @@[m [mclass Database:[m
UNIX_TIMESTAMP(u.ts_active),[m
u.sort_post,[m
u.sort_cmt,[m
[32m+[m[32m u.timezone,[m
s.id[m
FROM users u[m
JOIN subspaces s ON s.owner=u.id[m
[36m@@ -669,10 +681,12 @@[m [mclass Database:[m
[m
for (id, name, info, url, recovery, avatar, role, flags, notif, email, email_inter,[m
email_range, password, ts_password, ts_created, ts_active,[m
[31m- sort_post, sort_cmt, user_subspace_id) in cur:[m
[32m+[m[32m sort_post, sort_cmt, timezone, user_subspace_id) in cur:[m
user = User(id, name, info, url, recovery, avatar, role, flags, notif,[m
[31m- email, email_inter, email_range, password, \[m
[31m- ts_password, ts_created, ts_active, sort_post, sort_cmt)[m
[32m+[m[32m email, email_inter, email_range,[m
[32m+[m[32m password, ts_password,[m
[32m+[m[32m ts_created, ts_active,[m
[32m+[m[32m sort_post, sort_cmt, timezone)[m
user.moderated_subspace_ids = [user_subspace_id] + self.get_moderated_subspace_ids(user)[m
return user[m
[m
[36m@@ -721,7 +735,7 @@[m [mclass Database:[m
email=None, email_inter=None, email_range=None,[m
notif=None, flags=None,[m
password=None, password_expiration_offset_minutes=0,[m
[31m- sort_post=None, sort_cmt=None,[m
[32m+[m[32m sort_post=None, sort_cmt=None, timezone=None,[m
active=False):[m
if type(user) is User:[m
user = user.id[m
[36m@@ -768,6 +782,9 @@[m [mclass Database:[m
if sort_cmt:[m
stm.append('sort_cmt=?')[m
values.append(sort_cmt)[m
[32m+[m[32m if timezone:[m
[32m+[m[32m stm.append('timezone=?')[m
[32m+[m[32m values.append(timezone)[m
if active:[m
stm.append('ts_active=CURRENT_TIMESTAMP()')[m
if stm:[m
[36m@@ -1045,7 +1062,7 @@[m [mclass Database:[m
[m
mods = [][m
for (id, avatar, name) in cur:[m
[31m- mods.append(User(id, name, None, None, None, avatar, None, None, None, None, None, None,[m
[32m+[m[32m mods.append(User(id, name, None, None, None, avatar, None, None, None, None, None, None, None,[m
None, None, None, None, None, None))[m
return mods[m
[m
[36m@@ -2261,7 +2278,7 @@[m [mclass Search:[m
for (ts, id, name, avatar, info, url) in cur:[m
self.results.append(((exact_match(name), ts),[m
User(id, name, info, url, None, avatar, None, None, None,[m
[31m- None, None, None, None, None, ts, None, None, None)))[m
[32m+[m[32m None, None, None, None, None, ts, None, None, None, None)))[m
[m
# Subspaces.[m
cur.execute(f"""[m
[1mdiff --git a/requirements.txt b/requirements.txt[m
[1mnew file mode 100644[m
[1mindex 0000000..af44f19[m
[1m--- /dev/null[m
[1m+++ b/requirements.txt[m
[36m@@ -0,0 +1 @@[m
[32m+[m[32mpytz[m
[1mdiff --git a/settings.py b/settings.py[m
[1mindex 2fb5b76..4766529 100644[m
[1m--- a/settings.py[m
[1m+++ b/settings.py[m
[36m@@ -1,4 +1,5 @@[m
import math[m
[32m+[m[32mimport pytz[m
from utils import *[m
from model import Notification, User, Subspace, FOLLOW_USER, FOLLOW_SUBSPACE, \[m
MUTE_USER, MUTE_SUBSPACE[m
[36m@@ -176,7 +177,7 @@[m [mdef make_settings_page(session):[m
return 30, '/settings/notif'[m
[m
elif req.path == session.path + 'settings/email-range':[m
[31m- prompt = 'Hour range when emails are not sent, in UTC: (Examples: "0-6", "21-3")'[m
[32m+[m[32m prompt = f'{session.tz.localize(datetime.datetime.now()).tzname()} hour range (inclusive) when emails are not sent: (Examples: "0-6", "21-3")'[m
pattern = re.compile(r'(\d+)-(\d+)')[m
if req.query == None:[m
return 10, prompt[m
[36m@@ -188,6 +189,12 @@[m [mdef make_settings_page(session):[m
begin = min(23, begin)[m
end = max(0, end)[m
end = min(23, end)[m
[32m+[m[32m # Convert from user's time zone to UTC. The actual date doesn't matter,[m
[32m+[m[32m # we are just using the hours.[m
[32m+[m[32m dt_begin = session.tz.localize(datetime.datetime(2023, 6, 19, begin, 0, 0))[m
[32m+[m[32m dt_end = session.tz.localize(datetime.datetime(2023, 6, 19, end, 0, 0))[m
[32m+[m[32m begin = dt_begin.astimezone(pytz.utc).hour[m
[32m+[m[32m end = dt_end.astimezone(pytz.utc).hour[m
db.update_user(session.user, email_range=f"{begin}-{end}")[m
return 30, '/settings/notif'[m
[m
[36m@@ -287,7 +294,20 @@[m [mdef make_settings_page(session):[m
page += '\n### Email\n'[m
page += f'=> /settings/email 馃摟 Send to: {session.user.email if session.user.email else "(not set)"}\n'[m
page += f'\n=> /settings/email-inter Interval: {session.user.email_inter} minutes\n'[m
[31m- email_range = "(not set)" if not session.user.email_range else session.user.email_range[m
[32m+[m[32m #email_range = "(not set)" if not session.user.email_range else session.user.email_range[m
[32m+[m
[32m+[m[32m # Convert the range to local hours.[m
[32m+[m[32m if session.user.email_range:[m
[32m+[m[32m begin, end = map(int, session.user.email_range.split('-'))[m
[32m+[m[32m # The date doesn't matter, we are just using the hours.[m
[32m+[m[32m dt_begin = datetime.datetime(2023, 6, 19, begin, 0, 0, tzinfo=UTC)[m
[32m+[m[32m dt_end = datetime.datetime(2023, 6, 19, end, 0, 0, tzinfo=UTC)[m
[32m+[m[32m dt_begin = dt_begin.astimezone(session.tz)[m
[32m+[m[32m dt_end = dt_end.astimezone(session.tz)[m
[32m+[m[32m email_range = f"{dt_begin.hour}-{dt_end.hour}"[m
[32m+[m[32m else:[m
[32m+[m[32m email_range = "(not set)"[m
[32m+[m
page += f'=> /settings/email-range "Do not disturb" range: {email_range}\n'[m
[m
return page[m
[36m@@ -507,6 +527,18 @@[m [mdef make_settings_page(session):[m
page += '\n'[m
return page[m
[m
[32m+[m[32m elif req.path == session.path + 'settings/timezone':[m
[32m+[m[32m if is_empty_query(req):[m
[32m+[m[32m page = 'Select a time zone:\n\n'[m
[32m+[m[32m for tz in pytz.all_timezones:[m
[32m+[m[32m page += f'=> ?{tz} {tz}\n'[m
[32m+[m[32m return page[m
[32m+[m[32m tz = clean_query(req)[m
[32m+[m[32m if not tz in pytz.all_timezones:[m
[32m+[m[32m return 50, 'Invalid time zone'[m
[32m+[m[32m db.update_user(session.user, timezone=tz)[m
[32m+[m[32m return 30, '/settings/display'[m
[32m+[m
elif req.path == session.path + 'settings/display':[m
page += f'# {session.user.name}: Settings\n'[m
page += '=> /settings 鈿欙笍 Go back\n\n'[m
[36m@@ -515,10 +547,12 @@[m [mdef make_settings_page(session):[m
[m
page += '## Display\n\n'[m
page += f'=> /settings/short-preview {session.CHECKS[nonzero(session.user.flags & User.SHORT_PREVIEW_FLAG)]} Short post previews\n'[m
[32m+[m[32m page += f'\n=> /settings/timezone Time zone: {user.timezone}\n'[m
page += f'=> /settings/ascii Display icons as: {ICON_MODE[nonzero(session.user.flags & User.ASCII_ICONS_FLAG)]}\n'[m
return page[m
[m
[31m- elif req.path == session.path + 'settings':[m
[32m+[m[32m elif req.path == session.path + 'settings' or \[m
[32m+[m[32m req.path == session.path + 'settings/':[m
page = f'# {session.user.name}: Settings\n'[m
page += f'\n=> /dashboard Back to Dashboard\n'[m
page += f'=> / Back to front page\n\n'[m
[1mdiff --git a/subspace.py b/subspace.py[m
[1mindex 9afc67a..bff379d 100644[m
[1m--- a/subspace.py[m
[1m+++ b/subspace.py[m
[36m@@ -329,7 +329,7 @@[m [mdef make_search_page(session):[m
kind = "Comment" if obj.parent else f"Issue #{obj.issueid}" if obj.issueid else "Post"[m
title = f' "{shorten_text(obj.title, 30)}"' if obj.title else ''[m
scope_desc = f"in {ctx} " if not scope and not obj.sub_owner else ""[m
[31m- page += f'=> /{ctx}/{obj.id} {kind}{title} {scope_desc}by {obj.poster_avatar} {obj.poster_name} on {obj.ymd_date()} {" 路 " if obj.tags else ""}{obj.tags}\n'[m
[32m+[m[32m page += f'=> /{ctx}/{obj.id} {kind}{title} {scope_desc}by {obj.poster_avatar} {obj.poster_name} on {obj.ymd_date(tz=session.tz)} {" 路 " if obj.tags else ""}{obj.tags}\n'[m
SEGTYPES = ['content', 'URL', 'image', 'attachment', 'poll option'][m
if result[2] != Segment.TEXT:[m
page += f'(matching {SEGTYPES[result[2]]}) '[m
[1mdiff --git a/user.py b/user.py[m
[1mindex 12ad553..29490aa 100644[m
[1m--- a/user.py[m
[1m+++ b/user.py[m
[36m@@ -152,8 +152,8 @@[m [mdef user_actions(session):[m
notifs = db.get_notifications(session.user, include_hidden=True, sort_desc=True)[m
cur_ymd = None[m
for notif in notifs:[m
[31m- ymd = notif.ymd_date()[m
[31m- link, label = notif.entry(show_age=False, with_time=True)[m
[32m+[m[32m ymd = notif.ymd_date(tz=session.tz)[m
[32m+[m[32m link, label = notif.entry(show_age=False, with_time=True, tz=session.tz)[m
if cur_ymd != ymd:[m
cur_ymd = ymd[m
page += f'## {ymd}\n\n'[m
[36m@@ -168,7 +168,7 @@[m [mdef user_actions(session):[m
page += '\nNo activity.\n'[m
for notif in notifs:[m
link, label = notif.entry(show_age=False)[m
[31m- page += f'=> {link} {notif.ymd_date()} {label}\n'[m
[32m+[m[32m page += f'=> {link} {notif.ymd_date(tz=session.tz)} {label}\n'[m
return page[m
[m
notif = db.get_notification(session.user, notif_id, clear=True)[m
[36m@@ -242,7 +242,7 @@[m [mdef make_dashboard_page(session):[m
sub = f' 路 s/{post.sub_name}'[m
else:[m
sub = ''[m
[31m- page += f'=> /edit/{post.id} {post.ymd_date()} {post.title_text()}{sub}\n'[m
[32m+[m[32m page += f'=> /edit/{post.id} {post.ymd_date(tz=session.tz)} {post.title_text()}{sub}\n'[m
[m
subs = db.get_subspaces(mod=session.user.id)[m
n = len(subs)[m
[1mdiff --git a/utils.py b/utils.py[m
[1mindex c0b883c..1b8c5bb 100644[m
[1m--- a/utils.py[m
[1m+++ b/utils.py[m
[36m@@ -169,7 +169,8 @@[m [mdef shorten_text(text, n):[m
[m
[m
def time_delta_text(sec, date_ts, suffix='ago', now='Now',[m
[31m- date_prefix='', date_fmt='%Y-%m-%d', date_sep=' 路 '):[m
[32m+[m[32m date_prefix='', date_fmt='%Y-%m-%d', date_sep=' 路 ',[m
[32m+[m[32m tz=None):[m
if sec < 2:[m
return now[m
if sec < 60:[m
[36m@@ -182,6 +183,8 @@[m [mdef time_delta_text(sec, date_ts, suffix='ago', now='Now',[m
return f'{hours} hour{plural_s(hours)} {suffix}'[m
days = round(sec / 3600 / 24)[m
dt = datetime.datetime.fromtimestamp(date_ts, UTC)[m
[32m+[m[32m if tz:[m
[32m+[m[32m dt = dt.astimezone(tz)[m
age = date_prefix + dt.strftime(date_fmt)[m
if days < 14:[m
return age + f'{date_sep}{days} day{plural_s(days)} {suffix}'[m
[36m@@ -195,9 +198,9 @@[m [mdef time_delta_text(sec, date_ts, suffix='ago', now='Now',[m
return age + f'{date_sep}{years} year{plural_s(years)} {suffix}'[m
[m
[m
[31m-def ago_text(ts, suffix='ago', now='Now'):[m
[32m+[m[32mdef ago_text(ts, suffix='ago', now='Now', tz=None):[m
sec = max(0, int(time.time()) - ts)[m
[31m- return time_delta_text(sec, ts, suffix, now)[m
[32m+[m[32m return time_delta_text(sec, ts, suffix, now, tz=tz)[m
[m
[m
def is_empty_query(req):[m
[1mdiff --git a/worker.py b/worker.py[m
[1mindex 7773beb..0af06cf 100644[m
[1m--- a/worker.py[m
[1m+++ b/worker.py[m
[36m@@ -69,7 +69,7 @@[m [mclass Emailer (threading.Thread):[m
pending_notifs.append(User(id, name, None, None, None, None, None,[m
None, enabled_types, email, None,[m
email_range, None, None, None, None,[m
[31m- None, None))[m
[32m+[m[32m None, None, None))[m
[m
messages = {}[m
footer = \[m
text/plain
This content has been proxied by September (ba2dc).