Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 138 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Account, Status, TimelineType } from "./domain/types";
import type { Account, Reaction, ReactionInput, Status, TimelineType } from "./domain/types";
import { AccountAdd } from "./ui/components/AccountAdd";
import { AccountSelector } from "./ui/components/AccountSelector";
import { ComposeBox } from "./ui/components/ComposeBox";
Expand Down Expand Up @@ -44,6 +44,85 @@ const PageHeader = ({ title }: { title: string }) => (
</div>
);

const sortReactions = (reactions: Reaction[]) =>
[...reactions].sort((a, b) => {
if (a.count === b.count) {
return a.name.localeCompare(b.name);
}
return b.count - a.count;
});

const buildReactionSignature = (reactions: Reaction[]) =>
sortReactions(reactions).map((reaction) =>
[reaction.name, reaction.count, reaction.url ?? "", reaction.isCustom ? "1" : "0", reaction.host ?? ""].join("|")
);

const hasSameReactions = (left: Status, right: Status) => {
if (left.myReaction !== right.myReaction) {
return false;
}
const leftSig = buildReactionSignature(left.reactions);
const rightSig = buildReactionSignature(right.reactions);
if (leftSig.length !== rightSig.length) {
return false;
}
return leftSig.every((value, index) => value === rightSig[index]);
};

const adjustReactionCount = (
reactions: Reaction[],
name: string,
delta: number,
fallback?: ReactionInput
) => {
let updated = false;
const next = reactions
.map((reaction) => {
if (reaction.name !== name) {
return reaction;
}
updated = true;
const count = reaction.count + delta;
if (count <= 0) {
return null;
}
return { ...reaction, count };
})
.filter((reaction): reaction is Reaction => reaction !== null);

if (!updated && delta > 0 && fallback) {
next.push({ ...fallback, count: delta });
}

return next;
};

const buildOptimisticReactionStatus = (
status: Status,
reaction: ReactionInput,
remove: boolean
): Status => {
let nextReactions = status.reactions;
if (remove) {
nextReactions = adjustReactionCount(nextReactions, reaction.name, -1);
} else {
if (status.myReaction && status.myReaction !== reaction.name) {
nextReactions = adjustReactionCount(nextReactions, status.myReaction, -1);
}
nextReactions = adjustReactionCount(nextReactions, reaction.name, 1, reaction);
}
const sorted = sortReactions(nextReactions);
const favouritesCount = sorted.reduce((sum, item) => sum + item.count, 0);
const myReaction = remove ? null : reaction.name;
return {
...status,
reactions: sorted,
myReaction,
favouritesCount,
favourited: Boolean(myReaction)
};
};

