feat: use @github/relative-time-element to display time

This commit is contained in:
Steven 2024-03-13 23:47:34 +08:00
parent e795149186
commit ded4da07a3
11 changed files with 64 additions and 158 deletions

View File

@ -11,6 +11,7 @@
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0",
"@github/relative-time-element": "^4.3.1",
"@matejmazur/react-katex": "^3.1.3",
"@mui/joy": "5.0.0-beta.30",
"@reduxjs/toolkit": "^1.9.7",

7
web/pnpm-lock.yaml generated
View File

@ -14,6 +14,9 @@ dependencies:
'@emotion/styled':
specifier: ^11.11.0
version: 11.11.0(@emotion/react@11.11.4)(@types/react@18.2.63)(react@18.2.0)
'@github/relative-time-element':
specifier: ^4.3.1
version: 4.3.1
'@matejmazur/react-katex':
specifier: ^3.1.3
version: 3.1.3(katex@0.16.9)(react@18.2.0)
@ -905,6 +908,10 @@ packages:
resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==}
dev: false
/@github/relative-time-element@4.3.1:
resolution: {integrity: sha512-zL79nlhZVCg7x2Pf/HT5MB0mowmErE71VXpF10/3Wy8dQwkninNO1M9aOizh2wKC5LkSpDXqNYjDZwbH0/bcSg==}
dev: false
/@humanwhocodes/config-array@0.11.14:
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'}

View File

@ -1,22 +0,0 @@
import { useTranslate } from "@/utils/i18n";
interface Props {
className?: string;
}
const BetaBadge: React.FC<Props> = (props: Props) => {
const { className } = props;
const t = useTranslate();
return (
<span
className={`mx-1 px-1 leading-5 text-xs border font-normal dark:border-zinc-600 rounded-full text-gray-500 dark:text-gray-400 ${
className ?? ""
}`}
>
{t("common.beta")}
</span>
);
};
export default BetaBadge;

View File

