Skip to content

Commit e156e43

Browse files
authored
Merge pull request #165 from deholic/release/v0.11.0
release: v0.11.0
2 parents ea70c79 + 87a65e1 commit e156e43

8 files changed

Lines changed: 462 additions & 20 deletions

File tree

src/App.tsx

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,13 @@ const TimelineSection = ({
403403
const actionsDisabled = timelineType === "notifications";
404404
const emptyMessage = timelineType === "notifications" ? "표시할 알림이 없습니다." : "표시할 글이 없습니다.";
405405

406+
useEffect(() => {
407+
if (!timeline.error) {
408+
return;
409+
}
410+
showToast(timeline.error, { tone: "error" });
411+
}, [showToast, timeline.error]);
412+
406413
useEffect(() => {
407414
const el = scrollRef.current;
408415
if (!el) {
@@ -475,6 +482,13 @@ const TimelineSection = ({
475482
refreshNotifications();
476483
}, [notificationsOpen, refreshNotifications]);
477484

485+
useEffect(() => {
486+
if (!notificationsError) {
487+
return;
488+
}
489+
showToast(notificationsError, { tone: "error" });
490+
}, [notificationsError, showToast]);
491+
478492
const handleToggleFavourite = async (status: Status) => {
479493
if (!account) {
480494
onError("계정을 선택해주세요.");
@@ -649,7 +663,6 @@ const TimelineSection = ({
649663
<div className="overlay-backdrop" aria-hidden="true" />
650664
<div ref={notificationMenuRef} className="notification-popover panel" role="dialog" aria-modal="true" aria-label="알림">
651665
<div className="notification-popover-body" ref={notificationScrollRef}>
652-
{notificationsError ? <p className="error">{notificationsError}</p> : null}
653666
{notificationItems.length === 0 && !notificationsLoading ? (
654667
<p className="empty">표시할 알림이 없습니다.</p>
655668
) : null}
@@ -785,7 +798,6 @@ const TimelineSection = ({
785798
</div>
786799
<div className="timeline-column-body" ref={scrollRef}>
787800
{!account ? <p className="empty">계정을 선택하면 타임라인을 불러옵니다.</p> : null}
788-
{account && timeline.error ? <p className="error">{timeline.error}</p> : null}
789801
{account && timeline.items.length === 0 && !timeline.loading ? (
790802
<p className="empty">{emptyMessage}</p>
791803
) : null}
@@ -836,10 +848,14 @@ const TimelineSection = ({
836848
);
837849
};
838850

839-
type ThemeMode = "default" | "christmas" | "sky-pink" | "monochrome";
851+
type ThemeMode = "default" | "christmas" | "sky-pink" | "monochrome" | "matcha-core";
840852

841853
const isThemeMode = (value: string): value is ThemeMode =>
842-
value === "default" || value === "christmas" || value === "sky-pink" || value === "monochrome";
854+
value === "default" ||
855+
value === "christmas" ||
856+
value === "sky-pink" ||
857+
value === "monochrome" ||
858+
value === "matcha-core";
843859

844860
const getStoredTheme = (): ThemeMode => {
845861
const storedTheme = localStorage.getItem("textodon.theme");
@@ -888,6 +904,7 @@ export const App = () => {
888904
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
889905
const [mobileComposeOpen, setMobileComposeOpen] = useState(false);
890906
const { services, accountsState } = useAppContext();
907+
const { showToast } = useToast();
891908
const [sections, setSections] = useState<TimelineSectionConfig[]>(() => {
892909
try {
893910
const raw = localStorage.getItem(SECTION_STORAGE_KEY);
@@ -956,6 +973,14 @@ export const App = () => {
956973
const previousAccountIds = useRef<Set<string>>(new Set());
957974
const hasAccounts = accountsState.accounts.length > 0;
958975

976+
useEffect(() => {
977+
if (!actionError) {
978+
return;
979+
}
980+
showToast(actionError, { tone: "error" });
981+
setActionError(null);
982+
}, [actionError, showToast]);
983+
959984
const registerTimelineListener = useCallback((accountId: string, listener: (status: Status) => void) => {
960985
const next = new Map(timelineListeners.current);
961986
const existing = next.get(accountId) ?? new Set();
@@ -1669,7 +1694,6 @@ export const App = () => {
16691694
{hasAccounts ? (
16701695
<section className="main-column">
16711696
{oauthLoading ? <p className="empty">OAuth 인증 중...</p> : null}
1672-
{actionError ? <p className="error">{actionError}</p> : null}
16731697
{route === "home" ? (
16741698
<section className="panel">
16751699
{sections.length > 0 ? (
@@ -1881,6 +1905,7 @@ onAccountChange={setSectionAccount}
18811905
<option value="christmas">크리스마스</option>
18821906
<option value="sky-pink">하늘핑크</option>
18831907
<option value="monochrome">모노톤</option>
1908+
<option value="matcha-core">말차코어</option>
18841909
</select>
18851910
</div>
18861911
<div className="settings-item">

src/ui/components/AccountAdd.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,23 @@ import React, { useState } from "react";
22
import type { OAuthClient } from "../../services/OAuthClient";
33
import { normalizeInstanceUrl } from "../utils/account";
44
import { createOauthState, loadRegisteredApp, saveRegisteredApp, storePendingOAuth } from "../utils/oauth";
5+
import { useToast } from "../state/ToastContext";
56

67
export const AccountAdd = ({
78
oauth
89
}: {
910
oauth: OAuthClient;
1011
}) => {
1112
const [instanceUrl, setInstanceUrl] = useState("");
12-
const [error, setError] = useState<string | null>(null);
1313
const [loading, setLoading] = useState(false);
1414
const [showForm, setShowForm] = useState(false);
15+
const { showToast } = useToast();
1516
const handleSubmit = async (event: React.FormEvent) => {
1617
event.preventDefault();
17-
setError(null);
1818

1919
const normalizedUrl = normalizeInstanceUrl(instanceUrl);
2020
if (!normalizedUrl) {
21-
setError("서버 주소를 입력해주세요.");
21+
showToast("서버 주소를 입력해주세요.", { tone: "error" });
2222
return;
2323
}
2424

@@ -42,7 +42,7 @@ export const AccountAdd = ({
4242
const authorizeUrl = oauth.buildAuthorizeUrl(registered, state);
4343
window.location.assign(authorizeUrl);
4444
} catch (err) {
45-
setError(err instanceof Error ? err.message : "OAuth 연결에 실패했습니다.");
45+
showToast(err instanceof Error ? err.message : "OAuth 연결에 실패했습니다.", { tone: "error" });
4646
} finally {
4747
setLoading(false);
4848
}
@@ -83,7 +83,6 @@ export const AccountAdd = ({
8383
onChange={(event) => setInstanceUrl(event.target.value)}
8484
/>
8585
</label>
86-
{error ? <p className="error">{error}</p> : null}
8786
<button type="submit" disabled={loading}>
8887
{loading ? "연결 중..." : "OAuth로 연결"}
8988
</button>

src/ui/components/ComposeBox.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Account, Visibility } from "../../domain/types";
33
import type { MastodonApi } from "../../services/MastodonApi";
44
import { useEmojiManager, type EmojiItem } from "../hooks/useEmojiManager";
55
import { useImageZoom } from "../hooks/useImageZoom";
6+
import { useToast } from "../state/ToastContext";
67
import {
78
calculateCharacterCount,
89
getCharacterLimit,
@@ -94,6 +95,8 @@ export const ComposeBox = ({
9495
} = useImageZoom(imageContainerRef, imageRef);
9596
const [emojiPanelOpen, setEmojiPanelOpen] = useState(false);
9697
const [recentOpen, setRecentOpen] = useState(true);
98+
const { showToast } = useToast();
99+
const lastEmojiErrorRef = useRef<string | null>(null);
97100

98101
// 문자 수 관련 상태
99102
const [characterLimit, setCharacterLimit] = useState<number | null>(null);
@@ -114,6 +117,19 @@ export const ComposeBox = ({
114117
searchEmojis
115118
} = useEmojiManager(account, api, false);
116119

120+
useEffect(() => {
121+
if (emojiStatus !== "error") {
122+
lastEmojiErrorRef.current = null;
123+
return;
124+
}
125+
const message = emojiError ?? "이모지를 불러오지 못했습니다.";
126+
if (message === lastEmojiErrorRef.current) {
127+
return;
128+
}
129+
lastEmojiErrorRef.current = message;
130+
showToast(message, { tone: "error" });
131+
}, [emojiError, emojiStatus, showToast]);
132+
117133
const activeImage = useMemo(
118134
() => attachments.find((item) => item.id === activeImageId) ?? null,
119135
[attachments, activeImageId]
@@ -209,7 +225,7 @@ export const ComposeBox = ({
209225

210226
// 문자 수 제한 검사
211227
if (characterLimit && currentCharCount > characterLimit) {
212-
alert(`글자 수 제한(${characterLimit.toLocaleString()}자)을 초과했습니다.`);
228+
showToast(`글자 수 제한(${characterLimit.toLocaleString()}자)을 초과했습니다.`, { tone: "error" });
213229
return;
214230
}
215231

src/ui/components/ProfileModal.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,30 @@ export const ProfileModal = ({
295295
};
296296
}, [account, api, targetAccountId]);
297297

298+
useEffect(() => {
299+
if (!profileError) {
300+
return;
301+
}
302+
showToast(profileError, { tone: "error" });
303+
setProfileError(null);
304+
}, [profileError, showToast]);
305+
306+
useEffect(() => {
307+
if (!followError) {
308+
return;
309+
}
310+
showToast(followError, { tone: "error" });
311+
setFollowError(null);
312+
}, [followError, showToast]);
313+
314+
useEffect(() => {
315+
if (!itemsError) {
316+
return;
317+
}
318+
showToast(itemsError, { tone: "error" });
319+
setItemsError(null);
320+
}, [itemsError, showToast]);
321+
298322
const updateItem = useCallback((next: Status) => {
299323
setItems((current) => current.map((item) => (item.id === next.id ? next : item)));
300324
}, []);
@@ -605,7 +629,6 @@ export const ProfileModal = ({
605629
const message = error instanceof Error ? error.message : fallbackMessage;
606630
setRelationship(previous);
607631
setFollowError(message);
608-
showToast(message, { tone: "error" });
609632
} finally {
610633
setFollowLoading(false);
611634
}
@@ -908,9 +931,7 @@ export const ProfileModal = ({
908931
) : null}
909932
</div>
910933
</div>
911-
{followError ? <p className="error">{followError}</p> : null}
912934
{profileLoading ? <p className="empty">프로필을 불러오는 중...</p> : null}
913-
{profileError ? <p className="error">{profileError}</p> : null}
914935
{bioContent
915936
? bioContent.type === "html"
916937
? <div className="profile-bio" dangerouslySetInnerHTML={{ __html: bioContent.value }} />
@@ -929,7 +950,6 @@ export const ProfileModal = ({
929950
</section>
930951
<section className="profile-posts">
931952
<h4>작성한 글</h4>
932-
{itemsError ? <p className="error">{itemsError}</p> : null}
933953
{itemsLoading && items.length === 0 ? <p className="empty">게시글을 불러오는 중...</p> : null}
934954
{!itemsLoading && items.length === 0 ? <p className="empty">표시할 글이 없습니다.</p> : null}
935955
{items.length > 0 ? (

src/ui/components/ReactionPicker.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Account, ReactionInput } from "../../domain/types";
33
import type { MastodonApi } from "../../services/MastodonApi";
44
import { useClickOutside } from "../hooks/useClickOutside";
55
import { useEmojiManager, type EmojiItem } from "../hooks/useEmojiManager";
6+
import { useToast } from "../state/ToastContext";
67

78
export const ReactionPicker = ({
89
account,
@@ -21,6 +22,8 @@ export const ReactionPicker = ({
2122
const [emojiSearchQuery, setEmojiSearchQuery] = useState("");
2223
const buttonRef = useRef<HTMLButtonElement | null>(null);
2324
const panelRef = useRef<HTMLDivElement | null>(null);
25+
const { showToast } = useToast();
26+
const lastEmojiErrorRef = useRef<string | null>(null);
2427

2528
// useEmojiManager 훅 사용
2629
const {
@@ -37,6 +40,19 @@ export const ReactionPicker = ({
3740
searchEmojis
3841
} = useEmojiManager(account, api, false);
3942

43+
useEffect(() => {
44+
if (emojiStatus !== "error") {
45+
lastEmojiErrorRef.current = null;
46+
return;
47+
}
48+
const message = emojiError ?? "이모지를 불러오지 못했습니다.";
49+
if (message === lastEmojiErrorRef.current) {
50+
return;
51+
}
52+
lastEmojiErrorRef.current = message;
53+
showToast(message, { tone: "error" });
54+
}, [emojiError, emojiStatus, showToast]);
55+
4056
const emojiSearchResults = useMemo(() => {
4157
if (!emojiSearchQuery.trim()) {
4258
return [];

src/ui/components/StatusModal.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Account, CustomEmoji, Status, ThreadContext } from "../../domain/t
33
import type { MastodonApi } from "../../services/MastodonApi";
44
import { TimelineItem } from "./TimelineItem";
55
import BoostIcon from "../assets/boost-icon.svg?react";
6+
import { useToast } from "../state/ToastContext";
67

78
export const StatusModal = ({
89
status,
@@ -120,6 +121,7 @@ export const StatusModal = ({
120121
const [threadContext, setThreadContext] = useState<ThreadContext | null>(null);
121122
const [isLoadingThread, setIsLoadingThread] = useState(false);
122123
const [threadError, setThreadError] = useState<string | null>(null);
124+
const { showToast } = useToast();
123125

124126
// 스레드 컨텍스트 가져오기
125127
useEffect(() => {
@@ -145,6 +147,14 @@ export const StatusModal = ({
145147
fetchThreadContext();
146148
}, [account, api, displayStatus.id]);
147149

150+
useEffect(() => {
151+
if (!threadError) {
152+
return;
153+
}
154+
showToast(threadError, { tone: "error" });
155+
setThreadError(null);
156+
}, [showToast, threadError]);
157+
148158
return (
149159
<div
150160
className="status-modal"
@@ -268,11 +278,6 @@ export const StatusModal = ({
268278

269279
{/* 로딩 상태는 헤더에서 처리 */}
270280

271-
{threadError && (
272-
<div className="thread-error">
273-
<span>{threadError}</span>
274-
</div>
275-
)}
276281
</div>
277282
</div>
278283
</div>

0 commit comments

Comments
 (0)