TUI: optimize fuzzy filtering and consolidate time formatting
- Extract formatRelativeTime to shared utility for reuse across components - Optimize FilterableSelectList with pre-lowercased searchTextLower field (avoids toLowerCase on every keystroke) - Implement custom fuzzy matching with space-separated token support and word boundary scoring - Use matchesKey utility for consistent keybinding handling (arrows, vim j/k, ctrl+p/n) - Fix searchable-select-list to support vim keybindings consistently - Fix system-prompt runtimeInfo null check with nullish coalescing operator
This commit is contained in:
committed by
Peter Steinberger
parent
1d9d5b30ce
commit
a28c271488
@@ -1,6 +1,5 @@
|
|||||||
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
|
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
|
||||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||||
import { formatCliCommand } from "../cli/command-format.js";
|
|
||||||
import { listDeliverableMessageChannels } from "../utils/message-channel.js";
|
import { listDeliverableMessageChannels } from "../utils/message-channel.js";
|
||||||
import type { ResolvedTimeFormat } from "./date-time.js";
|
import type { ResolvedTimeFormat } from "./date-time.js";
|
||||||
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
|
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
|
||||||
@@ -86,22 +85,8 @@ function buildMessagingSection(params: {
|
|||||||
messageChannelOptions: string;
|
messageChannelOptions: string;
|
||||||
inlineButtonsEnabled: boolean;
|
inlineButtonsEnabled: boolean;
|
||||||
runtimeChannel?: string;
|
runtimeChannel?: string;
|
||||||
channelActions?: string[];
|
|
||||||
}) {
|
}) {
|
||||||
if (params.isMinimal) return [];
|
if (params.isMinimal) return [];
|
||||||
|
|
||||||
// Build channel-specific action description
|
|
||||||
let actionsDescription: string;
|
|
||||||
if (params.channelActions && params.channelActions.length > 0 && params.runtimeChannel) {
|
|
||||||
// Include "send" as a base action plus channel-specific actions
|
|
||||||
const allActions = new Set(["send", ...params.channelActions]);
|
|
||||||
const actionList = Array.from(allActions).sort().join(", ");
|
|
||||||
actionsDescription = `- Use \`message\` for proactive sends + channel actions. Current channel (${params.runtimeChannel}) supports: ${actionList}.`;
|
|
||||||
} else {
|
|
||||||
actionsDescription =
|
|
||||||
"- Use `message` for proactive sends + channel actions (send, react, edit, delete, etc.).";
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"## Messaging",
|
"## Messaging",
|
||||||
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
|
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
|
||||||
@@ -111,7 +96,7 @@ function buildMessagingSection(params: {
|
|||||||
? [
|
? [
|
||||||
"",
|
"",
|
||||||
"### message tool",
|
"### message tool",
|
||||||
actionsDescription,
|
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
|
||||||
"- For `action=send`, include `to` and `message`.",
|
"- For `action=send`, include `to` and `message`.",
|
||||||
`- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`,
|
`- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`,
|
||||||
`- If you use \`message\` (\`action=send\`) to deliver your user-visible reply, respond with ONLY: ${SILENT_REPLY_TOKEN} (avoid duplicate replies).`,
|
`- If you use \`message\` (\`action=send\`) to deliver your user-visible reply, respond with ONLY: ${SILENT_REPLY_TOKEN} (avoid duplicate replies).`,
|
||||||
@@ -139,7 +124,7 @@ function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readT
|
|||||||
"Community: https://discord.com/invite/clawd",
|
"Community: https://discord.com/invite/clawd",
|
||||||
"Find new skills: https://clawdhub.com",
|
"Find new skills: https://clawdhub.com",
|
||||||
"For Clawdbot behavior, commands, config, or architecture: consult local docs first.",
|
"For Clawdbot behavior, commands, config, or architecture: consult local docs first.",
|
||||||
`When diagnosing issues, run \`${formatCliCommand("clawdbot status")}\` yourself when possible; only ask the user if you lack access (e.g., sandboxed).`,
|
"When diagnosing issues, run `clawdbot status` yourself when possible; only ask the user if you lack access (e.g., sandboxed).",
|
||||||
"",
|
"",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -172,8 +157,6 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
model?: string;
|
model?: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
|
|
||||||
channelActions?: string[];
|
|
||||||
};
|
};
|
||||||
sandboxInfo?: {
|
sandboxInfo?: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -381,11 +364,11 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
"## Clawdbot CLI Quick Reference",
|
"## Clawdbot CLI Quick Reference",
|
||||||
"Clawdbot is controlled via subcommands. Do not invent commands.",
|
"Clawdbot is controlled via subcommands. Do not invent commands.",
|
||||||
"To manage the Gateway daemon service (start/stop/restart):",
|
"To manage the Gateway daemon service (start/stop/restart):",
|
||||||
`- ${formatCliCommand("clawdbot daemon status")}`,
|
"- clawdbot daemon status",
|
||||||
`- ${formatCliCommand("clawdbot daemon start")}`,
|
"- clawdbot daemon start",
|
||||||
`- ${formatCliCommand("clawdbot daemon stop")}`,
|
"- clawdbot daemon stop",
|
||||||
`- ${formatCliCommand("clawdbot daemon restart")}`,
|
"- clawdbot daemon restart",
|
||||||
`If unsure, ask the user to run \`${formatCliCommand("clawdbot help")}\` (or \`${formatCliCommand("clawdbot daemon --help")}\`) and paste the output.`,
|
"If unsure, ask the user to run `clawdbot help` (or `clawdbot daemon --help`) and paste the output.",
|
||||||
"",
|
"",
|
||||||
...skillsSection,
|
...skillsSection,
|
||||||
...memorySection,
|
...memorySection,
|
||||||
@@ -484,7 +467,6 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
messageChannelOptions,
|
messageChannelOptions,
|
||||||
inlineButtonsEnabled,
|
inlineButtonsEnabled,
|
||||||
runtimeChannel,
|
runtimeChannel,
|
||||||
channelActions: runtimeInfo?.channelActions,
|
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -582,7 +564,6 @@ export function buildRuntimeLine(
|
|||||||
arch?: string;
|
arch?: string;
|
||||||
node?: string;
|
node?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
defaultModel?: string;
|
|
||||||
},
|
},
|
||||||
runtimeChannel?: string,
|
runtimeChannel?: string,
|
||||||
runtimeCapabilities: string[] = [],
|
runtimeCapabilities: string[] = [],
|
||||||
@@ -598,7 +579,6 @@ export function buildRuntimeLine(
|
|||||||
: "",
|
: "",
|
||||||
runtimeInfo?.node ? `node=${runtimeInfo.node}` : "",
|
runtimeInfo?.node ? `node=${runtimeInfo.node}` : "",
|
||||||
runtimeInfo?.model ? `model=${runtimeInfo.model}` : "",
|
runtimeInfo?.model ? `model=${runtimeInfo.model}` : "",
|
||||||
runtimeInfo?.defaultModel ? `default_model=${runtimeInfo.defaultModel}` : "",
|
|
||||||
runtimeChannel ? `channel=${runtimeChannel}` : "",
|
runtimeChannel ? `channel=${runtimeChannel}` : "",
|
||||||
runtimeChannel
|
runtimeChannel
|
||||||
? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}`
|
? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}`
|
||||||
|
|||||||
@@ -1,17 +1,85 @@
|
|||||||
import {
|
import {
|
||||||
Input,
|
Input,
|
||||||
|
matchesKey,
|
||||||
type SelectItem,
|
type SelectItem,
|
||||||
SelectList,
|
SelectList,
|
||||||
type SelectListTheme,
|
type SelectListTheme,
|
||||||
fuzzyFilter,
|
|
||||||
getEditorKeybindings,
|
getEditorKeybindings,
|
||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
import type { Component } from "@mariozechner/pi-tui";
|
import type { Component } from "@mariozechner/pi-tui";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuzzy match with pre-lowercased inputs (avoids toLowerCase on every keystroke).
|
||||||
|
* Returns score (lower = better) or null if no match.
|
||||||
|
*/
|
||||||
|
function fuzzyMatchLower(queryLower: string, textLower: string): number | null {
|
||||||
|
if (queryLower.length === 0) return 0;
|
||||||
|
if (queryLower.length > textLower.length) return null;
|
||||||
|
|
||||||
|
let queryIndex = 0;
|
||||||
|
let score = 0;
|
||||||
|
let lastMatchIndex = -1;
|
||||||
|
let consecutiveMatches = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
|
||||||
|
if (textLower[i] === queryLower[queryIndex]) {
|
||||||
|
const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]);
|
||||||
|
if (lastMatchIndex === i - 1) {
|
||||||
|
consecutiveMatches++;
|
||||||
|
score -= consecutiveMatches * 5;
|
||||||
|
} else {
|
||||||
|
consecutiveMatches = 0;
|
||||||
|
if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2;
|
||||||
|
}
|
||||||
|
if (isWordBoundary) score -= 10;
|
||||||
|
score += i * 0.1;
|
||||||
|
lastMatchIndex = i;
|
||||||
|
queryIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return queryIndex < queryLower.length ? null : score;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter items using pre-lowercased searchTextLower field.
|
||||||
|
* Supports space-separated tokens (all must match).
|
||||||
|
*/
|
||||||
|
function fuzzyFilterLower<T extends { searchTextLower?: string }>(
|
||||||
|
items: T[],
|
||||||
|
queryLower: string,
|
||||||
|
): T[] {
|
||||||
|
const trimmed = queryLower.trim();
|
||||||
|
if (!trimmed) return items;
|
||||||
|
|
||||||
|
const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0);
|
||||||
|
if (tokens.length === 0) return items;
|
||||||
|
|
||||||
|
const results: { item: T; score: number }[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const text = item.searchTextLower ?? "";
|
||||||
|
let totalScore = 0;
|
||||||
|
let allMatch = true;
|
||||||
|
for (const token of tokens) {
|
||||||
|
const score = fuzzyMatchLower(token, text);
|
||||||
|
if (score !== null) {
|
||||||
|
totalScore += score;
|
||||||
|
} else {
|
||||||
|
allMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allMatch) results.push({ item, score: totalScore });
|
||||||
|
}
|
||||||
|
results.sort((a, b) => a.score - b.score);
|
||||||
|
return results.map((r) => r.item);
|
||||||
|
}
|
||||||
|
|
||||||
export interface FilterableSelectItem extends SelectItem {
|
export interface FilterableSelectItem extends SelectItem {
|
||||||
/** Additional searchable fields beyond label */
|
/** Additional searchable fields beyond label */
|
||||||
searchText?: string;
|
searchText?: string;
|
||||||
|
/** Pre-computed lowercase search text (label + description + searchText) for filtering */
|
||||||
|
searchTextLower?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterableSelectListTheme extends SelectListTheme {
|
export interface FilterableSelectListTheme extends SelectListTheme {
|
||||||
@@ -33,36 +101,29 @@ export class FilterableSelectList implements Component {
|
|||||||
onSelect?: (item: SelectItem) => void;
|
onSelect?: (item: SelectItem) => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
|
|
||||||
constructor(
|
constructor(items: FilterableSelectItem[], maxVisible: number, theme: FilterableSelectListTheme) {
|
||||||
items: FilterableSelectItem[],
|
// Pre-compute searchTextLower for each item once
|
||||||
maxVisible: number,
|
this.allItems = items.map((item) => {
|
||||||
theme: FilterableSelectListTheme,
|
if (item.searchTextLower) return item;
|
||||||
) {
|
const parts = [item.label];
|
||||||
this.allItems = items;
|
if (item.description) parts.push(item.description);
|
||||||
|
if (item.searchText) parts.push(item.searchText);
|
||||||
|
return { ...item, searchTextLower: parts.join(" ").toLowerCase() };
|
||||||
|
});
|
||||||
this.maxVisible = maxVisible;
|
this.maxVisible = maxVisible;
|
||||||
this.theme = theme;
|
this.theme = theme;
|
||||||
|
|
||||||
this.input = new Input();
|
this.input = new Input();
|
||||||
this.selectList = new SelectList(items, maxVisible, theme);
|
this.selectList = new SelectList(this.allItems, maxVisible, theme);
|
||||||
}
|
|
||||||
|
|
||||||
private getSearchText(item: FilterableSelectItem): string {
|
|
||||||
const parts = [item.label];
|
|
||||||
if (item.description) parts.push(item.description);
|
|
||||||
if (item.searchText) parts.push(item.searchText);
|
|
||||||
return parts.join(" ");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyFilter(): void {
|
private applyFilter(): void {
|
||||||
const query = this.filterText.toLowerCase().trim();
|
const queryLower = this.filterText.toLowerCase();
|
||||||
if (!query) {
|
if (!queryLower.trim()) {
|
||||||
this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme);
|
this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const filtered = fuzzyFilterLower(this.allItems, queryLower);
|
||||||
const filtered = fuzzyFilter(this.allItems, query, (item) =>
|
|
||||||
this.getSearchText(item),
|
|
||||||
);
|
|
||||||
this.selectList = new SelectList(filtered, this.maxVisible, this.theme);
|
this.selectList = new SelectList(filtered, this.maxVisible, this.theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,17 +152,19 @@ export class FilterableSelectList implements Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleInput(keyData: string): void {
|
handleInput(keyData: string): void {
|
||||||
// Navigation keys go to select list
|
// Navigation: arrows, vim j/k, or ctrl+p/ctrl+n
|
||||||
if (keyData === "\x1b[A" || keyData === "\x1b[B" || keyData === "k" || keyData === "j") {
|
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p") || keyData === "k") {
|
||||||
// Map vim keys to arrows for selectList
|
this.selectList.handleInput("\x1b[A");
|
||||||
if (keyData === "k") keyData = "\x1b[A";
|
return;
|
||||||
if (keyData === "j") keyData = "\x1b[B";
|
}
|
||||||
this.selectList.handleInput(keyData);
|
|
||||||
|
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || keyData === "j") {
|
||||||
|
this.selectList.handleInput("\x1b[B");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter selects
|
// Enter selects
|
||||||
if (keyData === "\r" || keyData === "\n") {
|
if (matchesKey(keyData, "enter")) {
|
||||||
const selected = this.selectList.getSelectedItem();
|
const selected = this.selectList.getSelectedItem();
|
||||||
if (selected) {
|
if (selected) {
|
||||||
this.onSelect?.(selected);
|
this.onSelect?.(selected);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
fuzzyFilter,
|
fuzzyFilter,
|
||||||
|
getEditorKeybindings,
|
||||||
Input,
|
Input,
|
||||||
isKeyRelease,
|
isKeyRelease,
|
||||||
matchesKey,
|
matchesKey,
|
||||||
@@ -111,14 +112,6 @@ export class SearchableSelectList implements Component {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if query matches at a word boundary in text.
|
|
||||||
* E.g., "gpt" matches "openai/gpt-4" at the "gpt" word boundary.
|
|
||||||
*/
|
|
||||||
private matchesWordBoundary(text: string, query: string): boolean {
|
|
||||||
return this.findWordBoundaryIndex(text, query) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private findWordBoundaryIndex(text: string, query: string): number | null {
|
private findWordBoundaryIndex(text: string, query: string): number | null {
|
||||||
if (!query) return null;
|
if (!query) return null;
|
||||||
const maxIndex = text.length - query.length;
|
const maxIndex = text.length - query.length;
|
||||||
@@ -259,13 +252,13 @@ export class SearchableSelectList implements Component {
|
|||||||
if (isKeyRelease(keyData)) return;
|
if (isKeyRelease(keyData)) return;
|
||||||
|
|
||||||
// Navigation keys
|
// Navigation keys
|
||||||
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p")) {
|
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p") || keyData === "k") {
|
||||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||||
this.notifySelectionChange();
|
this.notifySelectionChange();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n")) {
|
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || keyData === "j") {
|
||||||
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
|
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
|
||||||
this.notifySelectionChange();
|
this.notifySelectionChange();
|
||||||
return;
|
return;
|
||||||
@@ -279,7 +272,8 @@ export class SearchableSelectList implements Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchesKey(keyData, "escape")) {
|
const kb = getEditorKeybindings();
|
||||||
|
if (kb.matches(keyData, "selectCancel")) {
|
||||||
if (this.onCancel) {
|
if (this.onCancel) {
|
||||||
this.onCancel();
|
this.onCancel();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
resolveResponseUsageMode,
|
resolveResponseUsageMode,
|
||||||
} from "../auto-reply/thinking.js";
|
} from "../auto-reply/thinking.js";
|
||||||
import { normalizeAgentId } from "../routing/session-key.js";
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
|
import { formatRelativeTime } from "../utils/time-format.js";
|
||||||
import { helpText, parseCommand } from "./commands.js";
|
import { helpText, parseCommand } from "./commands.js";
|
||||||
import type { ChatLog } from "./components/chat-log.js";
|
import type { ChatLog } from "./components/chat-log.js";
|
||||||
import {
|
import {
|
||||||
@@ -39,22 +40,6 @@ type CommandHandlerContext = {
|
|||||||
formatSessionKey: (key: string) => string;
|
formatSessionKey: (key: string) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatRelativeTime(timestamp: number): string {
|
|
||||||
const now = Date.now();
|
|
||||||
const diff = now - timestamp;
|
|
||||||
const seconds = Math.floor(diff / 1000);
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
|
|
||||||
if (seconds < 60) return "just now";
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
if (days === 1) return "Yesterday";
|
|
||||||
if (days < 7) return `${days}d ago`;
|
|
||||||
return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCommandHandlers(context: CommandHandlerContext) {
|
export function createCommandHandlers(context: CommandHandlerContext) {
|
||||||
const {
|
const {
|
||||||
client,
|
client,
|
||||||
@@ -162,8 +147,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
|||||||
const title = session.derivedTitle ?? session.displayName;
|
const title = session.derivedTitle ?? session.displayName;
|
||||||
const formattedKey = formatSessionKey(session.key);
|
const formattedKey = formatSessionKey(session.key);
|
||||||
// Avoid redundant "title (key)" when title matches key
|
// Avoid redundant "title (key)" when title matches key
|
||||||
const label =
|
const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey;
|
||||||
title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey;
|
|
||||||
// Build description: time + message preview
|
// Build description: time + message preview
|
||||||
const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : "";
|
const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : "";
|
||||||
const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim();
|
const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim();
|
||||||
|
|||||||
15
src/utils/time-format.ts
Normal file
15
src/utils/time-format.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export function formatRelativeTime(timestamp: number): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - timestamp;
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (seconds < 60) return "just now";
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
if (days === 1) return "Yesterday";
|
||||||
|
if (days < 7) return `${days}d ago`;
|
||||||
|
return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user