forked from mirrors/pronouns.cc
feat: add timezone display
This commit is contained in:
commit
2a4ddaeea5
9 changed files with 139 additions and 4 deletions
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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",
|
||||||
|
|
5
scripts/migrate/019_timezones.sql
Normal file
5
scripts/migrate/019_timezones.sql
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
-- +migrate Up
|
||||||
|
|
||||||
|
-- 2023-07-30: Add user timezones
|
||||||
|
|
||||||
|
alter table users add column timezone text null;
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue