pl-fe: display account local time if specified

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2025-07-02 13:09:06 +02:00
parent 6dadad462a
commit 8be1b7e5e5
6 changed files with 118 additions and 6 deletions

View File

@ -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<IAccountHoverCard> = ({ visible = true }) => {
};
}, []);
const { x, y, strategy, refs, context, placement } = useFloating({
open: !!account,
elements: {
@ -115,6 +118,8 @@ const AccountHoverCard: React.FC<IAccountHoverCard> = ({ 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 (
<div
className={clsx({
@ -158,6 +163,10 @@ const AccountHoverCard: React.FC<IAccountHoverCard> = ({ visible = true }) => {
</HStack>
) : null}
{timezoneField && (
<AccountLocalTime accountId={account.id} field={timezoneField} />
)}
{account.pronouns.length > 0 && (
<HStack alignItems='center' space={0.5}>
<Icon

View File

@ -0,0 +1,94 @@
import { Account } from 'pl-api';
import React, { useEffect, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useLoggedIn } from 'pl-fe/hooks/use-logged-in';
import HStack from './ui/hstack';
import Icon from './ui/icon';
import Text from './ui/text';
const supportedTimeZones = Intl.supportedValuesOf('timeZone');
const UTC_REGEX = /(GMT|UTC)([+-])([0-9]{1,2})/i;
const getSupportedTimezone = (value: string): string | false => {
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<IAccountLocalTime> = ({ accountId, field }) => {
const intl = useIntl();
const { me } = useLoggedIn();
const [localTime, setLocalTime] = useState<string | null>(null);
const [isTimezoneEqual, setIsTimezoneEqual] = useState<boolean>(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 (
<HStack className='mt-1' alignItems='center' space={0.5} title={intl.formatMessage(messages.timezone, { timezone: field.value })}>
<Icon
src={require('@tabler/icons/outline/clock.svg')}
className='size-4 text-gray-800 dark:text-gray-200'
/>
<Text size='sm'>
{localTime}
{me !== accountId && isTimezoneEqual && (
<span className='text-green-500'> <FormattedMessage id='account.timezone.equal' defaultMessage='(same as you)' /></span>
)}
</Text>
</HStack>
);
};
export { AccountLocalTime as default };

View File

@ -8,7 +8,7 @@ import ProfileField from '../profile-field';
import type { Account } from 'pl-fe/normalizers/account';
interface IProfileFieldsPanel {
account: Pick<Account, 'emojis' | 'fields'>;
account: Pick<Account, 'emojis' | 'fields' | 'id'>;
}
/** Custom profile fields for sidebar. */
@ -16,7 +16,7 @@ const ProfileFieldsPanel: React.FC<IProfileFieldsPanel> = ({ account }) => (
<Widget>
<Stack space={4}>
{account.fields.map((field, i) => (
<ProfileField field={field} key={i} emojis={account.emojis} />
<ProfileField field={field} key={i} emojis={account.emojis} accountId={account.id} />
))}
</Stack>
</Widget>

View File

@ -231,7 +231,7 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
{account.fields.length > 0 && (
<Stack space={2} className='mt-4 xl:hidden'>
{account.fields.map((field, i) => (
<ProfileField field={field} key={i} emojis={account.emojis} />
<ProfileField field={field} key={i} emojis={account.emojis} accountId={account.id} />
))}
</Stack>
)}

View File

@ -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<IProfileField> = ({ field, emojis }) => {
const ProfileField: React.FC<IProfileField> = ({ accountId, field, emojis }) => {
const intl = useIntl();
if (isTicker(field.name)) {
@ -72,9 +76,12 @@ const ProfileField: React.FC<IProfileField> = ({ field, emojis }) => {
<ParsedContent html={field.value} emojis={emojis} />
</Markup>
</HStack>
{isTimezoneLabel(field.name) && (
<AccountLocalTime accountId={accountId} field={field} />
)}
</dd>
</dl>
);
};
export { ProfileField as default };
export { ProfileField as default, isTimezoneLabel };

View File

@ -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",