diff --git a/backend/db/user.go b/backend/db/user.go index ccb0965..7f6834e 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 ( @@ -539,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") @@ -577,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/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..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 @@ -311,6 +325,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 248072b..b635c8b 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -15,6 +15,7 @@ export interface User { links: string[]; member_title: string | null; badges: number; + utc_offset: number | null; names: FieldEntry[]; pronouns: Pronoun[]; @@ -57,6 +58,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/@[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/frontend/src/routes/edit/profile/+page.svelte b/frontend/src/routes/edit/profile/+page.svelte index da01fe3..8baa2d4 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,30 @@ 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/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; 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