File size: 6,916 Bytes
5351cc8
 
ea7b176
5351cc8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea7b176
5351cc8
 
 
ea7b176
 
 
 
 
 
 
 
5351cc8
 
 
 
 
 
 
 
 
 
 
 
 
 
ea7b176
5351cc8
 
 
 
 
 
 
 
 
 
ea7b176
 
 
 
 
5351cc8
 
 
 
 
 
 
 
ea7b176
5351cc8
 
 
 
 
 
 
 
 
 
 
 
 
 
ea7b176
5351cc8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7127075
 
 
 
 
 
 
5351cc8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
import { useEffect, useReducer, useRef } from "react";
import type { FormEvent } from "react";
import type { SurfaceScope } from "./apiBridge";
import type { Viewer } from "./types";
import {
  deleteRecordComment,
  fetchRecordComments,
  postRecordComment,
} from "./recordCommentsApi";
import type { RecordComment } from "./recordCommentsApi";
import {
  initialRecordCommentsState,
  recordCommentsReducer,
} from "./recordCommentsState";

function initials(name: string): string {
  return (
    name
      .trim()
      .split(/\s+/)
      .slice(0, 2)
      .map((part) => part[0]?.toUpperCase())
      .join("") || "?"
  );
}

function when(value: string): string {
  const date = new Date(value);
  if (!Number.isFinite(date.getTime())) return value;
  return new Intl.DateTimeFormat(undefined, {
    dateStyle: "medium",
    timeStyle: "short",
  }).format(date);
}

export default function RecordComments({
  scope,
  pid,
  viewer,
}: {
  /**
   * ⭐ WAVE 19 (owner item 12) — WHICH DATABASE THIS RECORD BELONGS TO. Required, with no
   * default, for the reason the Insights tab states at `RecordDetail.tsx`'s `scope` prop: a pid
   * is only meaningful inside one topic, and asking the customer endpoint about a product's
   * CRC32 hash either 403s on a screen showing a product (the reported bug) or — on a
   * collision — answers 200 and files the comment against somebody's customer.
   */
  scope: SurfaceScope;
  pid: number;
  viewer?: Viewer;
}) {
  const [state, dispatch] = useReducer(
    recordCommentsReducer,
    pid,
    initialRecordCommentsState
  );
  const { comments, draft, loading, posting, error } = state;
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  useEffect(() => {
    const controller = new AbortController();
    dispatch({ type: "record", pid });
    fetchRecordComments(scope, pid, controller.signal)
      .then((comments) => dispatch({ type: "loaded", pid, comments }))
      .catch((reason: unknown) => {
        if (reason instanceof DOMException && reason.name === "AbortError") return;
        dispatch({
          type: "load-failed",
          pid,
          error: reason instanceof Error ? reason.message : "Comments could not be loaded.",
        });
      });
    return () => controller.abort();
    // ⚠ `scope` is in the deps beside `pid`: the reducer's stale-completion guard keys on the
    // PID alone, so a surface switch that landed on the same pid would otherwise accept the old
    // topic's feed. The mount is also keyed by scope+pid (RecordDetail), which makes this belt
    // and braces — kept because the guard here is the one that survives a host that forgets.
  }, [scope, pid]);

  const post = async (event: FormEvent) => {
    event.preventDefault();
    const body = draft.trim();
    if (!body || posting) return;
    const requestPid = pid;
    dispatch({ type: "posting", pid: requestPid });
    try {
      const comment = await postRecordComment(scope, requestPid, body);
      dispatch({ type: "posted", pid: requestPid, comment });
      if (requestPid === pid) textareaRef.current?.focus();
    } catch (reason) {
      dispatch({
        type: "post-failed",
        pid: requestPid,
        error: reason instanceof Error ? reason.message : "That comment was not saved.",
      });
    }
  };

  const remove = async (comment: RecordComment) => {
    const requestPid = pid;
    try {
      await deleteRecordComment(scope, requestPid, comment.id);
      dispatch({ type: "deleted", pid: requestPid, commentId: comment.id });
    } catch (reason) {
      dispatch({
        type: "delete-failed",
        pid: requestPid,
        error: reason instanceof Error ? reason.message : "That comment was not deleted.",
      });
    }
  };

  return (
    <aside className="cg-record-comments" aria-label="Record comments">
      <div className="cg-record-comments-head">
        <h2>Comments</h2>
        <span>{comments.length.toLocaleString()}</span>
      </div>
      <div className="cg-record-comment-feed" aria-live="polite">
        {/* wave17 GRID — item 3 / R6. The feed is `aria-live="polite"`, so the spinner's
            `aria-label` is what it announces; dropping it would make the wait silent. */}
        {loading && (
          <div className="cg-record-comment-state">
            <span className="lp-spin" role="status" aria-label="Loading" />
          </div>
        )}
        {!loading && comments.length === 0 && !error && (
          <div className="cg-record-comment-state">
            No comments yet. Add context for the team here.
          </div>
        )}
        {comments.map((comment) => {
          const canDelete =
            viewer?.isAdmin === true ||
            viewer?.name?.trim().toLowerCase() === comment.authorKey.toLowerCase();
          return (
            <article className="cg-record-comment" key={comment.id}>
              <div className="cg-record-comment-avatar" aria-hidden>
                {initials(comment.author)}
              </div>
              <div className="cg-record-comment-content">
                <div className="cg-record-comment-meta">
                  <strong>{comment.author}</strong>
                  <time dateTime={comment.createdAt}>{when(comment.createdAt)}</time>
                  {canDelete && (
                    <button
                      type="button"
                      className="cg-record-comment-delete"
                      aria-label={`Delete comment by ${comment.author}`}
                      title="Delete comment"
                      onClick={() => void remove(comment)}
                    >
                      ×
                    </button>
                  )}
                </div>
                <p>{comment.body}</p>
              </div>
            </article>
          );
        })}
        {error && <div className="cg-record-comment-error">{error}</div>}
      </div>
      <form className="cg-record-comment-compose" onSubmit={(event) => void post(event)}>
        <label htmlFor={`cg-comment-${pid}`}>Add a comment</label>
        <textarea
          ref={textareaRef}
          id={`cg-comment-${pid}`}
          value={draft}
          maxLength={4000}
          placeholder="Share an update or leave context…"
          onChange={(event) =>
            dispatch({ type: "draft", pid, draft: event.target.value })
          }
          onKeyDown={(event) => {
            if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
              event.preventDefault();
              event.currentTarget.form?.requestSubmit();
            }
          }}
        />
        <div className="cg-record-comment-compose-foot">
          <span>Ctrl/⌘ + Enter to post</span>
          <button type="submit" disabled={posting || !draft.trim()}>
            {posting ? "Posting…" : "Comment"}
          </button>
        </div>
      </form>
    </aside>
  );
}