From e10db2fa09ec48f4a3c2553645d6af547f65ec38 Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 30 Jul 2023 23:13:35 +0200 Subject: [PATCH 1/3] feat: display timezone --- backend/db/user.go | 16 +++++++++++++ backend/routes/user/get_user.go | 7 ++++++ backend/routes/user/patch_user.go | 2 ++ frontend/src/lib/api/entities.ts | 1 + frontend/src/routes/@[username]/+page.svelte | 24 ++++++++++++++++++++ scripts/migrate/019_timezones.sql | 5 ++++ 6 files changed, 55 insertions(+) create mode 100644 scripts/migrate/019_timezones.sql diff --git a/backend/db/user.go b/backend/db/user.go index ccb0965..dd222a9 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -52,6 +52,7 @@ type User struct { IsAdmin bool ListPrivate bool LastSIDReroll time.Time `db:"last_sid_reroll"` + Timezone *string DeletedAt *time.Time SelfDelete *bool @@ -113,6 +114,21 @@ func (u User) NumProviders() (numProviders int) { return numProviders } +// UTCOffset returns the user's UTC offset in seconds. If the user does not have a timezone set, `ok` is false. +func (u User) UTCOffset() (offset int, ok bool) { + if u.Timezone == nil { + return 0, false + } + + loc, err := time.LoadLocation(*u.Timezone) + if err != nil { + return 0, false + } + + _, offset = time.Now().In(loc).Zone() + return offset, true +} + type Badge int32 const ( diff --git a/backend/routes/user/get_user.go b/backend/routes/user/get_user.go index f6a9aae..d1163a3 100644 --- a/backend/routes/user/get_user.go +++ b/backend/routes/user/get_user.go @@ -28,12 +28,14 @@ type GetUserResponse struct { CustomPreferences db.CustomPreferences `json:"custom_preferences"` Flags []db.UserFlag `json:"flags"` Badges db.Badge `json:"badges"` + UTCOffset *int `json:"utc_offset"` } type GetMeResponse struct { GetUserResponse CreatedAt time.Time `json:"created_at"` + Timezone *string `json:"timezone"` MaxInvites int `json:"max_invites"` IsAdmin bool `json:"is_admin"` @@ -87,6 +89,10 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags [ resp.Badges |= db.BadgeAdmin } + if offset, ok := u.UTCOffset(); ok { + resp.UTCOffset = &offset + } + resp.Members = make([]PartialMember, len(members)) for i := range members { resp.Members[i] = PartialMember{ @@ -195,6 +201,7 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error { render.JSON(w, r, GetMeResponse{ GetUserResponse: dbUserToResponse(u, fields, members, flags), CreatedAt: u.ID.Time(), + Timezone: u.Timezone, MaxInvites: u.MaxInvites, IsAdmin: u.IsAdmin, ListPrivate: u.ListPrivate, diff --git a/backend/routes/user/patch_user.go b/backend/routes/user/patch_user.go index 716dcca..a5c634c 100644 --- a/backend/routes/user/patch_user.go +++ b/backend/routes/user/patch_user.go @@ -311,6 +311,8 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { // echo the updated user back on success render.JSON(w, r, GetMeResponse{ GetUserResponse: dbUserToResponse(u, fields, nil, flags), + CreatedAt: u.ID.Time(), + Timezone: u.Timezone, MaxInvites: u.MaxInvites, IsAdmin: u.IsAdmin, ListPrivate: u.ListPrivate, diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index 61c5c92..523cc0e 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -14,6 +14,7 @@ export interface User { links: string[]; member_title: string | null; badges: number; + utc_offset: number | null; names: FieldEntry[]; pronouns: Pronoun[]; diff --git a/frontend/src/routes/@[username]/+page.svelte b/frontend/src/routes/@[username]/+page.svelte index a181ee7..0492ad2 100644 --- a/frontend/src/routes/@[username]/+page.svelte +++ b/frontend/src/routes/@[username]/+page.svelte @@ -13,7 +13,9 @@ Modal, ModalBody, ModalFooter, + Tooltip, } from "sveltestrap"; + import { DateTime, Duration, FixedOffsetZone, Zone } from "luxon"; import FieldCard from "$lib/components/FieldCard.svelte"; import PronounLink from "$lib/components/PronounLink.svelte"; import PartialMemberCard from "$lib/components/PartialMemberCard.svelte"; @@ -77,6 +79,24 @@ let memberNameValid = true; $: memberNameValid = memberNameRegex.test(newMemberName); + let currentTime: string | null; + let timezone: string | null; + $: setTime(data.utc_offset); + + const setTime = (offset: number | null) => { + if (!offset) { + currentTime = null; + timezone = null; + return; + } + + const now = DateTime.now(); + const zone = FixedOffsetZone.instance(offset / 60); + + currentTime = now.setZone(zone).toLocaleString(DateTime.TIME_SIMPLE); + timezone = zone.formatOffset(now.toUnixInteger(), "narrow"); + }; + const createMember = async () => { try { const member = await apiFetchClient("/members", "POST", { @@ -168,6 +188,10 @@ {:else}

@{data.name}

{/if} + {#if data.utc_offset} + Current time + {currentTime} (UTC{timezone}) + {/if} {#if profileEmpty && $userStore?.id === data.id}

diff --git a/scripts/migrate/019_timezones.sql b/scripts/migrate/019_timezones.sql new file mode 100644 index 0000000..f45c69c --- /dev/null +++ b/scripts/migrate/019_timezones.sql @@ -0,0 +1,5 @@ +-- +migrate Up + +-- 2023-07-30: Add user timezones + +alter table users add column timezone text null; From 3e3ccd971bd8491f8594688e93c39ca7ab09d042 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 2 Aug 2023 23:24:38 +0200 Subject: [PATCH 2/3] feat: add timezone settings --- backend/db/user.go | 10 +++- backend/routes/user/patch_user.go | 16 ++++- frontend/src/lib/api/entities.ts | 1 + frontend/src/routes/edit/profile/+page.svelte | 59 ++++++++++++++++++- frontend/vite.config.ts | 1 + scripts/seeddb/main.go | 2 +- 6 files changed, 85 insertions(+), 4 deletions(-) diff --git a/backend/db/user.go b/backend/db/user.go index dd222a9..7f6834e 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -555,9 +555,10 @@ func (db *DB) UpdateUser( memberTitle *string, listPrivate *bool, links *[]string, avatar *string, + timezone *string, customPreferences *CustomPreferences, ) (u User, err error) { - if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil && customPreferences == nil { + if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil && timezone == nil && customPreferences == nil { sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql() if err != nil { return u, errors.Wrap(err, "building sql") @@ -593,6 +594,13 @@ func (db *DB) UpdateUser( builder = builder.Set("member_title", *memberTitle) } } + if timezone != nil { + if *timezone == "" { + builder = builder.Set("timezone", nil) + } else { + builder = builder.Set("timezone", *timezone) + } + } if links != nil { builder = builder.Set("links", *links) } diff --git a/backend/routes/user/patch_user.go b/backend/routes/user/patch_user.go index a5c634c..a07f6e9 100644 --- a/backend/routes/user/patch_user.go +++ b/backend/routes/user/patch_user.go @@ -25,6 +25,7 @@ type PatchUserRequest struct { Pronouns *[]db.PronounEntry `json:"pronouns"` Fields *[]db.Field `json:"fields"` Avatar *string `json:"avatar"` + Timezone *string `json:"timezone"` ListPrivate *bool `json:"list_private"` CustomPreferences *db.CustomPreferences `json:"custom_preferences"` Flags *[]xid.ID `json:"flags"` @@ -91,6 +92,19 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { } } + // validate timezone + if req.Timezone != nil { + if *req.Timezone != "" { + _, err := time.LoadLocation(*req.Timezone) + if err != nil { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("%q is not a valid timezone", *req.Timezone), + } + } + } + } + // validate links if req.Links != nil { if len(*req.Links) > db.MaxUserLinksLength { @@ -224,7 +238,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { } } - u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash, req.CustomPreferences) + u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash, req.Timezone, req.CustomPreferences) if err != nil && errors.Cause(err) != db.ErrNothingToUpdate { log.Errorf("updating user: %v", err) return err diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index 523cc0e..d4e6b7a 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -57,6 +57,7 @@ export interface MeUser extends User { fediverse_instance: string | null; list_private: boolean; last_sid_reroll: string; + timezone: string | null; } export interface Field { diff --git a/frontend/src/routes/edit/profile/+page.svelte b/frontend/src/routes/edit/profile/+page.svelte index da01fe3..b4b7673 100644 --- a/frontend/src/routes/edit/profile/+page.svelte +++ b/frontend/src/routes/edit/profile/+page.svelte @@ -21,14 +21,16 @@ CardBody, CardHeader, FormGroup, + InputGroup, Icon, Input, Popover, TabContent, TabPane, + InputGroupText, } from "sveltestrap"; import { encode } from "base64-arraybuffer"; - import { DateTime } from "luxon"; + import { DateTime, FixedOffsetZone } from "luxon"; import { apiFetchClient } from "$lib/api/fetch"; import { PUBLIC_SHORT_BASE } from "$env/static/public"; import IconButton from "$lib/components/IconButton.svelte"; @@ -60,6 +62,7 @@ let flags: PrideFlag[] = window.structuredClone(data.user.flags); let list_private = data.user.list_private; let custom_preferences = window.structuredClone(data.user.custom_preferences); + let timezone = data.user.timezone; let avatar: string | null; let avatar_files: FileList | null; @@ -98,6 +101,7 @@ member_title, list_private, custom_preferences, + timezone, ); $: getAvatar(avatar_files).then((b64) => (avatar = b64)); @@ -114,6 +118,7 @@ member_title: string, list_private: boolean, custom_preferences: CustomPreferences, + timezone: string | null, ) => { if (bio !== (user.bio || "")) return true; if (display_name !== (user.display_name || "")) return true; @@ -126,6 +131,7 @@ if (!customPreferencesEqual(custom_preferences, user.custom_preferences)) return true; if (avatar !== null) return true; if (list_private !== user.list_private) return true; + if (timezone !== user.timezone) return true; return false; }; @@ -208,6 +214,24 @@ return uri; }; + let currentTime = ""; + let displayTimezone = ""; + $: setTime(timezone); + + const setTime = (timezone: string | null) => { + if (!timezone) { + currentTime = ""; + displayTimezone = ""; + return; + } + + const offset = DateTime.now().setZone(timezone).offset; + const zone = FixedOffsetZone.instance(offset); + + currentTime = now.setZone(zone).toLocaleString(DateTime.TIME_SIMPLE); + displayTimezone = zone.formatOffset(now.toUnixInteger(), "narrow"); + }; + const moveName = (index: number, up: boolean) => { if (up && index == 0) return; if (!up && index == names.length - 1) return; @@ -361,6 +385,7 @@ fields, member_title, list_private, + timezone: timezone || "", custom_preferences, flags: flags.map((flag) => flag.id), }); @@ -403,6 +428,10 @@ addToast({ body: "Copied the short link to your clipboard!", duration: 2000 }); }; + const detectTimezone = () => { + timezone = DateTime.local().zoneName; + }; + interface SnapshotData { bio: string; display_name: string; @@ -413,6 +442,7 @@ fields: Field[]; flags: PrideFlag[]; list_private: boolean; + timezone: string | null; custom_preferences: CustomPreferences; avatar: string | null; @@ -432,6 +462,7 @@ fields, flags, list_private, + timezone, custom_preferences, avatar, newName, @@ -448,6 +479,7 @@ fields = value.fields; flags = value.flags; list_private = value.list_private; + timezone = value.timezone; custom_preferences = value.custom_preferences; avatar = value.avatar; newName = value.newName; @@ -787,6 +819,31 @@ pronouns.cc/@{data.user.name}/[member-name].

+
+
+

+ You can optionally set your timezone, which will show your current local time on your + profile. +

+ + + + + + +

+ {#if timezone} + This will show up on your profile like this: + + {currentTime} (UTC{displayTimezone}) +
+ {/if} + + Your timezone is never shared directly, only the difference between UTC and your + current timezone is. + +

+
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7d8ed95..5f528ea 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,6 +5,7 @@ import { plugin as markdown, Mode as MarkdownMode } from "vite-plugin-markdown"; export default defineConfig({ plugins: [sveltekit(), markdown({ mode: [MarkdownMode.HTML] })], server: { + host: "127.0.0.1", proxy: { "/api": { target: "http://localhost:8080", diff --git a/scripts/seeddb/main.go b/scripts/seeddb/main.go index 3755617..d18b354 100644 --- a/scripts/seeddb/main.go +++ b/scripts/seeddb/main.go @@ -48,7 +48,7 @@ func run(c *cli.Context) error { return err } - _, err = pg.UpdateUser(ctx, tx, u.ID, ptr("testing"), ptr("This is a bio!"), nil, ptr(false), &[]string{"https://pronouns.cc"}, nil, nil) + _, err = pg.UpdateUser(ctx, tx, u.ID, ptr("testing"), ptr("This is a bio!"), nil, ptr(false), &[]string{"https://pronouns.cc"}, nil, nil, nil) if err != nil { fmt.Println("error setting user info:", err) return err From 32ad02a26075dd1d58d9b9a9f346eca423771438 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 2 Aug 2023 23:27:28 +0200 Subject: [PATCH 3/3] tweak detect timezone button placement --- frontend/src/routes/edit/profile/+page.svelte | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/routes/edit/profile/+page.svelte b/frontend/src/routes/edit/profile/+page.svelte index b4b7673..8baa2d4 100644 --- a/frontend/src/routes/edit/profile/+page.svelte +++ b/frontend/src/routes/edit/profile/+page.svelte @@ -826,9 +826,8 @@ profile.

- + -