From 8be1b7e5e568c4c038c5b6c8b1172d3794222c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Wed, 2 Jul 2025 13:09:06 +0200 Subject: [PATCH] pl-fe: display account local time if specified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .../src/components/account-hover-card.tsx | 9 ++ .../src/components/account-local-time.tsx | 94 +++++++++++++++++++ .../panels/profile-fields-panel.tsx | 4 +- .../components/panels/profile-info-panel.tsx | 2 +- .../features/ui/components/profile-field.tsx | 13 ++- packages/pl-fe/src/locales/en.json | 2 + 6 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 packages/pl-fe/src/components/account-local-time.tsx diff --git a/packages/pl-fe/src/components/account-hover-card.tsx b/packages/pl-fe/src/components/account-hover-card.tsx index 84e9d0526..169b55185 100644 --- a/packages/pl-fe/src/components/account-hover-card.tsx +++ b/packages/pl-fe/src/components/account-hover-card.tsx @@ -14,12 +14,14 @@ import Icon from 'pl-fe/components/ui/icon'; import Stack from 'pl-fe/components/ui/stack'; import Text from 'pl-fe/components/ui/text'; import ActionButton from 'pl-fe/features/ui/components/action-button'; +import { isTimezoneLabel } from 'pl-fe/features/ui/components/profile-field'; import { UserPanel } from 'pl-fe/features/ui/util/async-components'; import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; import { useAppSelector } from 'pl-fe/hooks/use-app-selector'; import { accountScrobbleQueryOptions } from 'pl-fe/queries/accounts/account-scrobble'; import { useAccountHoverCardStore } from 'pl-fe/stores/account-hover-card'; +import AccountLocalTime from './account-local-time'; import { showAccountHoverCard } from './hover-account-wrapper'; import { ParsedContent } from './parsed-content'; import { dateFormatOptions } from './relative-timestamp'; @@ -77,6 +79,7 @@ const AccountHoverCard: React.FC = ({ visible = true }) => { }; }, []); + const { x, y, strategy, refs, context, placement } = useFloating({ open: !!account, elements: { @@ -115,6 +118,8 @@ const AccountHoverCard: React.FC = ({ visible = true }) => { const memberSinceDate = intl.formatDate(account.created_at, { month: 'long', year: 'numeric' }); const followedBy = me !== account.id && account.relationship?.followed_by === true; + const timezoneField = account.fields.find(field => isTimezoneLabel(field.name)); + return (
= ({ visible = true }) => { ) : null} + {timezoneField && ( + + )} + {account.pronouns.length > 0 && ( { + let foundTimezone = supportedTimeZones.find((tz) => value.toLowerCase().startsWith(tz.toLowerCase())); + if (!foundTimezone) { + const match = value.match(UTC_REGEX); + if (match) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, __, sign, hours] = match; + foundTimezone = supportedTimeZones.find((tz) => tz.toLowerCase() === `etc/gmt${sign === '+' ? '-' : '+'}${+hours}`); + } + } + return foundTimezone || false; +}; + +const messages = defineMessages({ + timezone: { id: 'account.timezone', defaultMessage: 'Timezone: {timezone}' }, +}); + +interface IAccountLocalTime { + accountId: string; + field: Account['fields'][number]; +} + +const AccountLocalTime: React.FC = ({ accountId, field }) => { + const intl = useIntl(); + + const { me } = useLoggedIn(); + const [localTime, setLocalTime] = useState(null); + const [isTimezoneEqual, setIsTimezoneEqual] = useState(false); + + useEffect(() => { + const timezone = getSupportedTimezone(field.value); + if (!timezone) return; + + const format = Intl.DateTimeFormat(intl.locale, { + timeZone: timezone, + timeStyle: 'short', + }); + + { + const dateNow = new Date(); + const yourTime = dateNow.toLocaleString(intl.locale, { timeStyle: 'short' }); + const userLocalTime = format.format(dateNow); + setLocalTime(userLocalTime); + setIsTimezoneEqual(userLocalTime === yourTime); + } + + let timer: NodeJS.Timeout | null = null; + const init = setInterval(() => { + if (new Date().getSeconds() === 0) { + clearInterval(init); + timer = setInterval(() => { + setLocalTime(format.format(new Date())); + }, 60000); + } + }, 1000); + + return () => { + if (timer) clearInterval(timer); + clearInterval(init); + }; + }, [field.name]); + + if (!localTime) return null; + + return ( + + + + {localTime} + {me !== accountId && isTimezoneEqual && ( + + )} + + + ); +}; + +export { AccountLocalTime as default }; diff --git a/packages/pl-fe/src/features/ui/components/panels/profile-fields-panel.tsx b/packages/pl-fe/src/features/ui/components/panels/profile-fields-panel.tsx index dd2be80b9..09a8a4376 100644 --- a/packages/pl-fe/src/features/ui/components/panels/profile-fields-panel.tsx +++ b/packages/pl-fe/src/features/ui/components/panels/profile-fields-panel.tsx @@ -8,7 +8,7 @@ import ProfileField from '../profile-field'; import type { Account } from 'pl-fe/normalizers/account'; interface IProfileFieldsPanel { - account: Pick; + account: Pick; } /** Custom profile fields for sidebar. */ @@ -16,7 +16,7 @@ const ProfileFieldsPanel: React.FC = ({ account }) => ( {account.fields.map((field, i) => ( - + ))} diff --git a/packages/pl-fe/src/features/ui/components/panels/profile-info-panel.tsx b/packages/pl-fe/src/features/ui/components/panels/profile-info-panel.tsx index 888c2b108..a597ae110 100644 --- a/packages/pl-fe/src/features/ui/components/panels/profile-info-panel.tsx +++ b/packages/pl-fe/src/features/ui/components/panels/profile-info-panel.tsx @@ -231,7 +231,7 @@ const ProfileInfoPanel: React.FC = ({ account, username }) => {account.fields.length > 0 && ( {account.fields.map((field, i) => ( - + ))} )} diff --git a/packages/pl-fe/src/features/ui/components/profile-field.tsx b/packages/pl-fe/src/features/ui/components/profile-field.tsx index 18ea25f59..b734b555c 100644 --- a/packages/pl-fe/src/features/ui/components/profile-field.tsx +++ b/packages/pl-fe/src/features/ui/components/profile-field.tsx @@ -1,7 +1,8 @@ import clsx from 'clsx'; import React from 'react'; -import { defineMessages, useIntl, FormatDateOptions } from 'react-intl'; +import { defineMessages, useIntl, type FormatDateOptions } from 'react-intl'; +import AccountLocalTime from 'pl-fe/components/account-local-time'; import Markup from 'pl-fe/components/markup'; import { ParsedContent } from 'pl-fe/components/parsed-content'; import HStack from 'pl-fe/components/ui/hstack'; @@ -16,6 +17,8 @@ const getTicker = (value: string): string => (value.match(/\$([a-zA-Z]*)/i) || [ const isTicker = (value: string): boolean => Boolean(getTicker(value)); const isZapEmoji = (value: string) => /^\u26A1[\uFE00-\uFE0F]?$/.test(value); +const isTimezoneLabel = (value: string) => /^time( |)zone$/i.test(value); + const messages = defineMessages({ linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' }, }); @@ -30,12 +33,13 @@ const dateFormatOptions: FormatDateOptions = { }; interface IProfileField { + accountId: string; field: Account['fields'][number]; emojis?: Account['emojis']; } /** Renders a single profile field. */ -const ProfileField: React.FC = ({ field, emojis }) => { +const ProfileField: React.FC = ({ accountId, field, emojis }) => { const intl = useIntl(); if (isTicker(field.name)) { @@ -72,9 +76,12 @@ const ProfileField: React.FC = ({ field, emojis }) => { + {isTimezoneLabel(field.name) && ( + + )} ); }; -export { ProfileField as default }; +export { ProfileField as default, isTimezoneLabel }; diff --git a/packages/pl-fe/src/locales/en.json b/packages/pl-fe/src/locales/en.json index af55de495..2d038f60e 100644 --- a/packages/pl-fe/src/locales/en.json +++ b/packages/pl-fe/src/locales/en.json @@ -73,6 +73,8 @@ "account.subscribe.failure": "An error occurred trying to subscribe to this account.", "account.subscribe.success": "You have subscribed to this account.", "account.subscribers": "Subscribers", + "account.timezone": "Timezone: {timezone}", + "account.timezone.equal": "(same as you)", "account.unblock": "Unblock @{name}", "account.unblock_domain": "Unhide {domain}", "account.unendorse": "Don't feature on profile",