@ -1,7 +1,9 @@
import { Dropdown, Menu, MenuButton, MenuItem } from "@mui/joy";
import classNames from "classnames";
import toast from "react-hot-toast";
import { useLocation } from "react-router-dom";
import Icon from "@/components/Icon";
import useNavigateTo from "@/hooks/useNavigateTo";
import { useMemoStore } from "@/store/v1";
import { RowStatus } from "@/types/proto/api/v2/common";
import { Memo } from "@/types/proto/api/v2/memo_service";
@ -14,14 +16,15 @@ interface Props {
memo: Memo;
className?: string;
hiddenActions?: ("edit" | "archive" | "delete" | "share" | "pin")[];
onArchived?: () => void;
onDeleted?: () => void;
}
const MemoActionMenu = (props: Props) => {
const { memo, hiddenActions } = props;
const t = useTranslate();
const location = useLocation();
const navigateTo = useNavigateTo();
const memoStore = useMemoStore();
const isInMemoDetailPage = location.pathname.startsWith(`/m/${memo.name}`);
const handleTogglePinMemoBtnClick = async () => {
try {
@ -66,9 +69,12 @@ const MemoActionMenu = (props: Props) => {
} catch (error: any) {
console.error(error);
toast.error(error.response.data.message);
return;
}
if (props.onArchived) {
props.onArchived();
toast.success("Archived successfully");
if (isInMemoDetailPage) {
navigateTo("/archived");
}
};
@ -80,8 +86,9 @@ const MemoActionMenu = (props: Props) => {
dialogName: "delete-memo-dialog",
onConfirm: async () => {
await memoStore.deleteMemo(memo.id);
if (props.onDeleted) {
props.onDeleted();
toast.success("Deleted successfully");
if (isInMemoDetailPage) {
navigateTo("/");
}
},
});

View File

@ -2,7 +2,6 @@ import { useContext, useEffect } from "react";
import { Link } from "react-router-dom";
import Icon from "@/components/Icon";
import MemoResourceListView from "@/components/MemoResourceListView";
import { getDateTimeString } from "@/helpers/datetime";
import useLoading from "@/hooks/useLoading";
import { useMemoStore } from "@/store/v1";
import MemoContent from "..";
@ -51,7 +50,9 @@ const EmbeddedMemo = ({ resourceId, params: paramsStr }: Props) => {
return (
<div className="relative flex flex-col justify-start items-start w-full p-4 pt-3 !my-2 bg-white dark:bg-zinc-800 rounded-lg border border-gray-200 dark:border-zinc-700 hover:shadow">
<div className="w-full mb-1 flex flex-row justify-between items-center">
<span className="text-sm text-gray-400 select-none">{getDateTimeString(memo.displayTime)}</span>
<div className="text-sm leading-6 text-gray-400 select-none">
<relative-time datetime={memo.displayTime?.toISOString()} tense="past"></relative-time>
</div>
<Link className="hover:opacity-80" to={`/m/${memo.name}`} unstable_viewTransition>
<Icon.ArrowUpRight className="w-5 h-auto opacity-80 text-gray-400" />
</Link>

View File

@ -74,7 +74,7 @@ const MemoContent: React.FC<Props> = (props: Props) => {
<div
ref={memoContentContainerRef}
className={classNames(
"w-full max-w-full word-break text-base leading-6 space-y-1 whitespace-pre-wrap",
"w-full max-w-full word-break text-base leading-7 space-y-1 whitespace-pre-wrap",
showCompactMode && "line-clamp-6",
)}
onClick={handleMemoContentClick}

View File

@ -1,9 +1,7 @@
import { Tooltip } from "@mui/joy";
import classNames from "classnames";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { getRelativeTimeString, getTimeStampByDate } from "@/helpers/datetime";
import { Link, useLocation } from "react-router-dom";
import useCurrentUser from "@/hooks/useCurrentUser";
import useNavigateTo from "@/hooks/useNavigateTo";
import { useUserStore, extractUsernameFromName } from "@/store/v1";
@ -25,6 +23,7 @@ import VisibilityIcon from "./VisibilityIcon";
interface Props {
memo: Memo;
compact?: boolean;
showVisibility?: boolean;
showPinned?: boolean;
className?: string;
@ -33,12 +32,11 @@ interface Props {
const MemoView: React.FC<Props> = (props: Props) => {
const { memo, className } = props;
const t = useTranslate();
const location = useLocation();
const navigateTo = useNavigateTo();
const { i18n } = useTranslation();
const currentUser = useCurrentUser();
const userStore = useUserStore();
const user = useCurrentUser();
const [displayTime, setDisplayTime] = useState<string>(getRelativeTimeString(getTimeStampByDate(memo.displayTime)));
const [creator, setCreator] = useState(userStore.getUserByUsername(extractUsernameFromName(memo.creator)));
const memoContainerRef = useRef<HTMLDivElement>(null);
const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
@ -46,6 +44,7 @@ const MemoView: React.FC<Props> = (props: Props) => {
(relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemoId === memo.id,
).length;
const readonly = memo.creator !== user?.name;
const isInMemoDetailPage = location.pathname.startsWith(`/m/${memo.name}`);
// Initial related data: creator.
useEffect(() => {
@ -55,20 +54,6 @@ const MemoView: React.FC<Props> = (props: Props) => {
})();
}, []);
// Update display time string.
useEffect(() => {
let intervalFlag: any = -1;
if (Date.now() - getTimeStampByDate(memo.displayTime) < 1000 * 60 * 60 * 24) {
intervalFlag = setInterval(() => {
setDisplayTime(getRelativeTimeString(getTimeStampByDate(memo.displayTime)));
}, 1000 * 1);
}
return () => {
clearInterval(intervalFlag);
};
}, [i18n.language]);
const handleGotoMemoDetailPage = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.altKey) {
showChangeMemoCreatedTsDialog(memo.id);
@ -124,8 +109,8 @@ const MemoView: React.FC<Props> = (props: Props) => {
</div>
)}
</div>
<div className="flex flex-row justify-end items-center select-none shrink-0 gap-1">
<div className="w-auto invisible group-hover:visible flex flex-row justify-between items-center gap-1">
<div className="flex flex-row justify-end items-center select-none shrink-0 gap-2">
<div className="w-auto invisible group-hover:visible flex flex-row justify-between items-center gap-2">
{props.showVisibility && memo.visibility !== Visibility.PRIVATE && (
<Tooltip title={t(`memo.visibility.${convertVisibilityToString(memo.visibility).toLowerCase()}` as any)} placement="top">
<span className="flex justify-center items-center hover:opacity-70">
@ -133,25 +118,27 @@ const MemoView: React.FC<Props> = (props: Props) => {
</span>
</Tooltip>
)}
{currentUser && <ReactionSelector className="border-none" memo={memo} />}
{currentUser && <ReactionSelector className="border-none w-auto h-auto" memo={memo} />}
</div>
<Link
className={classNames(
"flex flex-row justify-start items-center hover:opacity-70",
commentAmount === 0 && "invisible group-hover:visible",
)}
to={`/m/${memo.name}#comments`}
unstable_viewTransition
>
<Icon.MessageCircleMore className="w-4 h-4 mx-auto text-gray-500 dark:text-gray-400" />
{commentAmount > 0 && <span className="text-xs text-gray-500 dark:text-gray-400">{commentAmount}</span>}
</Link>
{!isInMemoDetailPage && (
<Link
className={classNames(
"flex flex-row justify-start items-center hover:opacity-70",
commentAmount === 0 && "invisible group-hover:visible",
)}
to={`/m/${memo.name}#comments`}
unstable_viewTransition
>
<Icon.MessageCircleMore className="w-4 h-4 mx-auto text-gray-500 dark:text-gray-400" />
{commentAmount > 0 && <span className="text-xs text-gray-500 dark:text-gray-400">{commentAmount}</span>}
</Link>
)}
{props.showPinned && memo.pinned && (
<Tooltip title={"Pinned"} placement="top">
<Icon.Bookmark className="ml-1 w-4 h-auto text-amber-500" />
<Icon.Bookmark className="w-4 h-auto text-amber-500" />
</Tooltip>
)}
{!readonly && <MemoActionMenu memo={memo} hiddenActions={props.showPinned ? [] : ["pin"]} />}
{!readonly && <MemoActionMenu className="-ml-1" memo={memo} hiddenActions={props.showPinned ? [] : ["pin"]} />}
</div>
</div>
<MemoContent
@ -160,13 +147,13 @@ const MemoView: React.FC<Props> = (props: Props) => {
content={memo.content}
readonly={readonly}
onClick={handleMemoContentClick}
compact={true}
compact={props.compact ?? true}
/>
<MemoResourceListView resources={memo.resources} />
<div className="w-full flex flex-row justify-between items-center mt-1">
<span className="text-sm leading-6 text-gray-400 select-none" onClick={handleGotoMemoDetailPage}>
{displayTime}
</span>
<div className="w-full flex flex-row justify-between items-center">
<div className="text-sm leading-6 text-gray-400 select-none">
<relative-time datetime={memo.displayTime?.toISOString()} tense="past" onClick={handleGotoMemoDetailPage}></relative-time>
</div>
</div>
<MemoRelationListView memo={memo} relations={referencedMemos} />
<MemoReactionistView memo={memo} reactions={memo.reactions} />

View File

@ -4,13 +4,6 @@ export function getTimeStampByDate(t: Date | number | string | any): number {
return new Date(t).getTime();
}
export function getDateStampByDate(t?: Date | number | string): number {
const tsFromDate = getTimeStampByDate(t ? t : Date.now());
const d = new Date(tsFromDate);
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
}
/**
* Get a time string to provided time.
*
@ -57,56 +50,6 @@ export function getDateTimeString(t?: Date | number | string | any, locale = i18
}
}
/**
* Get a localized relative time string to provided time.
*
* Possible outputs for "long" format and "en" locale:
* - "x seconds ago"
* - "x minutes ago"
* - "x hours ago"
* - "yesterday"
* - "x days ago"
* - "x weeks ago"
* - "x months ago"
* - "last year"
* - "x years ago"
*/
export const getRelativeTimeString = (time: number, locale = i18n.language, formatStyle: "long" | "short" | "narrow" = "long"): string => {
const pastTimeMillis = Date.now() - time;
const secMillis = 1000;
const minMillis = secMillis * 60;
const hourMillis = minMillis * 60;
const dayMillis = hourMillis * 24;
// Show full date if more than 1 day ago.
if (pastTimeMillis >= dayMillis) {
return getDateTimeString(time, locale);
}
// numeric: "auto" provides "yesterday" for 1 day ago, "always" provides "1 day ago"
const formatOpts = { style: formatStyle, numeric: "auto" } as Intl.RelativeTimeFormatOptions;
const relTime = new Intl.RelativeTimeFormat(locale, formatOpts);
if (pastTimeMillis < minMillis) {
return relTime.format(-Math.round(pastTimeMillis / secMillis), "second");
}
if (pastTimeMillis < hourMillis) {
return relTime.format(-Math.round(pastTimeMillis / minMillis), "minute");
}
if (pastTimeMillis < dayMillis) {
return relTime.format(-Math.round(pastTimeMillis / hourMillis), "hour");
}
if (pastTimeMillis < dayMillis * 7) {
return relTime.format(-Math.round(pastTimeMillis / dayMillis), "day");
}
if (pastTimeMillis < dayMillis * 30) {
return relTime.format(-Math.round(pastTimeMillis / (dayMillis * 7)), "week");
}
if (pastTimeMillis < dayMillis * 365) {
return relTime.format(-Math.round(pastTimeMillis / (dayMillis * 30)), "month");
}
return relTime.format(-Math.round(pastTimeMillis / (dayMillis * 365)), "year");
};
/**
* This returns the normalized date string of the provided date.
* Format is always `YYYY-MM-DDT00:00`.
@ -143,33 +86,6 @@ export function getNormalizedDateString(t?: Date | number | string): string {
return `${yyyy}-${MM}-${dd}`;
}
/**
* This returns the Unix timestamp (the number of **seconds** since the Unix Epoch) of the provided date.
*
* If no date is provided, the current date is used.
* ```
* getUnixTime("2019-01-25 00:00") // 1548381600
* ```
* This value is floored to the nearest second, and does not include a milliseconds component.
*/
export function getUnixTime(t?: Date | number | string): number {
const date = new Date(t ? t : Date.now());
return Math.floor(date.getTime() / 1000);
}
/**
* Checks if the provided date or timestamp is in the future.
*
* If no date is provided, the current date is used.
*
* @param t - Date or timestamp to check.
* @returns `true` if the date is in the future, `false` otherwise.
*/
export function isFutureDate(t?: Date | number | string): boolean {
const timestamp = getTimeStampByDate(t ? t : Date.now());
return timestamp > Date.now();
}
/**
* Calculates a new Date object by adjusting the provided date, timestamp, or date string
* based on the current timezone offset.

View File

@ -1,3 +1,4 @@
import "@github/relative-time-element";
import { CssVarsProvider } from "@mui/joy";
import { createRoot } from "react-dom/client";
import { Toaster } from "react-hot-toast";

View File

@ -11,7 +11,6 @@ import MobileHeader from "@/components/MobileHeader";
import SearchBar from "@/components/SearchBar";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import { getTimeStampByDate } from "@/helpers/datetime";
import { getDateTimeString } from "@/helpers/datetime";
import useCurrentUser from "@/hooks/useCurrentUser";
import useFilterWithUrlParams from "@/hooks/useFilterWithUrlParams";
import { useMemoList, useMemoStore } from "@/store/v1";
@ -105,7 +104,9 @@ const Archived = () => {
>
<div className="w-full mb-1 flex flex-row justify-between items-center">
<div className="w-full max-w-[calc(100%-20px)] flex flex-row justify-start items-center mr-1">
<span className="text-sm text-gray-400 select-none">{getDateTimeString(memo.displayTime)}</span>
<div className="text-sm leading-6 text-gray-400 select-none">
<relative-time datetime={memo.displayTime?.toISOString()} tense="past"></relative-time>
</div>
</div>
<div className="flex flex-row justify-end items-center gap-x-2">
<Tooltip title={t("common.restore")} placement="top">

View File

@ -81,7 +81,14 @@ const MemoDetail = () => {
</Link>
</div>
)}
<MemoView key={`${memo.id}-${memo.displayTime}`} className="shadow hover:shadow-xl transition-all" memo={memo} />
<MemoView
key={`${memo.id}-${memo.displayTime}`}
className="shadow hover:shadow-xl transition-all"
memo={memo}
compact={false}
showVisibility
showPinned
/>
<div className="pt-8 pb-16 w-full">
<h2 id="comments" className="sr-only">
Comments