File size: 4,359 Bytes
d784651
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
"use client";

import { useEffect, useRef, useState } from "react";

/**
 * Lightweight keyboard-shortcut hook. Pass a map of `mod+key` strings to
 * handlers. `mod` is platform-aware: ⌘ on macOS, Ctrl elsewhere.
 *
 * Examples:
 *   useHotkeys({ "mod+k": () => focusComposer(), "mod+n": newConv })
 *
 * Keys are case-insensitive. Modifiers may be combined with `+`:
 *   mod  ⌘ on macOS, Ctrl elsewhere
 *   shift, alt
 */
export function useHotkeys(map: Record<string, (e: KeyboardEvent) => void>) {
  const ref = useRef(map);
  ref.current = map;

  useEffect(() => {
    const isMac =
      typeof navigator !== "undefined" &&
      /Mac|iPad|iPhone|iPod/.test(navigator.platform);

    const handler = (e: KeyboardEvent) => {
      const mod = isMac ? e.metaKey : e.ctrlKey;
      const key = e.key.toLowerCase();
      for (const combo in ref.current) {
        const parts = combo.toLowerCase().split("+");
        const wantsMod = parts.includes("mod");
        const wantsShift = parts.includes("shift");
        const wantsAlt = parts.includes("alt");
        const wantedKey = parts[parts.length - 1];
        if (wantsMod !== mod) continue;
        if (wantsShift !== e.shiftKey) continue;
        if (wantsAlt !== e.altKey) continue;
        // Special: "enter" matches Return
        if (wantedKey === "enter" && key !== "enter") continue;
        if (wantedKey !== "enter" && key !== wantedKey) continue;
        ref.current[combo](e);
        return;
      }
    };
    window.addEventListener("keydown", handler);
    return () => window.removeEventListener("keydown", handler);
  }, []);
}

/**
 * Tracks viewport breakpoint reactively. Returns true when below `px`.
 */
export function useIsMobile(px = 768): boolean {
  const [isMobile, setIsMobile] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia(`(max-width: ${px - 1}px)`);
    const update = () => setIsMobile(mq.matches);
    update();
    mq.addEventListener("change", update);
    return () => mq.removeEventListener("change", update);
  }, [px]);
  return isMobile;
}

/**
 * Re-renders every `ms` so relative-time strings stay fresh.
 */
export function useTick(ms = 30_000) {
  const [, force] = useState(0);
  useEffect(() => {
    const id = setInterval(() => force((n) => n + 1), ms);
    return () => clearInterval(id);
  }, [ms]);
}

/**
 * Sets document.title based on the active conversation title.
 */
export function useDocumentTitle(title: string | null | undefined) {
  useEffect(() => {
    const base = "doc·to·lora · Etiya BSS Atelier";
    document.title = title ? `${title} — doc·to·lora` : base;
  }, [title]);
}

/**
 * Returns a relative-time string like "12s ago", "3m ago", "yesterday".
 */
export function relativeTime(ts: number, now = Date.now()): string {
  const diff = Math.max(0, now - ts);
  const s = Math.floor(diff / 1000);
  if (s < 5) return "just now";
  if (s < 60) return `${s}s ago`;
  const m = Math.floor(s / 60);
  if (m < 60) return `${m}m ago`;
  const h = Math.floor(m / 60);
  if (h < 24) return `${h}h ago`;
  const d = Math.floor(h / 24);
  if (d < 7) return `${d}d ago`;
  return new Date(ts).toLocaleDateString(undefined, {
    month: "short",
    day: "numeric",
  });
}

/**
 * Buckets conversations by recency (Today / Yesterday / Last 7 days /
 * Last 30 days / Earlier). Preserves the input order within each bucket.
 */
export function bucketByDate<T extends { updatedAt: number }>(
  items: T[],
  now = Date.now()
): { label: string; items: T[] }[] {
  const day = 24 * 60 * 60 * 1000;
  const startOfToday = new Date(now);
  startOfToday.setHours(0, 0, 0, 0);
  const todayMs = startOfToday.getTime();
  const buckets: Record<string, T[]> = {
    Today: [],
    Yesterday: [],
    "Last 7 days": [],
    "Last 30 days": [],
    Earlier: [],
  };
  const order = ["Today", "Yesterday", "Last 7 days", "Last 30 days", "Earlier"];
  for (const it of items) {
    const t = it.updatedAt;
    if (t >= todayMs) buckets.Today.push(it);
    else if (t >= todayMs - day) buckets.Yesterday.push(it);
    else if (t >= todayMs - 7 * day) buckets["Last 7 days"].push(it);
    else if (t >= todayMs - 30 * day) buckets["Last 30 days"].push(it);
    else buckets.Earlier.push(it);
  }
  return order
    .map((label) => ({ label, items: buckets[label] }))
    .filter((g) => g.items.length > 0);
}