fix: various fixes to things + simply app proxy sentry setup
This commit is contained in:
@@ -170,22 +170,6 @@ function stripApiSuffix(url) {
|
||||
return url.endsWith('/api') ? url.slice(0, -4) : url;
|
||||
}
|
||||
|
||||
function parseSentryDsn(dsn) {
|
||||
if (!dsn) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(dsn);
|
||||
const path = parsed.pathname.replace(/^\/+|\/+$/g, '');
|
||||
const segments = path ? path.split('/') : [];
|
||||
const projectId = segments.length > 0 ? segments[segments.length - 1] : undefined;
|
||||
const publicKey = parsed.username || undefined;
|
||||
return {projectId, publicKey};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAppPublic(config) {
|
||||
const appPublic = getValue(config, ['app_public'], {});
|
||||
const domain = getValue(config, ['domain'], {});
|
||||
@@ -193,25 +177,13 @@ function resolveAppPublic(config) {
|
||||
const endpoints = deriveEndpointsFromDomain(domain, overrides);
|
||||
const defaultBootstrapEndpoint = endpoints.api;
|
||||
const defaultPublicEndpoint = stripApiSuffix(endpoints.api);
|
||||
const sentryDsn =
|
||||
asString(appPublic.sentry_dsn) ?? asString(getValue(config, ['services', 'app_proxy', 'sentry_dsn']));
|
||||
const sentryParsed = parseSentryDsn(sentryDsn);
|
||||
const sentryDsn = asString(appPublic.sentry_dsn);
|
||||
return {
|
||||
apiVersion: asString(appPublic.api_version, '1'),
|
||||
bootstrapApiEndpoint: asString(appPublic.bootstrap_api_endpoint, defaultBootstrapEndpoint),
|
||||
bootstrapApiPublicEndpoint: asString(appPublic.bootstrap_api_public_endpoint, defaultPublicEndpoint),
|
||||
relayDirectoryUrl: asString(appPublic.relay_directory_url),
|
||||
sentryDsn,
|
||||
sentryProxyPath: asString(
|
||||
appPublic.sentry_proxy_path,
|
||||
asString(getValue(config, ['services', 'app_proxy', 'sentry_proxy_path']), '/error-reporting-proxy'),
|
||||
),
|
||||
sentryReportHost: asString(
|
||||
appPublic.sentry_report_host,
|
||||
asString(getValue(config, ['services', 'app_proxy', 'sentry_report_host']), ''),
|
||||
),
|
||||
sentryProjectId: asString(appPublic.sentry_project_id, sentryParsed.projectId),
|
||||
sentryPublicKey: asString(appPublic.sentry_public_key, sentryParsed.publicKey),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,9 +234,6 @@ export default () => {
|
||||
PUBLIC_BUILD_TIMESTAMP: buildMetadata.buildTimestamp,
|
||||
PUBLIC_RELEASE_CHANNEL: buildMetadata.releaseChannel,
|
||||
PUBLIC_SENTRY_DSN: appPublic.sentryDsn ?? null,
|
||||
PUBLIC_SENTRY_PROJECT_ID: appPublic.sentryProjectId ?? null,
|
||||
PUBLIC_SENTRY_PUBLIC_KEY: appPublic.sentryPublicKey ?? null,
|
||||
PUBLIC_SENTRY_PROXY_PATH: appPublic.sentryProxyPath,
|
||||
PUBLIC_API_VERSION: appPublic.apiVersion,
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: appPublic.bootstrapApiEndpoint,
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: appPublic.bootstrapApiPublicEndpoint ?? appPublic.bootstrapApiEndpoint,
|
||||
@@ -482,9 +451,6 @@ export default () => {
|
||||
'import.meta.env.PUBLIC_BUILD_TIMESTAMP': getPublicEnvVar(publicValues, 'PUBLIC_BUILD_TIMESTAMP'),
|
||||
'import.meta.env.PUBLIC_RELEASE_CHANNEL': getPublicEnvVar(publicValues, 'PUBLIC_RELEASE_CHANNEL'),
|
||||
'import.meta.env.PUBLIC_SENTRY_DSN': getPublicEnvVar(publicValues, 'PUBLIC_SENTRY_DSN'),
|
||||
'import.meta.env.PUBLIC_SENTRY_PROJECT_ID': getPublicEnvVar(publicValues, 'PUBLIC_SENTRY_PROJECT_ID'),
|
||||
'import.meta.env.PUBLIC_SENTRY_PUBLIC_KEY': getPublicEnvVar(publicValues, 'PUBLIC_SENTRY_PUBLIC_KEY'),
|
||||
'import.meta.env.PUBLIC_SENTRY_PROXY_PATH': getPublicEnvVar(publicValues, 'PUBLIC_SENTRY_PROXY_PATH'),
|
||||
'import.meta.env.PUBLIC_API_VERSION': getPublicEnvVar(publicValues, 'PUBLIC_API_VERSION'),
|
||||
'import.meta.env.PUBLIC_BOOTSTRAP_API_ENDPOINT': getPublicEnvVar(publicValues, 'PUBLIC_BOOTSTRAP_API_ENDPOINT'),
|
||||
'import.meta.env.PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT': getPublicEnvVar(
|
||||
|
||||
@@ -189,7 +189,13 @@ export const PreloadableUserPopout = React.forwardRef<
|
||||
<Popout
|
||||
ref={ref}
|
||||
render={({popoutKey}) => (
|
||||
<UserProfilePopout popoutKey={popoutKey} user={user} isWebhook={isWebhook} guildId={guildId} />
|
||||
<UserProfilePopout
|
||||
key={`${user.id}:${guildId ?? 'global'}:${isWebhook ? 'webhook' : 'user'}`}
|
||||
popoutKey={popoutKey}
|
||||
user={user}
|
||||
isWebhook={isWebhook}
|
||||
guildId={guildId}
|
||||
/>
|
||||
)}
|
||||
position={position}
|
||||
tooltip={tooltip}
|
||||
|
||||
@@ -129,6 +129,10 @@ export const UserProfileMobileSheet: React.FC = observer(function UserProfileMob
|
||||
);
|
||||
const [profile, setProfile] = useState<ProfileRecord | null>(initialProfile);
|
||||
const [isProfileLoading, setIsProfileLoading] = useState(() => !initialProfile);
|
||||
const profileMatchesContext = profile?.userId === userId && (profile?.guildId ?? null) === (guildId ?? null);
|
||||
const activeProfile = profileMatchesContext ? profile : initialProfile;
|
||||
const isContextSwitching = Boolean(userId) && !activeProfile && !profileMatchesContext;
|
||||
const shouldShowProfileLoading = (isProfileLoading && !activeProfile) || isContextSwitching;
|
||||
|
||||
useEffect(() => {
|
||||
setProfile(initialProfile);
|
||||
@@ -136,7 +140,7 @@ export const UserProfileMobileSheet: React.FC = observer(function UserProfileMob
|
||||
}, [initialProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || profile) {
|
||||
if (!userId || activeProfile) {
|
||||
setIsProfileLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -164,7 +168,7 @@ export const UserProfileMobileSheet: React.FC = observer(function UserProfileMob
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId, guildId, profile]);
|
||||
}, [userId, guildId, activeProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!guildId || !userId) {
|
||||
@@ -190,22 +194,24 @@ export const UserProfileMobileSheet: React.FC = observer(function UserProfileMob
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveProfile: ProfileRecord | null = profile ?? mockProfile ?? fallbackProfile;
|
||||
const effectiveProfile: ProfileRecord | null = activeProfile ?? mockProfile ?? fallbackProfile;
|
||||
const resolvedProfile: ProfileRecord = effectiveProfile ?? fallbackProfile!;
|
||||
const userNote = userId ? UserNoteStore.getUserNote(userId) : null;
|
||||
|
||||
const handleClose = () => {
|
||||
store.close();
|
||||
};
|
||||
const profileIdentityKey = `${displayUser.id}:${guildId ?? 'global'}`;
|
||||
|
||||
return (
|
||||
<UserProfileMobileSheetContent
|
||||
key={profileIdentityKey}
|
||||
user={displayUser}
|
||||
profile={resolvedProfile}
|
||||
userNote={userNote}
|
||||
guildId={guildId}
|
||||
autoFocusNote={autoFocusNote}
|
||||
isLoading={isProfileLoading}
|
||||
isLoading={shouldShowProfileLoading}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -112,32 +112,53 @@ export const UserProfilePopout: React.FC<UserProfilePopoutProps> = observer(
|
||||
[isWebhook, user.id, guildId, popoutKey],
|
||||
);
|
||||
|
||||
const fetchProfile = useCallback(async () => {
|
||||
if (isWebhook) return;
|
||||
|
||||
const isGuildMember = guildId ? GuildMemberStore.getMember(guildId, user.id) : false;
|
||||
|
||||
if (DeveloperOptionsStore.slowProfileLoad) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const cachedProfile = UserProfileStore.getProfile(user.id, guildId);
|
||||
setProfile(cachedProfile ?? createMockProfile(user));
|
||||
setProfileLoadError(false);
|
||||
|
||||
try {
|
||||
const fetchedProfile = await UserProfileActionCreators.fetch(user.id, isGuildMember ? guildId : undefined);
|
||||
setProfile(fetchedProfile);
|
||||
setProfileLoadError(false);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch profile for user popout', error);
|
||||
const cachedProfile = UserProfileStore.getProfile(user.id, guildId);
|
||||
setProfile(cachedProfile ?? createMockProfile(user));
|
||||
setProfileLoadError(true);
|
||||
if (isWebhook) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
}, [guildId, user.id, isWebhook]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
const isGuildMember = guildId ? GuildMemberStore.getMember(guildId, user.id) : false;
|
||||
|
||||
if (DeveloperOptionsStore.slowProfileLoad) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchedProfile = await UserProfileActionCreators.fetch(user.id, isGuildMember ? guildId : undefined);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setProfile(fetchedProfile);
|
||||
setProfileLoadError(false);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to fetch profile for user popout', error);
|
||||
const nextCachedProfile = UserProfileStore.getProfile(user.id, guildId);
|
||||
setProfile(nextCachedProfile ?? createMockProfile(user));
|
||||
setProfileLoadError(true);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProfile();
|
||||
}, [fetchProfile]);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [guildId, isWebhook, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (profileLoadError && profile) {
|
||||
|
||||
@@ -248,7 +248,13 @@ const SpectatorRow = observer(function SpectatorRow({
|
||||
return (
|
||||
<Popout
|
||||
render={({popoutKey}) => (
|
||||
<UserProfilePopout popoutKey={popoutKey} user={user} isWebhook={false} guildId={guildId} />
|
||||
<UserProfilePopout
|
||||
key={`${user.id}:${guildId ?? 'global'}:user`}
|
||||
popoutKey={popoutKey}
|
||||
user={user}
|
||||
isWebhook={false}
|
||||
guildId={guildId}
|
||||
/>
|
||||
)}
|
||||
position="left-start"
|
||||
onOpen={onPopoutOpen}
|
||||
|
||||
@@ -157,7 +157,13 @@ function VoiceParticipantPopoutRow({entry, guildId, channelId}: VoiceParticipant
|
||||
return (
|
||||
<Popout
|
||||
render={({popoutKey}) => (
|
||||
<UserProfilePopout popoutKey={popoutKey} user={entry.user} isWebhook={false} guildId={guildId ?? undefined} />
|
||||
<UserProfilePopout
|
||||
key={`${entry.user.id}:${guildId ?? 'global'}:user`}
|
||||
popoutKey={popoutKey}
|
||||
user={entry.user}
|
||||
isWebhook={false}
|
||||
guildId={guildId ?? undefined}
|
||||
/>
|
||||
)}
|
||||
position="left-start"
|
||||
>
|
||||
|
||||
@@ -70,8 +70,6 @@ const logger = new Logger('index');
|
||||
|
||||
preloadClientInfo();
|
||||
|
||||
const normalizePathSegment = (value: string): string => value.replace(/^\/+|\/+$/g, '');
|
||||
|
||||
async function resumePendingDesktopHandoffLogin(): Promise<void> {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi || typeof electronApi.consumeDesktopHandoffCode !== 'function') {
|
||||
@@ -103,7 +101,7 @@ async function resumePendingDesktopHandoffLogin(): Promise<void> {
|
||||
}
|
||||
|
||||
function initSentry(): void {
|
||||
const resolvedSentryDsn = buildRuntimeSentryDsn() || RuntimeConfigStore.sentryDsn;
|
||||
const resolvedSentryDsn = RuntimeConfigStore.sentryDsn;
|
||||
const normalizedBuildSha =
|
||||
Config.PUBLIC_BUILD_SHA && Config.PUBLIC_BUILD_SHA !== 'dev' ? Config.PUBLIC_BUILD_SHA : undefined;
|
||||
const buildNumberString =
|
||||
@@ -167,24 +165,6 @@ function initSentry(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function buildRuntimeSentryDsn(): string | null {
|
||||
if (!RuntimeConfigStore.sentryProjectId || !RuntimeConfigStore.sentryPublicKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const origin = window.location.origin;
|
||||
if (!origin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const proxyPath = normalizePathSegment(RuntimeConfigStore.sentryProxyPath ?? '/error-reporting-proxy');
|
||||
const projectSegment = normalizePathSegment(RuntimeConfigStore.sentryProjectId);
|
||||
|
||||
const url = new URL(`/${proxyPath}/${projectSegment}`, origin);
|
||||
url.username = RuntimeConfigStore.sentryPublicKey;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
await initI18n();
|
||||
|
||||
|
||||
@@ -310,10 +310,6 @@ class AccountStorage {
|
||||
sso: instance.sso,
|
||||
publicPushVapidKey: instance.publicPushVapidKey,
|
||||
sentryDsn: instance.sentryDsn ?? '',
|
||||
sentryProxyPath: instance.sentryProxyPath ?? '',
|
||||
sentryReportHost: instance.sentryReportHost ?? '',
|
||||
sentryProjectId: instance.sentryProjectId ?? '',
|
||||
sentryPublicKey: instance.sentryPublicKey ?? '',
|
||||
limits:
|
||||
instance.limits !== undefined && instance.limits !== null
|
||||
? JSON.parse(JSON.stringify(instance.limits))
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
MarkdownContext,
|
||||
type MarkdownRenderOptions,
|
||||
type RendererProps,
|
||||
} from '@app/lib/markdown/renderers/RendererTypes';
|
||||
import {TimestampRenderer} from '@app/lib/markdown/renderers/TimestampRenderer';
|
||||
import {NodeType, TimestampStyle} from '@fluxer/markdown_parser/src/types/Enums';
|
||||
import type {TimestampNode} from '@fluxer/markdown_parser/src/types/Nodes';
|
||||
import {setupI18n} from '@lingui/core';
|
||||
import React from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
const i18n = setupI18n({locale: 'en-US', messages: {'en-US': {}}});
|
||||
|
||||
function createRendererProps(node: TimestampNode): RendererProps<TimestampNode> {
|
||||
const options: MarkdownRenderOptions = {
|
||||
context: MarkdownContext.STANDARD_WITHOUT_JUMBO,
|
||||
shouldJumboEmojis: false,
|
||||
i18n,
|
||||
};
|
||||
|
||||
return {
|
||||
node,
|
||||
id: 'test-timestamp',
|
||||
renderChildren: () => null,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TimestampRenderer', () => {
|
||||
test('renders a non-throwing fallback for invalid timestamps', () => {
|
||||
const props = createRendererProps({
|
||||
type: NodeType.Timestamp,
|
||||
timestamp: Number.POSITIVE_INFINITY,
|
||||
style: TimestampStyle.ShortDateTime,
|
||||
});
|
||||
|
||||
const renderFn = () => renderToStaticMarkup(React.createElement(TimestampRenderer, props));
|
||||
|
||||
expect(renderFn).not.toThrow();
|
||||
const markup = renderFn();
|
||||
expect(markup).toContain('Infinity');
|
||||
expect(markup).not.toContain('<time');
|
||||
});
|
||||
|
||||
test('renders a semantic time element for valid timestamps', () => {
|
||||
const props = createRendererProps({
|
||||
type: NodeType.Timestamp,
|
||||
timestamp: 1618953630,
|
||||
style: TimestampStyle.ShortDateTime,
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(React.createElement(TimestampRenderer, props));
|
||||
|
||||
expect(markup).toContain('<time');
|
||||
expect(markup).toContain('dateTime="2021-04-20T21:20:30.000Z"');
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@
|
||||
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
|
||||
import type {RendererProps} from '@app/lib/markdown/renderers/RendererTypes';
|
||||
import {formatTimestamp} from '@app/lib/markdown/utils/DateFormatter';
|
||||
import {getDateFromUnixTimestampSeconds} from '@app/lib/markdown/utils/TimestampValidation';
|
||||
import WindowStore from '@app/stores/WindowStore';
|
||||
import markupStyles from '@app/styles/Markup.module.css';
|
||||
import timestampRendererStyles from '@app/styles/TimestampRenderer.module.css';
|
||||
@@ -32,8 +33,7 @@ import {ClockIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {DateTime} from 'luxon';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type {ReactElement} from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
import React, {type ReactElement, useEffect, useState} from 'react';
|
||||
|
||||
export const TimestampRenderer = observer(function TimestampRenderer({
|
||||
node,
|
||||
@@ -43,25 +43,26 @@ export const TimestampRenderer = observer(function TimestampRenderer({
|
||||
const {timestamp, style} = node;
|
||||
const i18n = options.i18n;
|
||||
|
||||
const totalMillis = timestamp * 1000;
|
||||
const date = new Date(totalMillis);
|
||||
const date = getDateFromUnixTimestampSeconds(timestamp);
|
||||
const isValidTimestamp = date !== null;
|
||||
const now = new Date();
|
||||
|
||||
const isPast = date < now;
|
||||
const isFuture = date > now;
|
||||
const isTodayDate = isSameDay(date);
|
||||
const isPast = date !== null && date < now;
|
||||
const isFuture = date !== null && date > now;
|
||||
const isTodayDate = date !== null && isSameDay(date);
|
||||
|
||||
const locale = getCurrentLocale();
|
||||
const fullDateTime = getFormattedDateTimeWithSeconds(date, locale);
|
||||
const fullDateTime = date !== null ? getFormattedDateTimeWithSeconds(date, locale) : null;
|
||||
|
||||
const isRelativeStyle = style === TimestampStyle.RelativeTime;
|
||||
const isWindowFocused = WindowStore.focused;
|
||||
const [relativeDisplayTime, setRelativeDisplayTime] = useState(() => formatTimestamp(timestamp, style, i18n));
|
||||
const luxonDate = DateTime.fromMillis(totalMillis);
|
||||
const relativeTime = luxonDate.toRelative();
|
||||
const [relativeDisplayTime, setRelativeDisplayTime] = useState(() =>
|
||||
isValidTimestamp ? formatTimestamp(timestamp, style, i18n) : '',
|
||||
);
|
||||
const relativeTime = date !== null ? DateTime.fromJSDate(date).toRelative() : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRelativeStyle || !isWindowFocused) {
|
||||
if (!isValidTimestamp || !isRelativeStyle || !isWindowFocused) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,7 +76,11 @@ export const TimestampRenderer = observer(function TimestampRenderer({
|
||||
refreshDisplay();
|
||||
const intervalId = setInterval(refreshDisplay, 1000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [isRelativeStyle, isWindowFocused, style, timestamp, i18n]);
|
||||
}, [isValidTimestamp, isRelativeStyle, isWindowFocused, style, timestamp, i18n]);
|
||||
|
||||
if (date === null || fullDateTime === null) {
|
||||
return React.createElement('span', {className: markupStyles.timestamp}, String(timestamp));
|
||||
}
|
||||
|
||||
const tooltipContent = (
|
||||
<div className={timestampRendererStyles.tooltipContainer}>
|
||||
|
||||
53
fluxer_app/src/lib/markdown/utils/DateFormatter.test.tsx
Normal file
53
fluxer_app/src/lib/markdown/utils/DateFormatter.test.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {formatTimestamp} from '@app/lib/markdown/utils/DateFormatter';
|
||||
import {TimestampStyle} from '@fluxer/markdown_parser/src/types/Enums';
|
||||
import {setupI18n} from '@lingui/core';
|
||||
import {afterEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const i18n = setupI18n({locale: 'en-US', messages: {'en-US': {}}});
|
||||
|
||||
describe('formatTimestamp', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('returns the raw numeric value for non-finite timestamps', () => {
|
||||
const output = formatTimestamp(Number.POSITIVE_INFINITY, TimestampStyle.ShortDateTime, i18n);
|
||||
|
||||
expect(output).toBe('Infinity');
|
||||
});
|
||||
|
||||
test('returns the raw numeric value for out-of-range timestamps', () => {
|
||||
const output = formatTimestamp(8640000000001, TimestampStyle.ShortDateTime, i18n);
|
||||
|
||||
expect(output).toBe('8640000000001');
|
||||
});
|
||||
|
||||
test('still formats valid relative timestamps', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-02-18T12:00:00.000Z'));
|
||||
|
||||
const oneMinuteAgoTimestamp = Math.floor(new Date('2026-02-18T11:59:00.000Z').getTime() / 1000);
|
||||
const output = formatTimestamp(oneMinuteAgoTimestamp, TimestampStyle.RelativeTime, i18n);
|
||||
|
||||
expect(output.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {getDateFromUnixTimestampSeconds} from '@app/lib/markdown/utils/TimestampValidation';
|
||||
import {shouldUse12HourFormat} from '@app/utils/DateUtils';
|
||||
import {getCurrentLocale} from '@app/utils/LocaleUtils';
|
||||
import {
|
||||
@@ -33,9 +34,8 @@ import {TimestampStyle} from '@fluxer/markdown_parser/src/types/Enums';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
function formatRelativeTime(timestamp: number, i18n: I18n): string {
|
||||
function formatRelativeTime(date: Date, i18n: I18n): string {
|
||||
const locale = getCurrentLocale();
|
||||
const date = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
|
||||
if (isSameDay(date, now)) {
|
||||
@@ -142,9 +142,14 @@ function formatRelativeTime(timestamp: number, i18n: I18n): string {
|
||||
export function formatTimestamp(timestamp: number, style: TimestampStyle, i18n: I18n): string {
|
||||
const locale = getCurrentLocale();
|
||||
const hour12 = shouldUse12HourFormat(locale);
|
||||
const date = getDateFromUnixTimestampSeconds(timestamp);
|
||||
|
||||
if (date == null) {
|
||||
return String(timestamp);
|
||||
}
|
||||
|
||||
if (style === TimestampStyle.RelativeTime) {
|
||||
return formatRelativeTime(timestamp, i18n);
|
||||
return formatRelativeTime(date, i18n);
|
||||
}
|
||||
|
||||
return formatTimestampWithStyle(timestamp, style, locale, hour12);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {getDateFromUnixTimestampSeconds} from '@app/lib/markdown/utils/TimestampValidation';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
describe('getDateFromUnixTimestampSeconds', () => {
|
||||
test('returns a valid date for normal unix timestamps', () => {
|
||||
const result = getDateFromUnixTimestampSeconds(1618953630);
|
||||
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect(result?.toISOString()).toBe('2021-04-20T21:20:30.000Z');
|
||||
});
|
||||
|
||||
test('returns null for infinity', () => {
|
||||
expect(getDateFromUnixTimestampSeconds(Number.POSITIVE_INFINITY)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for NaN', () => {
|
||||
expect(getDateFromUnixTimestampSeconds(Number.NaN)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when timestamp is beyond js date range', () => {
|
||||
expect(getDateFromUnixTimestampSeconds(8640000000001)).toBeNull();
|
||||
});
|
||||
});
|
||||
38
fluxer_app/src/lib/markdown/utils/TimestampValidation.tsx
Normal file
38
fluxer_app/src/lib/markdown/utils/TimestampValidation.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const MILLISECONDS_PER_SECOND = 1000;
|
||||
|
||||
export function getDateFromUnixTimestampSeconds(timestamp: number): Date | null {
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestampMillis = timestamp * MILLISECONDS_PER_SECOND;
|
||||
if (!Number.isFinite(timestampMillis)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(timestampMillis);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
@@ -243,10 +243,6 @@ class InstanceConfigStore {
|
||||
push: {public_vapid_key: RuntimeConfigStore.publicPushVapidKey},
|
||||
appPublic: {
|
||||
sentry_dsn: RuntimeConfigStore.sentryDsn,
|
||||
sentry_proxy_path: RuntimeConfigStore.sentryProxyPath,
|
||||
sentry_report_host: RuntimeConfigStore.sentryReportHost,
|
||||
sentry_project_id: RuntimeConfigStore.sentryProjectId,
|
||||
sentry_public_key: RuntimeConfigStore.sentryPublicKey,
|
||||
},
|
||||
federation: null,
|
||||
publicKey: null,
|
||||
|
||||
@@ -69,10 +69,6 @@ export interface InstancePush {
|
||||
|
||||
export interface InstanceAppPublic {
|
||||
sentry_dsn: string;
|
||||
sentry_proxy_path: string;
|
||||
sentry_report_host: string;
|
||||
sentry_project_id: string;
|
||||
sentry_public_key: string;
|
||||
}
|
||||
|
||||
export type GifProvider = 'klipy' | 'tenor';
|
||||
@@ -110,10 +106,6 @@ export interface RuntimeConfigSnapshot {
|
||||
publicPushVapidKey: string | null;
|
||||
limits: LimitConfigSnapshot;
|
||||
sentryDsn: string;
|
||||
sentryProxyPath: string;
|
||||
sentryReportHost: string;
|
||||
sentryProjectId: string;
|
||||
sentryPublicKey: string;
|
||||
relayDirectoryUrl: string | null;
|
||||
}
|
||||
|
||||
@@ -160,10 +152,6 @@ class RuntimeConfigStore {
|
||||
currentDefaultsHash: string | null = null;
|
||||
|
||||
sentryDsn: string = '';
|
||||
sentryProxyPath: string = '/error-reporting-proxy';
|
||||
sentryReportHost: string = '';
|
||||
sentryProjectId: string = '';
|
||||
sentryPublicKey: string = '';
|
||||
|
||||
relayDirectoryUrl: string | null = Config.PUBLIC_RELAY_DIRECTORY_URL;
|
||||
|
||||
@@ -237,10 +225,6 @@ class RuntimeConfigStore {
|
||||
'limits',
|
||||
'currentDefaultsHash',
|
||||
'sentryDsn',
|
||||
'sentryProxyPath',
|
||||
'sentryReportHost',
|
||||
'sentryProjectId',
|
||||
'sentryPublicKey',
|
||||
'relayDirectoryUrl',
|
||||
]);
|
||||
|
||||
@@ -302,10 +286,6 @@ class RuntimeConfigStore {
|
||||
this.currentDefaultsHash = null;
|
||||
|
||||
this.sentryDsn = snapshot.sentryDsn;
|
||||
this.sentryProxyPath = snapshot.sentryProxyPath;
|
||||
this.sentryReportHost = snapshot.sentryReportHost;
|
||||
this.sentryProjectId = snapshot.sentryProjectId;
|
||||
this.sentryPublicKey = snapshot.sentryPublicKey;
|
||||
|
||||
this.relayDirectoryUrl = snapshot.relayDirectoryUrl;
|
||||
}
|
||||
@@ -331,10 +311,6 @@ class RuntimeConfigStore {
|
||||
publicPushVapidKey: this.publicPushVapidKey,
|
||||
limits: this.cloneLimits(this.limits),
|
||||
sentryDsn: this.sentryDsn,
|
||||
sentryProxyPath: this.sentryProxyPath,
|
||||
sentryReportHost: this.sentryReportHost,
|
||||
sentryProjectId: this.sentryProjectId,
|
||||
sentryPublicKey: this.sentryPublicKey,
|
||||
relayDirectoryUrl: this.relayDirectoryUrl,
|
||||
};
|
||||
}
|
||||
@@ -472,10 +448,6 @@ class RuntimeConfigStore {
|
||||
|
||||
if (instance.app_public) {
|
||||
this.sentryDsn = instance.app_public.sentry_dsn;
|
||||
this.sentryProxyPath = instance.app_public.sentry_proxy_path;
|
||||
this.sentryReportHost = instance.app_public.sentry_report_host;
|
||||
this.sentryProjectId = instance.app_public.sentry_project_id;
|
||||
this.sentryPublicKey = instance.app_public.sentry_public_key;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,10 +114,6 @@ vi.mock('@app/lib/HttpClient', () => {
|
||||
limits: {version: 1, traitDefinitions: [], rules: []},
|
||||
app_public: {
|
||||
sentry_dsn: '',
|
||||
sentry_proxy_path: '',
|
||||
sentry_report_host: '',
|
||||
sentry_project_id: '',
|
||||
sentry_public_key: '',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user