Bubble [main]

User-specific time zones

=> 374faca7bee892540231982b2b3ab5d36419d3a1

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

This content has been proxied by September (ba2dc).