diff --git a/README.md b/README.md index cffb7cb..b91ab8f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Deck +# Deck 오픈소스 페디버스 웹 클라이언트입니다. 여러 계정을 등록해 전환하면서 여러 타임라인 섹션을 동시에 확인하고, 스트리밍으로 실시간 반영합니다. @@ -56,6 +56,16 @@ bun run build - 예시: GitHub Actions를 통해 `develop`은 beta(GitHub Pages), `main`은 production(Cloudflare Pages) 기준으로 자동 배포 - 빌드 후 `dist` 정적 파일을 원하는 호스팅에 직접 배포할 수도 있습니다. +## 오픈소스 + +프로젝트에서 사용하는 주요 오픈소스는 다음과 같습니다. + +- React: UI 렌더링 라이브러리 +- Vite: 번들링 및 개발 서버 +- DOMPurify: 콘텐츠 렌더링 시 XSS 정화 +- emoji-datasource: 커스텀 이모지 메타데이터 +- Wrangler: Cloudflare 배포 도구 + ## 기여 방법 프로젝트 개선을 위한 PR을 환영합니다. diff --git a/bun.lock b/bun.lock index bfd6e79..37b8b5f 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "textodon", "dependencies": { "dompurify": "^3.3.1", + "emoji-datasource": "^16.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "wrangler": "3.90.0", @@ -282,6 +283,8 @@ "electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="], + "emoji-datasource": ["emoji-datasource@16.0.0", "", {}, "sha512-/qHKqK5Nr3+8zhgO6kHmF43Fm5C8HNn0AaFRIpgw8HF3+uF0Vfc8jgLI1ZQS5ba1vBzksS8NBCjHejwLb2D/Sg=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], diff --git a/package.json b/package.json index a8b1eeb..8908afa 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "dompurify": "^3.3.1", + "emoji-datasource": "^16.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "wrangler": "3.90.0" diff --git a/src/App.tsx b/src/App.tsx index 368381e..ce5f9b2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,13 +10,19 @@ import { useTimeline } from "./ui/hooks/useTimeline"; import { useClickOutside } from "./ui/hooks/useClickOutside"; import { useAppContext } from "./ui/state/AppContext"; import type { AccountsState, AppServices } from "./ui/state/AppContext"; -import { createAccountId, formatHandle } from "./ui/utils/account"; -import { clearPendingOAuth, loadPendingOAuth } from "./ui/utils/oauth"; +import { createAccountId, formatHandle, normalizeInstanceUrl } from "./ui/utils/account"; +import { clearPendingOAuth, createOauthState, loadPendingOAuth, loadRegisteredApp, saveRegisteredApp, storePendingOAuth } from "./ui/utils/oauth"; import { getTimelineLabel, getTimelineOptions, normalizeTimelineType } from "./ui/utils/timeline"; +import { sanitizeHtml } from "./ui/utils/htmlSanitizer"; +import { renderMarkdown } from "./ui/utils/markdown"; +import { useToast } from "./ui/state/ToastContext"; import logoUrl from "./ui/assets/textodon-icon-blue.png"; import licenseText from "../LICENSE?raw"; +import ossMarkdown from "./ui/content/oss.md?raw"; +import termsMarkdown from "./ui/content/terms.md?raw"; type Route = "home" | "terms" | "license" | "oss"; +type InfoModalType = "terms" | "license" | "oss"; type TimelineSectionConfig = { id: string; accountId: string | null; timelineType: TimelineType }; type ProfileTarget = { status: Status; account: Account | null; zIndex: number }; @@ -182,41 +188,83 @@ const TimelineIcon = ({ timeline }: { timeline: TimelineType }) => { } }; +const termsHtml = sanitizeHtml(renderMarkdown(termsMarkdown)); +const ossHtml = sanitizeHtml(renderMarkdown(ossMarkdown)); + +const TermsContent = () => ( +
+); + +const LicenseContent = () =>
{licenseText}
; + +const OssContent = () => ( +
+); + +const getInfoModalTitle = (type: InfoModalType) => { + switch (type) { + case "terms": + return "이용약관"; + case "license": + return "라이선스"; + case "oss": + return "오픈소스 목록"; + default: + return ""; + } +}; + +const InfoModalContent = ({ type }: { type: InfoModalType }) => { + switch (type) { + case "terms": + return ; + case "license": + return ; + case "oss": + return ; + default: + return null; + } +}; + +const InfoModal = ({ type, onClose }: { type: InfoModalType; onClose: () => void }) => { + const title = getInfoModalTitle(type); + return ( +
+
+
+
+

{title}

+ +
+
+ +
+
+
+ ); +}; + const TermsPage = () => (
-

- Deck은 개인 또는 팀이 운영하는 마스토돈 인스턴스에 접속하는 클라이언트입니다. 본 - 서비스는 사용자의 계정 정보 및 게시물을 저장하지 않으며, 모든 요청은 사용자가 설정한 - 인스턴스로 직접 전송됩니다. -

-

- 사용자는 각 인스턴스의 정책과 법령을 준수해야 하며, 계정 보안과 토큰 관리 책임은 - 사용자에게 있습니다. 서비스는 제공되는 기능을 개선하거나 변경할 수 있습니다. -

+
); const LicensePage = () => (
-
{licenseText}
+
); const OssPage = () => (
-

Deck은 다음 오픈소스를 사용합니다.

-
    -
  • react
  • -
  • react-dom
  • -
  • vite
  • -
  • @vitejs/plugin-react
  • -
  • typescript
  • -
  • @types/react
  • -
  • @types/react-dom
  • -
+
); @@ -237,6 +285,7 @@ const TimelineSection = ({ onProfileClick, onError, onMoveSection, + onScrollToSection, canMoveLeft, canMoveRight, canRemoveSection, @@ -245,7 +294,8 @@ const TimelineSection = ({ showCustomEmojis, showReactions, registerTimelineListener, - unregisterTimelineListener + unregisterTimelineListener, + columnRef }: { section: TimelineSectionConfig; account: Account | null; @@ -263,6 +313,7 @@ const TimelineSection = ({ onError: (message: string | null) => void; columnAccount: Account | null; onMoveSection: (sectionId: string, direction: "left" | "right") => void; + onScrollToSection: (sectionId: string) => void; onCloseStatusModal: () => void; canMoveLeft: boolean; canMoveRight: boolean; @@ -273,6 +324,7 @@ const TimelineSection = ({ showReactions: boolean; registerTimelineListener: (accountId: string, listener: (status: Status) => void) => void; unregisterTimelineListener: (accountId: string, listener: (status: Status) => void) => void; + columnRef?: React.Ref; }) => { const notificationsTimeline = useTimeline({ account, @@ -294,14 +346,26 @@ const TimelineSection = ({ const notificationMenuRef = useRef(null); const scrollRef = useRef(null); const notificationScrollRef = useRef(null); + const lastNotificationToastRef = useRef(0); const [menuOpen, setMenuOpen] = useState(false); const [timelineMenuOpen, setTimelineMenuOpen] = useState(false); const [notificationsOpen, setNotificationsOpen] = useState(false); const [notificationCount, setNotificationCount] = useState(0); const [isAtTop, setIsAtTop] = useState(true); + const { showToast } = useToast(); const timelineOptions = useMemo(() => getTimelineOptions(account?.platform, false), [account?.platform]); const timelineButtonLabel = `타임라인 선택: ${getTimelineLabel(timelineType)}`; const hasNotificationBadge = notificationCount > 0; + const instanceOriginUrl = useMemo(() => { + if (!account) { + return null; + } + try { + return normalizeInstanceUrl(account.instanceUrl); + } catch { + return null; + } + }, [account]); const notificationBadgeLabel = notificationsOpen ? "알림 닫기" : hasNotificationBadge @@ -314,7 +378,21 @@ const TimelineSection = ({ return; } setNotificationCount((count) => Math.min(count + 1, 99)); - }, [notificationsOpen, refreshNotifications]); + if (timelineType === "notifications") { + return; + } + const now = Date.now(); + if (now - lastNotificationToastRef.current < 5000) { + return; + } + lastNotificationToastRef.current = now; + showToast("새 알림이 도착했습니다.", { + tone: "info", + actionLabel: "알림 받은 컬럼으로 이동", + actionAriaLabel: "알림이 도착한 컬럼으로 이동", + onAction: () => onScrollToSection(section.id) + }); + }, [notificationsOpen, refreshNotifications, timelineType, showToast, onScrollToSection, section.id]); const timeline = useTimeline({ account, api: services.api, @@ -464,8 +542,16 @@ const TimelineSection = ({ } }; + const handleOpenInstanceOrigin = useCallback(() => { + if (!instanceOriginUrl) { + return; + } + window.open(instanceOriginUrl, "_blank", "noopener,noreferrer"); + setMenuOpen(false); + }, [instanceOriginUrl]); + return ( -
+
@@ -563,16 +648,6 @@ const TimelineSection = ({ <>