+
@@ -563,16 +648,6 @@ const TimelineSection = ({
<>
-
-
-
{notificationsError ?
{notificationsError}
: null}
{notificationItems.length === 0 && !notificationsLoading ? (
@@ -646,6 +721,13 @@ const TimelineSection = ({
>
새로고침
+
+ )}
+
+ );
+ })}
+ {customEmojiCategories.length > 0 && standardEmojiCategories.length > 0 ? (
+
+ 표준 이모지
+
+ ) : null}
+ {standardEmojiCategories.map((category) => {
+ const categoryKey = `${account.instanceUrl}::${category.id}`;
+ const isCollapsed = !expandedCategories.has(category.id);
+ return (
+
+ handleToggleCategory(category.id)}
+ aria-expanded={!isCollapsed}
+ >
+ {category.label}
+ {category.emojis.length}
+
+ {isCollapsed ? null : (
+
+ {category.emojis.map((emoji) => (
+
handleSelect(emoji)}
+ aria-label={`이모지 ${emoji.label}`}
+ title={emoji.shortcode ? `:${emoji.shortcode}:` : undefined}
+ >
+ {emoji.unicode ? (
+
+ {emoji.unicode}
+
+ ) : emoji.url ? (
+
+ ) : null}
))}
)}
);
- })
- : null}
+ })}
+ >
+ ) : null}
>
diff --git a/src/ui/components/StatusModal.tsx b/src/ui/components/StatusModal.tsx
index 6e5d5e5..9bc2190 100644
--- a/src/ui/components/StatusModal.tsx
+++ b/src/ui/components/StatusModal.tsx
@@ -1,5 +1,5 @@
-import React, { useCallback, useEffect, useState } from "react";
-import type { Account, Status, ThreadContext } from "../../domain/types";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import type { Account, CustomEmoji, Status, ThreadContext } from "../../domain/types";
import type { MastodonApi } from "../../services/MastodonApi";
import { TimelineItem } from "./TimelineItem";
import BoostIcon from "../assets/boost-icon.svg?react";
@@ -43,6 +43,69 @@ export const StatusModal = ({
}) => {
const displayStatus = status.reblog ?? status;
const boostedBy = status.reblog ? status.boostedBy : null;
+ const renderEmojiText = useCallback(
+ (text: string, customEmojis: CustomEmoji[]): React.ReactNode => {
+ if (!showCustomEmojis || customEmojis.length === 0) {
+ return text;
+ }
+ const emojiMap = new Map(customEmojis.map((emoji) => [emoji.shortcode, emoji.url]));
+ const regex = /:([a-zA-Z0-9_]+):/g;
+ const nodes: React.ReactNode[] = [];
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+ let segmentIndex = 0;
+ while ((match = regex.exec(text)) !== null) {
+ const shortcode = match[1];
+ const url = emojiMap.get(shortcode);
+ if (match.index > lastIndex) {
+ nodes.push(
+
+ {text.slice(lastIndex, match.index)}
+
+ );
+ segmentIndex += 1;
+ }
+ if (url) {
+ nodes.push(
+

+ );
+ } else {
+ nodes.push(
+
+ {match[0]}
+
+ );
+ }
+ segmentIndex += 1;
+ lastIndex = match.index + match[0].length;
+ }
+ if (lastIndex < text.length) {
+ nodes.push(
+
{text.slice(lastIndex)}
+ );
+ }
+ return nodes;
+ },
+ [showCustomEmojis]
+ );
+ const boostedLabel = useMemo(() => {
+ if (!boostedBy) {
+ return null;
+ }
+ const label = boostedBy.name || boostedBy.handle;
+ const labelNode = renderEmojiText(label, status.accountEmojis);
+ return (
+ <>
+ {labelNode} 님이 부스트함
+ >
+ );
+ }, [boostedBy, renderEmojiText, status.accountEmojis]);
const handleProfileClick = useCallback(
(target: Status) => {
if (!onProfileClick) {
@@ -144,10 +207,10 @@ export const StatusModal = ({
)}
- {boostedBy ? (
+ {boostedLabel ? (
- {boostedBy.name || boostedBy.handle} 님이 부스트함
+ {boostedLabel}
) : null}
diff --git a/src/ui/components/TimelineItem.tsx b/src/ui/components/TimelineItem.tsx
index 84ca0df..f21915f 100644
--- a/src/ui/components/TimelineItem.tsx
+++ b/src/ui/components/TimelineItem.tsx
@@ -122,15 +122,6 @@ export const TimelineItem = ({
}, [activeImageIndex, goToPrevImage, goToNextImage]);
const previewCard = displayStatus.card;
- const mentionNames = useMemo(() => {
- if (!displayStatus.mentions || displayStatus.mentions.length === 0) {
- return "";
- }
- return displayStatus.mentions
- .map((mention) => mention.displayName || mention.handle)
- .filter(Boolean)
- .join(", ");
- }, [displayStatus.mentions]);
const displayHandle = useMemo(() => {
if (displayStatus.accountHandle.includes("@")) {
return displayStatus.accountHandle;
@@ -162,19 +153,93 @@ export const TimelineItem = ({
return boostedBy.handle;
}
}, [boostedBy]);
+ const renderEmojiText = useCallback(
+ (text: string, customEmojis: CustomEmoji[]): React.ReactNode => {
+ if (!showCustomEmojis || customEmojis.length === 0) {
+ return text;
+ }
+ const emojiMap = new Map(customEmojis.map((emoji) => [emoji.shortcode, emoji.url]));
+ const regex = /:([a-zA-Z0-9_]+):/g;
+ const nodes: React.ReactNode[] = [];
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+ let segmentIndex = 0;
+ while ((match = regex.exec(text)) !== null) {
+ const shortcode = match[1];
+ const url = emojiMap.get(shortcode);
+ if (match.index > lastIndex) {
+ nodes.push(
+
+ {text.slice(lastIndex, match.index)}
+
+ );
+ segmentIndex += 1;
+ }
+ if (url) {
+ nodes.push(
+

+ );
+ } else {
+ nodes.push(
+
+ {match[0]}
+
+ );
+ }
+ segmentIndex += 1;
+ lastIndex = match.index + match[0].length;
+ }
+ if (lastIndex < text.length) {
+ nodes.push(
+
{text.slice(lastIndex)}
+ );
+ }
+ return nodes;
+ },
+ [showCustomEmojis]
+ );
+ const mentionLabel = useMemo(() => {
+ if (!displayStatus.mentions || displayStatus.mentions.length === 0) {
+ return null;
+ }
+ const labels = displayStatus.mentions
+ .map((mention) => mention.displayName || mention.handle)
+ .filter(Boolean);
+ if (labels.length === 0) {
+ return null;
+ }
+ const emojiList = [...displayStatus.customEmojis, ...displayStatus.accountEmojis];
+ return labels.map((label, index) => (
+
+ {index > 0 ? ", " : ""}
+ {renderEmojiText(label, emojiList)}
+
+ ));
+ }, [displayStatus.accountEmojis, displayStatus.customEmojis, displayStatus.mentions, renderEmojiText]);
const boostedLabel = useMemo(() => {
if (notification) {
return null;
}
if (boostedBy) {
const label = boostedBy.name || boostedHandle || boostedBy.handle;
- return `${label}이 부스트함`;
+ const labelNode = renderEmojiText(label, status.accountEmojis);
+ return (
+ <>
+ {labelNode}이 부스트함
+ >
+ );
}
if (displayStatus.reblogged) {
return "내가 부스트함";
}
return null;
- }, [boostedBy, boostedHandle, displayStatus.reblogged, activeHandle, notification]);
+ }, [boostedBy, boostedHandle, displayStatus.reblogged, notification, renderEmojiText, status.accountEmojis]);
const canOpenProfile = Boolean(onProfileClick);
const profileLabel = `${displayStatus.accountName || displayStatus.accountHandle} 프로필 보기`;
const notificationActorHandle = useMemo(() => {
@@ -204,8 +269,13 @@ export const TimelineItem = ({
}
const actorName =
notification.actor.name || notificationActorHandle || notification.actor.handle || "알 수 없는 사용자";
- return `${actorName} 님이 ${notification.label}`;
- }, [notification, notificationActorHandle]);
+ const actorNode = renderEmojiText(actorName, status.accountEmojis);
+ return (
+ <>
+ {actorNode} 님이 {notification.label}
+ >
+ );
+ }, [notification, notificationActorHandle, renderEmojiText, status.accountEmojis]);
const timestamp = useMemo(
() => new Date(displayStatus.createdAt).toLocaleString(),
[displayStatus.createdAt]
@@ -297,6 +367,19 @@ export const TimelineItem = ({
},
[displayStatus, onStatusClick]
);
+ const handleStatusKeyDown = useCallback(
+ (event: React.KeyboardEvent
) => {
+ if (event.key !== "Enter" && event.key !== " ") {
+ return;
+ }
+ event.preventDefault();
+ if (!onStatusClick) {
+ return;
+ }
+ onStatusClick(displayStatus);
+ },
+ [displayStatus, onStatusClick]
+ );
const visibilityIcon = useMemo(() => {
switch (displayStatus.visibility) {
case "public":
@@ -681,15 +764,31 @@ export const TimelineItem = ({
) : null}
{boostedLabel ? (
-