const TimelineIcon = ({ timeline }: { timeline: TimelineType }) => {
switch (timeline) {
case "home":
Expand Down Expand Up @@ -151,6 +230,7 @@ const TimelineSection = ({
onReply,
onStatusClick,
onCloseStatusModal,
onReact,
onError,
onMoveSection,
canMoveLeft,
Expand All @@ -174,6 +254,7 @@ const TimelineSection = ({
onRemoveSection: (sectionId: string) => void;
onReply: (status: Status, account: Account | null) => void;
onStatusClick: (status: Status, columnAccount: Account | null) => void;
onReact: (account: Account | null, status: Status, reaction: ReactionInput) => void;
onError: (message: string | null) => void;
columnAccount: Account | null;
onMoveSection: (sectionId: string, direction: "left" | "right") => void;
Expand Down Expand Up @@ -420,6 +501,13 @@ const TimelineSection = ({
}
};

const handleReact = useCallback(
(status: Status, reaction: ReactionInput) => {
onReact(account, status, reaction);
},
[account, onReact]
);

const handleDeleteStatus = async (status: Status) => {
if (!account) {
return;
Expand Down Expand Up @@ -575,11 +663,14 @@ const TimelineSection = ({
onToggleFavourite={handleToggleFavourite}
onToggleReblog={handleToggleReblog}
onDelete={handleDeleteStatus}
onReact={handleReact}
activeHandle={
account?.handle ? formatHandle(account.handle, account.instanceUrl) : account?.instanceUrl ?? ""
}
activeAccountHandle={account?.handle ?? ""}
activeAccountUrl={account?.url ?? null}
account={account}
api={services.api}
showProfileImage={showProfileImage}
showCustomEmojis={showCustomEmojis}
showReactions={showReactions}
Expand Down Expand Up @@ -701,11 +792,14 @@ const TimelineSection = ({
onToggleFavourite={handleToggleFavourite}
onToggleReblog={handleToggleReblog}
onDelete={handleDeleteStatus}
onReact={handleReact}
activeHandle={
account.handle ? formatHandle(account.handle, account.instanceUrl) : account.instanceUrl
}
activeAccountHandle={account.handle ?? ""}
activeAccountUrl={account.url ?? null}
account={account}
api={services.api}
showProfileImage={showProfileImage}
showCustomEmojis={showCustomEmojis}
showReactions={showReactions}
Expand Down Expand Up @@ -881,6 +975,14 @@ export const App = () => {
listeners.forEach((listener) => listener(status));
}, []);

const updateStatusEverywhere = useCallback(
(accountId: string, status: Status) => {
broadcastStatusUpdate(accountId, status);
setSelectedStatus((current) => (current && current.id === status.id ? status : current));
},
[broadcastStatusUpdate]
);

useEffect(() => {
const onHashChange = () => setRoute(parseRoute());
window.addEventListener("hashchange", onHashChange);
Expand Down Expand Up @@ -1211,6 +1313,39 @@ export const App = () => {
setSelectedStatus(null);
};

const handleReaction = useCallback(
async (account: Account | null, status: Status, reaction: ReactionInput) => {
if (!account) {
setActionError("계정을 선택해주세요.");
return;
}
if (account.platform !== "misskey") {
setActionError("리액션은 미스키 계정에서만 사용할 수 있습니다.");
return;
}
if (status.myReaction && status.myReaction !== reaction.name) {
setActionError("이미 리액션을 남겼습니다. 먼저 취소해주세요.");
return;
}
setActionError(null);
const isRemoving = status.myReaction === reaction.name;
const optimistic = buildOptimisticReactionStatus(status, reaction, isRemoving);
updateStatusEverywhere(account.id, optimistic);
try {
const updated = isRemoving
? await services.api.deleteReaction(account, status.id)
: await services.api.createReaction(account, status.id, reaction.name);
if (!hasSameReactions(updated, optimistic)) {
updateStatusEverywhere(account.id, updated);
}
} catch (err) {
setActionError(err instanceof Error ? err.message : "리액션 처리에 실패했습니다.");
updateStatusEverywhere(account.id, status);
}
},
[services.api, updateStatusEverywhere]
);

const composeAccountSelector = (
<AccountSelector
accounts={accountsState.accounts}
Expand Down Expand Up @@ -1439,8 +1574,9 @@ onAccountChange={setSectionAccount}
onAddSectionLeft={(id) => addSectionNear(id, "left")}
onAddSectionRight={(id) => addSectionNear(id, "right")}
onRemoveSection={removeSection}
onReply={handleReply}
onReply={handleReply}
onStatusClick={handleStatusClick}
onReact={handleReaction}
columnAccount={sectionAccount}
onCloseStatusModal={handleCloseStatusModal}
onError={(message) => setActionError(message || null)}
Expand Down
7 changes: 7 additions & 0 deletions src/domain/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ export type Reaction = {
host: string | null;
};

export type ReactionInput = {
name: string;
url: string | null;
isCustom: boolean;
host: string | null;
};

export type NotificationActor = {
name: string;
handle: string;
Expand Down
8 changes: 8 additions & 0 deletions src/infra/MastodonHttpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,14 @@ export class MastodonHttpClient implements MastodonApi {
return this.postAction(account, statusId, "unfavourite");
}

async createReaction(_account: Account, _statusId: string, _reaction: string): Promise<Status> {
throw new Error("리액션은 미스키 계정에서만 사용할 수 있습니다.");
}

async deleteReaction(_account: Account, _statusId: string): Promise<Status> {
throw new Error("리액션은 미스키 계정에서만 사용할 수 있습니다.");
}

async reblog(account: Account, statusId: string): Promise<Status> {
return this.postAction(account, statusId, "reblog");
}
Expand Down
13 changes: 13 additions & 0 deletions src/infra/MisskeyHttpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,19 @@ export class MisskeyHttpClient implements MastodonApi {
return this.fetchNote(account, statusId);
}

async createReaction(account: Account, statusId: string, reaction: string): Promise<Status> {
await this.postSimple(account, "/api/notes/reactions/create", {
noteId: statusId,
reaction
});
return this.fetchNote(account, statusId);
}

async deleteReaction(account: Account, statusId: string): Promise<Status> {
await this.postSimple(account, "/api/notes/reactions/delete", { noteId: statusId });
return this.fetchNote(account, statusId);
}

async reblog(account: Account, statusId: string): Promise<Status> {
await this.postSimple(account, "/api/notes/create", { renoteId: statusId });
return this.fetchNote(account, statusId);
Expand Down
8 changes: 8 additions & 0 deletions src/infra/UnifiedApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ export class UnifiedApiClient implements MastodonApi {
return this.getClient(account).unfavourite(account, statusId);
}

createReaction(account: Account, statusId: string, reaction: string) {
return this.getClient(account).createReaction(account, statusId, reaction);
}

deleteReaction(account: Account, statusId: string) {
return this.getClient(account).deleteReaction(account, statusId);
}

reblog(account: Account, statusId: string) {
return this.getClient(account).reblog(account, statusId);
}
Expand Down
2 changes: 2 additions & 0 deletions src/services/MastodonApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface MastodonApi {
deleteStatus(account: Account, statusId: string): Promise<void>;
favourite(account: Account, statusId: string): Promise<Status>;
unfavourite(account: Account, statusId: string): Promise<Status>;
createReaction(account: Account, statusId: string, reaction: string): Promise<Status>;
deleteReaction(account: Account, statusId: string): Promise<Status>;
reblog(account: Account, statusId: string): Promise<Status>;
unreblog(account: Account, statusId: string): Promise<Status>;
fetchInstanceInfo(account: Account): Promise<InstanceInfo>;
Expand Down
19 changes: 18 additions & 1 deletion src/ui/components/ComposeBox.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Account, CustomEmoji, Visibility } from "../../domain/types";
import type { MastodonApi } from "../../services/MastodonApi";
import { getCachedEmojis, setCachedEmojis } from "../utils/emojiCache";
import {
calculateCharacterCount,
getCharacterLimit,
Expand Down Expand Up @@ -300,6 +301,15 @@ export const ComposeBox = ({
if (!activeInstanceUrl) {
return;
}
const cached = getCachedEmojis(activeInstanceUrl);
if (cached) {
setEmojiCatalogs((current) => ({
...current,
[activeInstanceUrl]: cached
}));
setEmojiLoadState((current) => ({ ...current, [activeInstanceUrl]: "loaded" }));
setEmojiErrors((current) => ({ ...current, [activeInstanceUrl]: null }));
}
setRecentByInstance((current) => {
if (current[activeInstanceUrl]) {
return current;
Expand All @@ -321,11 +331,19 @@ export const ComposeBox = ({
if (emojiStatus === "loaded") {
return;
}
const cached = getCachedEmojis(activeInstanceUrl);
if (cached) {
setEmojiCatalogs((current) => ({ ...current, [activeInstanceUrl]: cached }));
setEmojiLoadState((current) => ({ ...current, [activeInstanceUrl]: "loaded" }));
setEmojiErrors((current) => ({ ...current, [activeInstanceUrl]: null }));
return;
}
setEmojiLoadState((current) => ({ ...current, [activeInstanceUrl]: "loading" }));
setEmojiErrors((current) => ({ ...current, [activeInstanceUrl]: null }));
const load = async () => {
try {
const emojis = await api.fetchCustomEmojis(account);
setCachedEmojis(activeInstanceUrl, emojis);
setEmojiCatalogs((current) => ({ ...current, [activeInstanceUrl]: emojis }));
setEmojiLoadState((current) => ({ ...current, [activeInstanceUrl]: "loaded" }));
} catch (err) {
Expand Down Expand Up @@ -764,4 +782,3 @@ export const ComposeBox = ({
);
};


Loading
Loading