Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added apps/web/public/elements/about.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
92 changes: 91 additions & 1 deletion apps/web/src/components/layout/UserMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
'use client';

import { BookMarked, LogOut, Avatar } from '@/components/system';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';

import {
Avatar,
BookMarked,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
ExternalLink,
Info,
LogOut,
} from '@/components/system';

import { useUser } from '@/hooks/useUser';
import { authClient } from '@/lib/auth-client';
import { DOCS_BASE_URL } from '@/lib/docs';
import { isParsableProductVersion, toReleaseTag } from '@/lib/product-version';
import { GITHUB_RELEASES_BASE_URL } from '@/lib/release-links';
import { PERSONAL_THEME_STORAGE_KEY } from '@/types/preferences';
import { cn } from '@/lib/utils';
import { useTRPC } from '@/trpc/client';

import {
DropdownMenu,
Expand All @@ -15,6 +33,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/system';
import Image from 'next/image';

export const UserMenu = ({
portalContainer,
Expand Down Expand Up @@ -56,6 +75,14 @@ function SignedInUserMenu({
switchOrgRedirectPath?: string;
user: NonNullable<ReturnType<typeof useUser>['user']>;
}) {
const trpc = useTRPC();
const [isAboutOpen, setIsAboutOpen] = useState(false);
const statusQuery = useQuery(
trpc.releases.status.queryOptions(undefined, {
staleTime: 30 * 60 * 1000,
refetchOnWindowFocus: false,
}),
);
const handleSignOut = async () => {
window.localStorage.removeItem(PERSONAL_THEME_STORAGE_KEY);
await authClient.signOut({
Expand All @@ -69,6 +96,11 @@ function SignedInUserMenu({

const userDisplayName = user.name ?? 'You';
const userEmail = user.resource.primaryEmailAddress?.emailAddress;
const displayVersion = statusQuery.data?.displayVersion ?? null;
const isReleaseVersion = isParsableProductVersion(displayVersion);
const releaseUrl = displayVersion
? `${GITHUB_RELEASES_BASE_URL}/#release-${toReleaseTag(displayVersion)}`
: null;

return (
<>
Expand Down Expand Up @@ -121,6 +153,15 @@ function SignedInUserMenu({
</div>

<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setIsAboutOpen(true)}>
<Info />
<span className="grow">About Roomote</span>
{displayVersion && (
<span className="text-muted-foreground text-xs">
{displayVersion}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a
href={DOCS_BASE_URL}
Expand All @@ -140,6 +181,55 @@ function SignedInUserMenu({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={isAboutOpen} onOpenChange={setIsAboutOpen}>
<DialogContent size="md">
<DialogHeader>
<DialogTitle>About Roomote</DialogTitle>
{displayVersion && (
<DialogDescription>
You&apos;re running Roomote version{' '}
<span className="font-mono text-[0.9em]">
{isReleaseVersion && releaseUrl ? (
<a
href={releaseUrl}
target="_blank"
rel="noreferrer"
className="underline"
>
{displayVersion}
</a>
) : (
displayVersion
)}
</span>
</DialogDescription>
)}
</DialogHeader>

<p className="max-w-54 min-h-14 md:max-w-none md:min-h-auto">
Made with care by humans and robots.
</p>
<div>
<Button variant="link" size="sm" asChild>
<a
href={GITHUB_RELEASES_BASE_URL}
target="_blank"
rel="noreferrer"
>
See all Roomote releases
<ExternalLink />
</a>
</Button>
</div>
<Image
src="/elements/about.png"
alt=""
width={100}
height={100}
className="absolute bottom-0 right-4 size-30"
/>
</DialogContent>
</Dialog>
</>
);
}
1 change: 1 addition & 0 deletions apps/web/src/lib/__tests__/product-version.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/web/src/lib/product-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export function isParsableProductVersion(
if (!normalized) {
return false;
}
return parseProductVersion(normalized).segments !== null;
return /^\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(normalized);
}

export function isProductVersionNewer(
Expand Down
16 changes: 15 additions & 1 deletion apps/web/src/trpc/commands/product-releases/index.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 54 additions & 0 deletions apps/web/src/trpc/commands/product-releases/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { execFileSync } from 'node:child_process';

import { db, deploymentSettings, eq } from '@roomote/db/server';
import {
getRedis,
Expand All @@ -14,6 +16,7 @@ import {
ROOMOTE_GITHUB_REPO,
} from '@/lib/release-links';
import {
isParsableProductVersion,
isProductVersionNewer,
normalizeProductVersion,
toReleaseTag,
Expand All @@ -25,6 +28,7 @@ const PRODUCT_VERSION_PATTERN = /^v?\d+\.\d+\.\d+(?:-[\w.]+)?$/i;

type ReleaseStatus = {
runningVersion: string | null;
displayVersion: string | null;
latestKnownVersion: string | null;
latestVersionCheckedAt: string | null;
updateAvailable: boolean;
Expand All @@ -50,6 +54,53 @@ function getRunningVersion(): string | null {
);
}

function getDisplayVersion(): string | null {
const releaseVersion = Env.RELEASE_VERSION?.trim();
if (releaseVersion) {
if (isParsableProductVersion(releaseVersion)) {
return toReleaseTag(releaseVersion);
}

// Channel builds include their commit after the final dash.
const commitFromRelease = releaseVersion.match(/[a-f0-9]{7,40}$/i)?.[0];
if (commitFromRelease) {
return commitFromRelease;
}
}

// Channel image builds receive their commit from GitHub Actions. A source
// checkout (the normal development path) has neither build metadata value,
// so resolve HEAD directly instead.
const commit = process.env.GITHUB_SHA?.trim() || getLocalGitCommit();
if (commit) {
return commit;
}

const productVersion = normalizeProductVersion(Env.RELEASE_PRODUCT_VERSION);
if (productVersion) {
return toReleaseTag(productVersion);
}

// Keep a useful identifier for deployments that do not expose commit
// metadata (for example a locally built self-hosted image).
return releaseVersion ?? null;
}

function getLocalGitCommit(): string | null {
try {
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
cwd: process.cwd(),
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
return commit || null;
} catch {
// Release images do not include .git. They always have RELEASE_VERSION,
// but keep this fallback safe for custom and detached deployments.
return null;
}
}

function canSeeUpdateStatus(auth: UserAuthSuccess): boolean {
return auth.isAdmin && !isRoomoteCloudEnabled(Env.R_CLOUD_ENABLED);
}
Expand All @@ -62,10 +113,12 @@ export async function getReleaseStatusCommand(
auth: UserAuthSuccess,
): Promise<ReleaseStatus> {
const runningVersion = getRunningVersion();
const displayVersion = getDisplayVersion();

if (!canSeeUpdateStatus(auth)) {
return {
runningVersion,
displayVersion,
latestKnownVersion: null,
latestVersionCheckedAt: null,
updateAvailable: false,
Expand All @@ -88,6 +141,7 @@ export async function getReleaseStatusCommand(

return {
runningVersion,
displayVersion,
latestKnownVersion,
latestVersionCheckedAt,
updateAvailable: isProductVersionNewer(latestKnownVersion, runningVersion),
Expand Down
Loading