From d6bb41d01a672ce7fad9e0dd448da65c845fd39a Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Sun, 11 Jan 2026 10:47:45 +0900 Subject: [PATCH 1/5] feat: support markdown emojis in profile fields --- src/ui/components/ProfileModal.tsx | 27 +++++++++++++++++- src/ui/styles/components.css | 36 ++++++++++++++++++++++++ src/ui/utils/markdown.test.ts | 10 +++++++ src/ui/utils/markdown.ts | 44 ++++++++++++++++++++++++------ 4 files changed, 108 insertions(+), 9 deletions(-) diff --git a/src/ui/components/ProfileModal.tsx b/src/ui/components/ProfileModal.tsx index 0c2bc7f..393ef59 100644 --- a/src/ui/components/ProfileModal.tsx +++ b/src/ui/components/ProfileModal.tsx @@ -11,6 +11,7 @@ import type { MastodonApi } from "../../services/MastodonApi"; import { sanitizeHtml } from "../utils/htmlSanitizer"; import { formatHandle } from "../utils/account"; import { isPlainUrl, renderTextWithLinks } from "../utils/linkify"; +import { renderMarkdown } from "../utils/markdown"; import { useClickOutside } from "../hooks/useClickOutside"; import { TimelineItem } from "./TimelineItem"; @@ -29,6 +30,21 @@ const buildFallbackProfile = (status: Status): UserProfile => ({ }); const hasHtmlTags = (value: string): boolean => /<[^>]+>/.test(value); +const hasMarkdownSyntax = (value: string): boolean => { + if (!value.trim()) { + return false; + } + const patterns = [ + /^#{1,3}\s/m, + /^-\s+/m, + /```/, + /\*\*[^*]+\*\*/, + /`[^`]+`/, + /\[[^\]]+\]\([^)]+\)/, + /!\[[^\]]*\]\([^)]+\)/ + ]; + return patterns.some((pattern) => pattern.test(value)); +}; const buildEmojiMap = (emojis: CustomEmoji[]): Map => new Map(emojis.map((emoji) => [emoji.shortcode, emoji.url])); @@ -407,13 +423,22 @@ export const ProfileModal = ({ if (hasHtmlTags(value)) { return ; } + if (hasMarkdownSyntax(value)) { + const markdownEmojiMap = showCustomEmojis ? emojiMap : undefined; + return ( +
+ ); + } const nodes = renderTextWithEmojis(value, `profile-field-${index}`, false); if (isPlainUrl(value)) { return {nodes}; } return {nodes}; }, - [renderTextWithEmojis] + [emojiMap, renderTextWithEmojis, showCustomEmojis] ); const renderFieldLabel = useCallback( diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index 4e18101..82df596 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -1992,6 +1992,42 @@ button.ghost { color: var(--color-action-bg); } +.profile-field-markdown p { + margin: 0; +} + +.profile-field-markdown ul, +.profile-field-markdown ol { + margin: 0; + padding-left: 18px; +} + +.profile-field-markdown li { + margin: 2px 0; +} + +.profile-field-markdown code { + padding: 2px 4px; + border-radius: 4px; + background: var(--color-readme-code-bg); +} + +.profile-field-markdown pre { + margin: 6px 0; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--color-readme-code-border); + background: var(--color-readme-code-bg); + overflow-x: auto; +} + +.profile-field-markdown img { + max-width: 100%; + height: auto; + border-radius: 8px; + border: 1px solid var(--color-readme-code-border); +} + .profile-posts { padding: 0 16px 20px; display: flex; diff --git a/src/ui/utils/markdown.test.ts b/src/ui/utils/markdown.test.ts index 026486c..edc6887 100644 --- a/src/ui/utils/markdown.test.ts +++ b/src/ui/utils/markdown.test.ts @@ -31,4 +31,14 @@ describe("renderMarkdown", () => { '

go)

' ); }); + + it("renders custom emojis outside inline code", () => { + const input = "hi :wave: `:wave:`"; + const emojiMap = new Map([["wave", "https://example.com/wave.png"]]); + const output = renderMarkdown(input, emojiMap); + + expect(output).toBe( + '

hi :wave: :wave:

' + ); + }); }); diff --git a/src/ui/utils/markdown.ts b/src/ui/utils/markdown.ts index 0917a78..1a357cd 100644 --- a/src/ui/utils/markdown.ts +++ b/src/ui/utils/markdown.ts @@ -26,26 +26,54 @@ const renderImageTag = (alt: string, url: string): string => { return `${safeAlt}`; }; -const formatInline = (text: string): string => { +const renderEmojiTag = (shortcode: string, url: string): string => { + if (!isSafeUrl(url)) { + return escapeHtml(`:${shortcode}:`); + } + const safeUrl = escapeAttr(url.trim()); + const safeAlt = escapeHtml(`:${shortcode}:`); + return `${safeAlt}`; +}; + +const formatInline = (text: string, emojiMap?: Map): string => { + const codeSpans: string[] = []; + let tokenized = text.replace(/`([^`]+)`/g, (_match, code) => { + const safeCode = escapeHtml(code); + codeSpans.push(`${safeCode}`); + return `\u0001${codeSpans.length - 1}\u0001`; + }); const images: string[] = []; - const tokenized = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => { + tokenized = tokenized.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => { const imageTag = renderImageTag(alt, url); images.push(imageTag); return `\u0000${images.length - 1}\u0000`; }); + const emojis: string[] = []; + if (emojiMap && emojiMap.size > 0) { + tokenized = tokenized.replace(/:([a-zA-Z0-9_]+):/g, (_match, shortcode) => { + const url = emojiMap.get(shortcode); + if (!url) { + return `:${shortcode}:`; + } + const emojiTag = renderEmojiTag(shortcode, url); + emojis.push(emojiTag); + return `\u0002${emojis.length - 1}\u0002`; + }); + } let out = escapeHtml(tokenized); - out = out.replace(/\u0000(\d+)\u0000/g, (_match, index) => images[Number(index)] ?? ""); - out = out.replace(/`([^`]+)`/g, "$1"); out = out.replace(/\*\*([^*]+)\*\*/g, "$1"); out = out.replace(/\*([^*]+)\*/g, "$1"); out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_match, label, url) => { const safeUrl = escapeAttr(url); return `${label}`; }); + out = out.replace(/\u0000(\d+)\u0000/g, (_match, index) => images[Number(index)] ?? ""); + out = out.replace(/\u0002(\d+)\u0002/g, (_match, index) => emojis[Number(index)] ?? ""); + out = out.replace(/\u0001(\d+)\u0001/g, (_match, index) => codeSpans[Number(index)] ?? ""); return out; }; -export const renderMarkdown = (markdown: string): string => { +export const renderMarkdown = (markdown: string, emojiMap?: Map): string => { const lines = markdown.split(/\r?\n/); const blocks: string[] = []; let inCode = false; @@ -56,14 +84,14 @@ export const renderMarkdown = (markdown: string): string => { const flushParagraph = () => { if (paragraphBuffer.length === 0) return; - const content = paragraphBuffer.map(formatInline).join("
"); + const content = paragraphBuffer.map((line) => formatInline(line, emojiMap)).join("
"); blocks.push(`

${content}

`); paragraphBuffer = []; }; const flushList = () => { if (listBuffer.length === 0) return; - const items = listBuffer.map((item) => `
  • ${formatInline(item)}
  • `).join(""); + const items = listBuffer.map((item) => `
  • ${formatInline(item, emojiMap)}
  • `).join(""); blocks.push(`
      ${items}
    `); listBuffer = []; }; @@ -113,7 +141,7 @@ export const renderMarkdown = (markdown: string): string => { flushParagraph(); flushList(); const level = headingMatch[1].length; - blocks.push(`${formatInline(headingMatch[2])}`); + blocks.push(`${formatInline(headingMatch[2], emojiMap)}`); continue; } From dbf9f598e407ab8d0795083ce8166aba646a7d57 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Sun, 11 Jan 2026 11:17:54 +0900 Subject: [PATCH 2/5] Fix garbled Korean UI text --- src/infra/MastodonHttpClient.ts | 4 ++-- src/infra/MisskeyHttpClient.ts | 4 ++-- src/ui/components/TimelineItem.tsx | 16 ++++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/infra/MastodonHttpClient.ts b/src/infra/MastodonHttpClient.ts index 2ebd6a7..d1cb152 100644 --- a/src/infra/MastodonHttpClient.ts +++ b/src/infra/MastodonHttpClient.ts @@ -186,7 +186,7 @@ export class MastodonHttpClient implements MastodonApi { headers: buildHeaders(account) }); if (!response.ok) { - throw new Error("?„로???•ë³´ë¥?불러?¤ì? 못했?µë‹ˆ??"); + throw new Error("프로필 정보를 불러오지 못했습니다."); } const data = (await response.json()) as unknown; return mapAccountProfile(data); @@ -249,7 +249,7 @@ export class MastodonHttpClient implements MastodonApi { headers: buildHeaders(account) }); if (!response.ok) { - throw new Error("?„로???글?„ë¥?불러?¤ì? 못했?µë‹ˆ??"); + throw new Error("게시글을 불러오지 못했습니다."); } const data = (await response.json()) as unknown[]; return data.map(mapStatus); diff --git a/src/infra/MisskeyHttpClient.ts b/src/infra/MisskeyHttpClient.ts index bb6f0a3..c4688c2 100644 --- a/src/infra/MisskeyHttpClient.ts +++ b/src/infra/MisskeyHttpClient.ts @@ -233,7 +233,7 @@ export class MisskeyHttpClient implements MastodonApi { body: JSON.stringify(buildBody(account, { userId: accountId })) }); if (!response.ok) { - throw new Error("?„로???•ë³´ë¥?불러?¤ì? 못했?µë‹ˆ??"); + throw new Error("프로필 정보를 불러오지 못했습니다."); } const data = (await response.json()) as unknown; return mapMisskeyUserProfile(data, account.instanceUrl); @@ -289,7 +289,7 @@ export class MisskeyHttpClient implements MastodonApi { ) }); if (!response.ok) { - throw new Error("?„로???글?„ë¥?불러?¤ì? 못했?µë‹ˆ??"); + throw new Error("게시글을 불러오지 못했습니다."); } const data = (await response.json()) as unknown[]; return data.map((item) => mapMisskeyStatusWithInstance(item, account.instanceUrl)); diff --git a/src/ui/components/TimelineItem.tsx b/src/ui/components/TimelineItem.tsx index 12d5390..d7e3e99 100644 --- a/src/ui/components/TimelineItem.tsx +++ b/src/ui/components/TimelineItem.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Account, CustomEmoji, Mention, ReactionInput, Status } from "../../domain/types"; import type { MastodonApi } from "../../services/MastodonApi"; import { sanitizeHtml } from "../utils/htmlSanitizer"; @@ -74,7 +74,7 @@ export const TimelineItem = ({ const menuRef = useRef(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - // useImageZoom ???ъ슜 + // useImageZoom 사용 const { zoom: imageZoom, offset: imageOffset, @@ -203,8 +203,8 @@ export const TimelineItem = ({ return null; } const actorName = - notification.actor.name || notificationActorHandle || notification.actor.handle || "?????놁쓬"; - return `${actorName} ?섏씠 ${notification.label}`; + notification.actor.name || notificationActorHandle || notification.actor.handle || "알 수 없는 사용자"; + return `${actorName} 님이 ${notification.label}`; }, [notification, notificationActorHandle]); const timestamp = useMemo( () => new Date(displayStatus.createdAt).toLocaleString(), @@ -988,14 +988,14 @@ export const TimelineItem = ({ className="image-modal-close" onClick={() => setActiveImageIndex(null)} > - ?リ린 + 닫기 {attachments.length > 1 ? (
    {actionsEnabled && canDelete ? ( ) : null} diff --git a/src/ui/styles/components.css b/src/ui/styles/components.css index 82df596..f4dc9f4 100644 --- a/src/ui/styles/components.css +++ b/src/ui/styles/components.css @@ -1396,7 +1396,8 @@ button.ghost { background: var(--color-delete-button-bg); } -.delete-button img { +.delete-button img, +.delete-button svg { width: 18px; height: 18px; } @@ -2056,7 +2057,8 @@ button.ghost { margin-bottom: 8px; } -.boosted-by img { +.boosted-by img, +.boosted-by svg { width: 18px; height: 18px; } @@ -2127,7 +2129,8 @@ button.ghost { transform: translateX(20px); } -.reply-info img { +.reply-info img, +.reply-info svg { width: 18px; height: 18px; } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..07a2a97 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,13 @@ +/// + +declare module "*.svg" { + const src: string; + export default src; +} + +declare module "*.svg?react" { + import type { FunctionComponent, SVGProps } from "react"; + + const ReactComponent: FunctionComponent & { title?: string }>; + export default ReactComponent; +} diff --git a/vite.config.ts b/vite.config.ts index 4c22aa8..f27bf62 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,8 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import svgr from "vite-plugin-svgr"; export default defineConfig({ - plugins: [react()], + plugins: [svgr(), react()], base: "./" }); From a5339f8f2da631a8fb69fc23e0f964f32d9eff8f Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Sun, 11 Jan 2026 13:24:52 +0900 Subject: [PATCH 5/5] =?UTF-8?q?Pretendard=20=ED=8F=B0=ED=8A=B8=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 6 +++--- src/ui/styles/base.css | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/index.html b/index.html index a2fe4de..2b6df8c 100644 --- a/index.html +++ b/index.html @@ -1,4 +1,4 @@ - + @@ -6,10 +6,10 @@ - + Deck diff --git a/src/ui/styles/base.css b/src/ui/styles/base.css index 21a4e30..602bf32 100644 --- a/src/ui/styles/base.css +++ b/src/ui/styles/base.css @@ -1,6 +1,8 @@ -:root { +@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css"); + +:root { color-scheme: light dark; - font-family: system-ui, -apple-system, "Segoe UI", "Noto Sans", "Helvetica Neue", Arial, sans-serif; + font-family: "Pretendard", system-ui, -apple-system, "Segoe UI", "Noto Sans", "Helvetica Neue", Arial, sans-serif; background: var(--page-background); color: var(--color-text-primary); --page-background: radial-gradient(circle at top left, #f0efe9, #f9f7f2 60%, #ffffff);