ITN Dev 07-22
feat: 게시물 탭을 Mattermost 스타일로 개선(이니셜 아바타, 메시지 그루핑, 표시명 우선순위)
@123dd117c363704edf279291eb124aa3c68a4a96
frontend/src/components/ChannelPosts.test.tsx
--- frontend/src/components/ChannelPosts.test.tsx
+++ frontend/src/components/ChannelPosts.test.tsx
@@ -104,6 +104,19 @@
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
   })
 
+  it('같은 사람이 5분 안에 연달아 쓴 메시지는 이름과 아바타를 한 번만 보여준다', async () => {
+    const now = Date.now()
+    mocks.getPosts.mockResolvedValue([
+      post({ id: 'p1', createAt: now }),
+      post({ id: 'p2', message: '이어서 씁니다', createAt: now + 1000 }),
+    ])
+
+    render(<ChannelPosts org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('이어서 씁니다')).toBeTruthy())
+    expect(screen.getAllByText('송민지')).toHaveLength(1)
+  })
+
   it('첨부파일 칩의 링크는 다운로드 엔드포인트를 가리킨다', async () => {
     mocks.getPosts.mockResolvedValue([
       post({ files: [{ id: 'f1', name: '보고서.pdf', size: 1200 }] }),
frontend/src/components/ChannelPosts.tsx
--- frontend/src/components/ChannelPosts.tsx
+++ frontend/src/components/ChannelPosts.tsx
@@ -28,25 +28,78 @@
   )
 }
 
-function PostRow({ post }: { post: PostView }) {
+// 이름에서 결정되는 아바타 색 — 같은 사람은 항상 같은 색이 나온다 (Mattermost 이니셜 아바타 방식)
+const AVATAR_COLORS = [
+  'bg-rose-500',
+  'bg-orange-500',
+  'bg-amber-500',
+  'bg-emerald-500',
+  'bg-teal-500',
+  'bg-sky-500',
+  'bg-indigo-500',
+  'bg-violet-500',
+  'bg-fuchsia-500',
+]
+
+function avatarColor(name: string): string {
+  let hash = 0
+  for (const ch of name) {
+    hash = (hash + ch.codePointAt(0)!) % AVATAR_COLORS.length
+  }
+  return AVATAR_COLORS[hash]
+}
+
+function Avatar({ name }: { name: string }) {
+  return (
+    <span
+      aria-hidden="true"
+      className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-semibold text-white ${avatarColor(name)}`}
+    >
+      {[...name][0] ?? '?'}
+    </span>
+  )
+}
+
+/** 같은 사람이 5분 안에 연달아 쓴 메시지는 아바타·이름 없이 이어 붙인다 (Mattermost와 동일) */
+const GROUP_WINDOW_MS = 5 * 60 * 1000
+
+function PostRow({ post, prev }: { post: PostView; prev: PostView | null }) {
   if (post.system) {
-    return <p className="py-1 text-center text-xs text-gray-400">{post.message}</p>
+    return <p className="py-2 text-center text-xs text-gray-400">{post.message}</p>
   }
 
+  const continued =
+    prev !== null &&
+    !prev.system &&
+    prev.user === post.user &&
+    post.createAt - prev.createAt < GROUP_WINDOW_MS
+
   return (
-    <div className="py-2">
-      <div className="flex items-baseline gap-2">
-        <span className="text-sm font-semibold text-gray-800">{post.user}</span>
-        <span className="text-xs text-gray-400">{timeFormatter.format(new Date(post.createAt))}</span>
+    <div className={`flex gap-3 ${continued ? 'mt-0.5' : 'mt-4 first:mt-0'}`}>
+      {continued ? <span className="w-9 shrink-0" /> : <Avatar name={post.user} />}
+
+      <div className="min-w-0 flex-1">
+        {!continued && (
+          <div className="flex items-baseline gap-2">
+            <span className="text-sm font-semibold text-gray-900">{post.user}</span>
+            <span className="text-xs text-gray-400">
+              {timeFormatter.format(new Date(post.createAt))}
+            </span>
+          </div>
+        )}
+        {post.message && (
+          <p className="whitespace-pre-wrap text-sm leading-relaxed text-gray-800">
+            {post.message}
+          </p>
+        )}
+        {post.files.length > 0 && (
+          <div className="mt-1.5 flex flex-wrap gap-1.5">
+            {post.files.map((f) => (
+              <AttachmentChip key={f.id} file={f} />
+            ))}
+          </div>
+        )}
       </div>
-      <p className="mt-0.5 whitespace-pre-wrap text-sm text-gray-700">{post.message}</p>
-      {post.files.length > 0 && (
-        <div className="mt-1.5 flex flex-wrap gap-1.5">
-          {post.files.map((f) => (
-            <AttachmentChip key={f.id} file={f} />
-          ))}
-        </div>
-      )}
     </div>
   )
 }
@@ -138,12 +191,16 @@
           )}
           <div
             ref={containerRef}
-            className="mt-3 h-96 overflow-y-auto rounded-lg border border-gray-200 px-3"
+            className="mt-3 h-96 overflow-y-auto rounded-lg border border-gray-200 bg-white p-4"
           >
             {posts.length === 0 ? (
-              <p className="py-6 text-center text-sm text-gray-300">게시글이 없습니다.</p>
+              <p className="py-6 text-center text-sm text-gray-400">
+                아직 게시글이 없습니다. Mattermost에서 메시지를 보내면 여기 나타납니다.
+              </p>
             ) : (
-              posts.map((post) => <PostRow key={post.id} post={post} />)
+              posts.map((post, i) => (
+                <PostRow key={post.id} post={post} prev={i > 0 ? posts[i - 1] : null} />
+              ))
             )}
           </div>
         </>
src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
--- src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
@@ -278,7 +278,12 @@
                 createAt, system, files);
     }
 
-    /** 실패해도 원본 id로 캐시해, 사용자 조회 실패 하나 때문에 목록 전체가 죽지 않게 한다. */
+    /**
+     * 표시 이름 우선순위는 Mattermost 화면과 같게 맞춘다:
+     * 닉네임 → 성명(first+last) → 계정명(username) → id.
+     * (계정명만 쓰면 화면에 itnadmin처럼 나와 실제 Mattermost 표시와 어긋난다.)
+     * 실패해도 원본 id로 캐시해, 사용자 조회 실패 하나 때문에 목록 전체가 죽지 않게 한다.
+     */
     private String resolveUserName(String userId) {
         return userNameCache.computeIfAbsent(userId, id -> {
             try {
@@ -293,6 +298,11 @@
                 if (!nickname.isBlank()) {
                     return nickname;
                 }
+                String fullName = (user.path("first_name").asText("") + " "
+                        + user.path("last_name").asText("")).trim();
+                if (!fullName.isBlank()) {
+                    return fullName;
+                }
                 String username = user.path("username").asText("");
                 return username.isBlank() ? id : username;
             } catch (RestClientException e) {
src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
--- src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
+++ src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
@@ -287,6 +287,35 @@
     }
 
     @Test
+    void 닉네임이_없으면_성명을_그마저_없으면_계정명을_표시한다() {
+        server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts"))
+                .withQueryParam("per_page", equalTo("60"))
+                .willReturn(okJson("""
+                        {
+                          "order": ["p2", "p1"],
+                          "posts": {
+                            "p1": {"id":"p1","user_id":"uFull","message":"성명 사용자","create_at":100,"type":""},
+                            "p2": {"id":"p2","user_id":"uBare","message":"계정명 사용자","create_at":200,"type":""}
+                          }
+                        }
+                        """)));
+        // Mattermost 화면과 같은 우선순위: 닉네임 → 성명(first+last) → 계정명
+        server.stubFor(get(urlPathEqualTo("/api/v4/users/uFull"))
+                .willReturn(okJson(
+                        "{\"username\":\"itnadmin\",\"nickname\":\"\","
+                                + "\"first_name\":\"아이티앤\",\"last_name\":\"관리자\"}")));
+        server.stubFor(get(urlPathEqualTo("/api/v4/users/uBare"))
+                .willReturn(okJson(
+                        "{\"username\":\"bareuser\",\"nickname\":\"\","
+                                + "\"first_name\":\"\",\"last_name\":\"\"}")));
+
+        List<PostView> posts = client.getRecentPosts("chan001mj", 60);
+
+        assertThat(posts).extracting(PostView::user)
+                .containsExactly("아이티앤 관리자", "bareuser");
+    }
+
+    @Test
     void 파일목록_조회는_두번째_페이지까지_모아_최신순으로_돌려준다() {
         server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts"))
                 .withQueryParam("per_page", equalTo("200"))
Add a comment
List