feat: 게시물 탭을 Mattermost 스타일로 개선(이니셜 아바타, 메시지 그루핑, 표시명 우선순위)
@123dd117c363704edf279291eb124aa3c68a4a96
--- frontend/src/components/ChannelPosts.test.tsx
+++ frontend/src/components/ChannelPosts.test.tsx
... | ... | @@ -104,6 +104,19 @@ |
| 104 | 104 |
await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) |
| 105 | 105 |
}) |
| 106 | 106 |
|
| 107 |
+ it('같은 사람이 5분 안에 연달아 쓴 메시지는 이름과 아바타를 한 번만 보여준다', async () => {
|
|
| 108 |
+ const now = Date.now() |
|
| 109 |
+ mocks.getPosts.mockResolvedValue([ |
|
| 110 |
+ post({ id: 'p1', createAt: now }),
|
|
| 111 |
+ post({ id: 'p2', message: '이어서 씁니다', createAt: now + 1000 }),
|
|
| 112 |
+ ]) |
|
| 113 |
+ |
|
| 114 |
+ render(<ChannelPosts org={org()} />)
|
|
| 115 |
+ |
|
| 116 |
+ await waitFor(() => expect(screen.getByText('이어서 씁니다')).toBeTruthy())
|
|
| 117 |
+ expect(screen.getAllByText('송민지')).toHaveLength(1)
|
|
| 118 |
+ }) |
|
| 119 |
+ |
|
| 107 | 120 |
it('첨부파일 칩의 링크는 다운로드 엔드포인트를 가리킨다', async () => {
|
| 108 | 121 |
mocks.getPosts.mockResolvedValue([ |
| 109 | 122 |
post({ files: [{ id: 'f1', name: '보고서.pdf', size: 1200 }] }),
|
--- frontend/src/components/ChannelPosts.tsx
+++ frontend/src/components/ChannelPosts.tsx
... | ... | @@ -28,25 +28,78 @@ |
| 28 | 28 |
) |
| 29 | 29 |
} |
| 30 | 30 |
|
| 31 |
-function PostRow({ post }: { post: PostView }) {
|
|
| 31 |
+// 이름에서 결정되는 아바타 색 — 같은 사람은 항상 같은 색이 나온다 (Mattermost 이니셜 아바타 방식) |
|
| 32 |
+const AVATAR_COLORS = [ |
|
| 33 |
+ 'bg-rose-500', |
|
| 34 |
+ 'bg-orange-500', |
|
| 35 |
+ 'bg-amber-500', |
|
| 36 |
+ 'bg-emerald-500', |
|
| 37 |
+ 'bg-teal-500', |
|
| 38 |
+ 'bg-sky-500', |
|
| 39 |
+ 'bg-indigo-500', |
|
| 40 |
+ 'bg-violet-500', |
|
| 41 |
+ 'bg-fuchsia-500', |
|
| 42 |
+] |
|
| 43 |
+ |
|
| 44 |
+function avatarColor(name: string): string {
|
|
| 45 |
+ let hash = 0 |
|
| 46 |
+ for (const ch of name) {
|
|
| 47 |
+ hash = (hash + ch.codePointAt(0)!) % AVATAR_COLORS.length |
|
| 48 |
+ } |
|
| 49 |
+ return AVATAR_COLORS[hash] |
|
| 50 |
+} |
|
| 51 |
+ |
|
| 52 |
+function Avatar({ name }: { name: string }) {
|
|
| 53 |
+ return ( |
|
| 54 |
+ <span |
|
| 55 |
+ aria-hidden="true" |
|
| 56 |
+ className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-semibold text-white ${avatarColor(name)}`}
|
|
| 57 |
+ > |
|
| 58 |
+ {[...name][0] ?? '?'}
|
|
| 59 |
+ </span> |
|
| 60 |
+ ) |
|
| 61 |
+} |
|
| 62 |
+ |
|
| 63 |
+/** 같은 사람이 5분 안에 연달아 쓴 메시지는 아바타·이름 없이 이어 붙인다 (Mattermost와 동일) */ |
|
| 64 |
+const GROUP_WINDOW_MS = 5 * 60 * 1000 |
|
| 65 |
+ |
|
| 66 |
+function PostRow({ post, prev }: { post: PostView; prev: PostView | null }) {
|
|
| 32 | 67 |
if (post.system) {
|
| 33 |
- return <p className="py-1 text-center text-xs text-gray-400">{post.message}</p>
|
|
| 68 |
+ return <p className="py-2 text-center text-xs text-gray-400">{post.message}</p>
|
|
| 34 | 69 |
} |
| 35 | 70 |
|
| 71 |
+ const continued = |
|
| 72 |
+ prev !== null && |
|
| 73 |
+ !prev.system && |
|
| 74 |
+ prev.user === post.user && |
|
| 75 |
+ post.createAt - prev.createAt < GROUP_WINDOW_MS |
|
| 76 |
+ |
|
| 36 | 77 |
return ( |
| 37 |
- <div className="py-2"> |
|
| 38 |
- <div className="flex items-baseline gap-2"> |
|
| 39 |
- <span className="text-sm font-semibold text-gray-800">{post.user}</span>
|
|
| 40 |
- <span className="text-xs text-gray-400">{timeFormatter.format(new Date(post.createAt))}</span>
|
|
| 78 |
+ <div className={`flex gap-3 ${continued ? 'mt-0.5' : 'mt-4 first:mt-0'}`}>
|
|
| 79 |
+ {continued ? <span className="w-9 shrink-0" /> : <Avatar name={post.user} />}
|
|
| 80 |
+ |
|
| 81 |
+ <div className="min-w-0 flex-1"> |
|
| 82 |
+ {!continued && (
|
|
| 83 |
+ <div className="flex items-baseline gap-2"> |
|
| 84 |
+ <span className="text-sm font-semibold text-gray-900">{post.user}</span>
|
|
| 85 |
+ <span className="text-xs text-gray-400"> |
|
| 86 |
+ {timeFormatter.format(new Date(post.createAt))}
|
|
| 87 |
+ </span> |
|
| 88 |
+ </div> |
|
| 89 |
+ )} |
|
| 90 |
+ {post.message && (
|
|
| 91 |
+ <p className="whitespace-pre-wrap text-sm leading-relaxed text-gray-800"> |
|
| 92 |
+ {post.message}
|
|
| 93 |
+ </p> |
|
| 94 |
+ )} |
|
| 95 |
+ {post.files.length > 0 && (
|
|
| 96 |
+ <div className="mt-1.5 flex flex-wrap gap-1.5"> |
|
| 97 |
+ {post.files.map((f) => (
|
|
| 98 |
+ <AttachmentChip key={f.id} file={f} />
|
|
| 99 |
+ ))} |
|
| 100 |
+ </div> |
|
| 101 |
+ )} |
|
| 41 | 102 |
</div> |
| 42 |
- <p className="mt-0.5 whitespace-pre-wrap text-sm text-gray-700">{post.message}</p>
|
|
| 43 |
- {post.files.length > 0 && (
|
|
| 44 |
- <div className="mt-1.5 flex flex-wrap gap-1.5"> |
|
| 45 |
- {post.files.map((f) => (
|
|
| 46 |
- <AttachmentChip key={f.id} file={f} />
|
|
| 47 |
- ))} |
|
| 48 |
- </div> |
|
| 49 |
- )} |
|
| 50 | 103 |
</div> |
| 51 | 104 |
) |
| 52 | 105 |
} |
... | ... | @@ -138,12 +191,16 @@ |
| 138 | 191 |
)} |
| 139 | 192 |
<div |
| 140 | 193 |
ref={containerRef}
|
| 141 |
- className="mt-3 h-96 overflow-y-auto rounded-lg border border-gray-200 px-3" |
|
| 194 |
+ className="mt-3 h-96 overflow-y-auto rounded-lg border border-gray-200 bg-white p-4" |
|
| 142 | 195 |
> |
| 143 | 196 |
{posts.length === 0 ? (
|
| 144 |
- <p className="py-6 text-center text-sm text-gray-300">게시글이 없습니다.</p> |
|
| 197 |
+ <p className="py-6 text-center text-sm text-gray-400"> |
|
| 198 |
+ 아직 게시글이 없습니다. Mattermost에서 메시지를 보내면 여기 나타납니다. |
|
| 199 |
+ </p> |
|
| 145 | 200 |
) : ( |
| 146 |
- posts.map((post) => <PostRow key={post.id} post={post} />)
|
|
| 201 |
+ posts.map((post, i) => ( |
|
| 202 |
+ <PostRow key={post.id} post={post} prev={i > 0 ? posts[i - 1] : null} />
|
|
| 203 |
+ )) |
|
| 147 | 204 |
)} |
| 148 | 205 |
</div> |
| 149 | 206 |
</> |
--- src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
... | ... | @@ -278,7 +278,12 @@ |
| 278 | 278 |
createAt, system, files); |
| 279 | 279 |
} |
| 280 | 280 |
|
| 281 |
- /** 실패해도 원본 id로 캐시해, 사용자 조회 실패 하나 때문에 목록 전체가 죽지 않게 한다. */ |
|
| 281 |
+ /** |
|
| 282 |
+ * 표시 이름 우선순위는 Mattermost 화면과 같게 맞춘다: |
|
| 283 |
+ * 닉네임 → 성명(first+last) → 계정명(username) → id. |
|
| 284 |
+ * (계정명만 쓰면 화면에 itnadmin처럼 나와 실제 Mattermost 표시와 어긋난다.) |
|
| 285 |
+ * 실패해도 원본 id로 캐시해, 사용자 조회 실패 하나 때문에 목록 전체가 죽지 않게 한다. |
|
| 286 |
+ */ |
|
| 282 | 287 |
private String resolveUserName(String userId) {
|
| 283 | 288 |
return userNameCache.computeIfAbsent(userId, id -> {
|
| 284 | 289 |
try {
|
... | ... | @@ -293,6 +298,11 @@ |
| 293 | 298 |
if (!nickname.isBlank()) {
|
| 294 | 299 |
return nickname; |
| 295 | 300 |
} |
| 301 |
+ String fullName = (user.path("first_name").asText("") + " "
|
|
| 302 |
+ + user.path("last_name").asText("")).trim();
|
|
| 303 |
+ if (!fullName.isBlank()) {
|
|
| 304 |
+ return fullName; |
|
| 305 |
+ } |
|
| 296 | 306 |
String username = user.path("username").asText("");
|
| 297 | 307 |
return username.isBlank() ? id : username; |
| 298 | 308 |
} catch (RestClientException e) {
|
--- src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
+++ src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
... | ... | @@ -287,6 +287,35 @@ |
| 287 | 287 |
} |
| 288 | 288 |
|
| 289 | 289 |
@Test |
| 290 |
+ void 닉네임이_없으면_성명을_그마저_없으면_계정명을_표시한다() {
|
|
| 291 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts"))
|
|
| 292 |
+ .withQueryParam("per_page", equalTo("60"))
|
|
| 293 |
+ .willReturn(okJson("""
|
|
| 294 |
+ {
|
|
| 295 |
+ "order": ["p2", "p1"], |
|
| 296 |
+ "posts": {
|
|
| 297 |
+ "p1": {"id":"p1","user_id":"uFull","message":"성명 사용자","create_at":100,"type":""},
|
|
| 298 |
+ "p2": {"id":"p2","user_id":"uBare","message":"계정명 사용자","create_at":200,"type":""}
|
|
| 299 |
+ } |
|
| 300 |
+ } |
|
| 301 |
+ """))); |
|
| 302 |
+ // Mattermost 화면과 같은 우선순위: 닉네임 → 성명(first+last) → 계정명 |
|
| 303 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/users/uFull"))
|
|
| 304 |
+ .willReturn(okJson( |
|
| 305 |
+ "{\"username\":\"itnadmin\",\"nickname\":\"\","
|
|
| 306 |
+ + "\"first_name\":\"아이티앤\",\"last_name\":\"관리자\"}"))); |
|
| 307 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/users/uBare"))
|
|
| 308 |
+ .willReturn(okJson( |
|
| 309 |
+ "{\"username\":\"bareuser\",\"nickname\":\"\","
|
|
| 310 |
+ + "\"first_name\":\"\",\"last_name\":\"\"}"))); |
|
| 311 |
+ |
|
| 312 |
+ List<PostView> posts = client.getRecentPosts("chan001mj", 60);
|
|
| 313 |
+ |
|
| 314 |
+ assertThat(posts).extracting(PostView::user) |
|
| 315 |
+ .containsExactly("아이티앤 관리자", "bareuser");
|
|
| 316 |
+ } |
|
| 317 |
+ |
|
| 318 |
+ @Test |
|
| 290 | 319 |
void 파일목록_조회는_두번째_페이지까지_모아_최신순으로_돌려준다() {
|
| 291 | 320 |
server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts"))
|
| 292 | 321 |
.withQueryParam("per_page", equalTo("200"))
|
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?