feat: add timezone display

This commit is contained in:
sam 2023-08-02 23:37:22 +02:00
commit 2a4ddaeea5
No known key found for this signature in database
GPG key ID: B4EF20DDE721CAA1
9 changed files with 139 additions and 4 deletions

View file

@ -52,6 +52,7 @@ type User struct {
IsAdmin bool IsAdmin bool
ListPrivate bool ListPrivate bool
LastSIDReroll time.Time `db:"last_sid_reroll"` LastSIDReroll time.Time `db:"last_sid_reroll"`
Timezone *string
DeletedAt *time.Time DeletedAt *time.Time
SelfDelete *bool SelfDelete *bool
@ -113,6 +114,21 @@ func (u User) NumProviders() (numProviders int) {
return numProviders 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 type Badge int32
const ( const (
@ -539,9 +555,10 @@ func (db *DB) UpdateUser(
memberTitle *string, listPrivate *bool, memberTitle *string, listPrivate *bool,
links *[]string, links *[]string,
avatar *string, avatar *string,
timezone *string,
customPreferences *CustomPreferences, customPreferences *CustomPreferences,
) (u User, err error) { ) (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() sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql()
if err != nil { if err != nil {
return u, errors.Wrap(err, "building sql") return u, errors.Wrap(err, "building sql")
@ -577,6 +594,13 @@ func (db *DB) UpdateUser(
builder = builder.Set("member_title", *memberTitle) 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 { if links != nil {
builder = builder.Set("links", *links) builder = builder.Set("links", *links)
} }

View file

@ -28,12 +28,14 @@ type GetUserResponse struct {
CustomPreferences db.CustomPreferences `json:"custom_preferences"` CustomPreferences db.CustomPreferences `json:"custom_preferences"`
Flags []db.UserFlag `json:"flags"` Flags []db.UserFlag `json:"flags"`
Badges db.Badge `json:"badges"` Badges db.Badge `json:"badges"`
UTCOffset *int `json:"utc_offset"`
} }
type GetMeResponse struct { type GetMeResponse struct {
GetUserResponse GetUserResponse
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Timezone *string `json:"timezone"`
MaxInvites int `json:"max_invites"` MaxInvites int `json:"max_invites"`
IsAdmin bool `json:"is_admin"` 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 resp.Badges |= db.BadgeAdmin
} }
if offset, ok := u.UTCOffset(); ok {
resp.UTCOffset = &offset
}
resp.Members = make([]PartialMember, len(members)) resp.Members = make([]PartialMember, len(members))
for i := range members { for i := range members {
resp.Members[i] = PartialMember{ resp.Members[i] = PartialMember{
@ -195,6 +201,7 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
render.JSON(w, r, GetMeResponse{ render.JSON(w, r, GetMeResponse{
GetUserResponse: dbUserToResponse(u, fields, members, flags), GetUserResponse: dbUserToResponse(u, fields, members, flags),
CreatedAt: u.ID.Time(), CreatedAt: u.ID.Time(),
Timezone: u.Timezone,
MaxInvites: u.MaxInvites, MaxInvites: u.MaxInvites,
IsAdmin: u.IsAdmin, IsAdmin: u.IsAdmin,
ListPrivate: u.ListPrivate, ListPrivate: u.ListPrivate,

View file

@ -25,6 +25,7 @@ type PatchUserRequest struct {
Pronouns *[]db.PronounEntry `json:"pronouns"` Pronouns *[]db.PronounEntry `json:"pronouns"`
Fields *[]db.Field `json:"fields"` Fields *[]db.Field `json:"fields"`
Avatar *string `json:"avatar"` Avatar *string `json:"avatar"`
Timezone *string `json:"timezone"`
ListPrivate *bool `json:"list_private"` ListPrivate *bool `json:"list_private"`
CustomPreferences *db.CustomPreferences `json:"custom_preferences"` CustomPreferences *db.CustomPreferences `json:"custom_preferences"`
Flags *[]xid.ID `json:"flags"` 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 // validate links
if req.Links != nil { if req.Links != nil {
if len(*req.Links) > db.MaxUserLinksLength { 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 { if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
log.Errorf("updating user: %v", err) log.Errorf("updating user: %v", err)
return 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 // echo the updated user back on success
render.JSON(w, r, GetMeResponse{ render.JSON(w, r, GetMeResponse{
GetUserResponse: dbUserToResponse(u, fields, nil, flags), GetUserResponse: dbUserToResponse(u, fields, nil, flags),
CreatedAt: u.ID.Time(),
Timezone: u.Timezone,
MaxInvites: u.MaxInvites, MaxInvites: u.MaxInvites,
IsAdmin: u.IsAdmin, IsAdmin: u.IsAdmin,
ListPrivate: u.ListPrivate, ListPrivate: u.ListPrivate,

View file

@ -15,6 +15,7 @@ export interface User {
links: string[]; links: string[];
member_title: string | null; member_title: string | null;
badges: number; badges: number;
utc_offset: number | null;
names: FieldEntry[]; names: FieldEntry[];
pronouns: Pronoun[]; pronouns: Pronoun[];
@ -57,6 +58,7 @@ export interface MeUser extends User {
fediverse_instance: string | null; fediverse_instance: string | null;
list_private: boolean; list_private: boolean;
last_sid_reroll: string; last_sid_reroll: string;
timezone: string | null;
} }
export interface Field { export interface Field {

View file

@ -13,7 +13,9 @@
Modal, Modal,
ModalBody, ModalBody,
ModalFooter, ModalFooter,
Tooltip,
} from "sveltestrap"; } from "sveltestrap";
import { DateTime, Duration, FixedOffsetZone, Zone } from "luxon";
import FieldCard from "$lib/components/FieldCard.svelte"; import FieldCard from "$lib/components/FieldCard.svelte";
import PronounLink from "$lib/components/PronounLink.svelte"; import PronounLink from "$lib/components/PronounLink.svelte";
import PartialMemberCard from "$lib/components/PartialMemberCard.svelte"; import PartialMemberCard from "$lib/components/PartialMemberCard.svelte";
@ -77,6 +79,24 @@
let memberNameValid = true; let memberNameValid = true;
$: memberNameValid = memberNameRegex.test(newMemberName); $: 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 () => { const createMember = async () => {
try { try {
const member = await apiFetchClient<Member>("/members", "POST", { const member = await apiFetchClient<Member>("/members", "POST", {
@ -168,6 +188,10 @@
{:else} {:else}
<h2>@{data.name} <Badges userBadges={data.badges} /></h2> <h2>@{data.name} <Badges userBadges={data.badges} /></h2>
{/if} {/if}
{#if data.utc_offset}
<Tooltip target="user-clock" placement="top">Current time</Tooltip>
<Icon id="user-clock" name="clock" aria-label="This user's current time" /> {currentTime} <span class="text-body-secondary">(UTC{timezone})</span>
{/if}
{#if profileEmpty && $userStore?.id === data.id} {#if profileEmpty && $userStore?.id === data.id}
<hr /> <hr />
<p> <p>

View file

@ -21,14 +21,16 @@
CardBody, CardBody,
CardHeader, CardHeader,
FormGroup, FormGroup,
InputGroup,
Icon, Icon,
Input, Input,
Popover, Popover,
TabContent, TabContent,
TabPane, TabPane,
InputGroupText,
} from "sveltestrap"; } from "sveltestrap";
import { encode } from "base64-arraybuffer"; import { encode } from "base64-arraybuffer";
import { DateTime } from "luxon"; import { DateTime, FixedOffsetZone } from "luxon";
import { apiFetchClient } from "$lib/api/fetch"; import { apiFetchClient } from "$lib/api/fetch";
import { PUBLIC_SHORT_BASE } from "$env/static/public"; import { PUBLIC_SHORT_BASE } from "$env/static/public";
import IconButton from "$lib/components/IconButton.svelte"; import IconButton from "$lib/components/IconButton.svelte";
@ -60,6 +62,7 @@
let flags: PrideFlag[] = window.structuredClone(data.user.flags); let flags: PrideFlag[] = window.structuredClone(data.user.flags);
let list_private = data.user.list_private; let list_private = data.user.list_private;
let custom_preferences = window.structuredClone(data.user.custom_preferences); let custom_preferences = window.structuredClone(data.user.custom_preferences);
let timezone = data.user.timezone;
let avatar: string | null; let avatar: string | null;
let avatar_files: FileList | null; let avatar_files: FileList | null;
@ -98,6 +101,7 @@
member_title, member_title,
list_private, list_private,
custom_preferences, custom_preferences,
timezone,
); );
$: getAvatar(avatar_files).then((b64) => (avatar = b64)); $: getAvatar(avatar_files).then((b64) => (avatar = b64));
@ -114,6 +118,7 @@
member_title: string, member_title: string,
list_private: boolean, list_private: boolean,
custom_preferences: CustomPreferences, custom_preferences: CustomPreferences,
timezone: string | null,
) => { ) => {
if (bio !== (user.bio || "")) return true; if (bio !== (user.bio || "")) return true;
if (display_name !== (user.display_name || "")) return true; if (display_name !== (user.display_name || "")) return true;
@ -126,6 +131,7 @@
if (!customPreferencesEqual(custom_preferences, user.custom_preferences)) return true; if (!customPreferencesEqual(custom_preferences, user.custom_preferences)) return true;
if (avatar !== null) return true; if (avatar !== null) return true;
if (list_private !== user.list_private) return true; if (list_private !== user.list_private) return true;
if (timezone !== user.timezone) return true;
return false; return false;
}; };
@ -208,6 +214,24 @@
return uri; 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) => { const moveName = (index: number, up: boolean) => {
if (up && index == 0) return; if (up && index == 0) return;
if (!up && index == names.length - 1) return; if (!up && index == names.length - 1) return;
@ -361,6 +385,7 @@
fields, fields,
member_title, member_title,
list_private, list_private,
timezone: timezone || "",
custom_preferences, custom_preferences,
flags: flags.map((flag) => flag.id), flags: flags.map((flag) => flag.id),
}); });
@ -403,6 +428,10 @@
addToast({ body: "Copied the short link to your clipboard!", duration: 2000 }); addToast({ body: "Copied the short link to your clipboard!", duration: 2000 });
}; };
const detectTimezone = () => {
timezone = DateTime.local().zoneName;
};
interface SnapshotData { interface SnapshotData {
bio: string; bio: string;
display_name: string; display_name: string;
@ -413,6 +442,7 @@
fields: Field[]; fields: Field[];
flags: PrideFlag[]; flags: PrideFlag[];
list_private: boolean; list_private: boolean;
timezone: string | null;
custom_preferences: CustomPreferences; custom_preferences: CustomPreferences;
avatar: string | null; avatar: string | null;
@ -432,6 +462,7 @@
fields, fields,
flags, flags,
list_private, list_private,
timezone,
custom_preferences, custom_preferences,
avatar, avatar,
newName, newName,
@ -448,6 +479,7 @@
fields = value.fields; fields = value.fields;
flags = value.flags; flags = value.flags;
list_private = value.list_private; list_private = value.list_private;
timezone = value.timezone;
custom_preferences = value.custom_preferences; custom_preferences = value.custom_preferences;
avatar = value.avatar; avatar = value.avatar;
newName = value.newName; newName = value.newName;
@ -787,6 +819,30 @@
<code class="text-nowrap">pronouns.cc/@{data.user.name}/[member-name]</code>. <code class="text-nowrap">pronouns.cc/@{data.user.name}/[member-name]</code>.
</strong> </strong>
</p> </p>
<hr />
<div class="m-1">
<p class="mt-1 my-2">
You can optionally set your timezone, which will show your current local time on your
profile.
</p>
<InputGroup>
<Button on:click={detectTimezone}>Detect timezone</Button>
<Input disabled value={timezone !== null ? timezone : "Unset"} />
<Button on:click={() => (timezone = null)}>Reset</Button>
</InputGroup>
<p class="mt-2">
{#if timezone}
This will show up on your profile like this:
<Icon name="clock" aria-hidden />
{currentTime} <span class="text-body-secondary">(UTC{displayTimezone})</span>
<br />
{/if}
<span class="text-muted">
Your timezone is never shared directly, only the difference between UTC and your
current timezone is.
</span>
</p>
</div>
</div> </div>
</div> </div>
<div> <div>

View file

@ -5,6 +5,7 @@ import { plugin as markdown, Mode as MarkdownMode } from "vite-plugin-markdown";
export default defineConfig({ export default defineConfig({
plugins: [sveltekit(), markdown({ mode: [MarkdownMode.HTML] })], plugins: [sveltekit(), markdown({ mode: [MarkdownMode.HTML] })],
server: { server: {
host: "127.0.0.1",
proxy: { proxy: {
"/api": { "/api": {
target: "http://localhost:8080", target: "http://localhost:8080",

View file

@ -0,0 +1,5 @@
-- +migrate Up
-- 2023-07-30: Add user timezones
alter table users add column timezone text null;

View file

@ -48,7 +48,7 @@ func run(c *cli.Context) error {
return err 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 { if err != nil {
fmt.Println("error setting user info:", err) fmt.Println("error setting user info:", err)
return err return err