import { type Dispatch, type SetStateAction, useState } from 'react';
import type { AnyProfileView, ChatBskyConvoDefs } from '@atcute/bluesky';
import type { ModerationOptions } from '@atcute/bluesky-moderation';
import { type ActiveConvoStates, useConvoActive } from '#/state/messages/convo';
import { useModerationOpts } from '#/state/moderation/moderation-opts';
import { useSession } from '#/state/session';
import type { SessionAccount } from '#/state/session/types';
import * as Dialog from '#/components/Dialog';
import { filterBlockedReactions } from '#/components/dms/util';
import * as TabScroller from '#/components/TabScroller';
import { tabLabel } from '#/components/TabScroller.css';
import { Text } from '#/components/Text';
import * as Toast from '#/components/Toast';
import { Button, ButtonIcon } from '#/components/web/Button';
import * as ProfileCard from '#/components/web/ProfileCard';
import XIcon from '#/icons/central/CrossLarge_round_outlined_radius1_stroke2.svg';
import { m } from '#/paraglide/messages';
import { space } from '#/styles/tokens.css';
import * as css from './ReactionsDialog.css';
const REACTION_ITEM_HEIGHT_ESTIMATE = 64;
type Reaction = {
key: string;
value: string;
senders: ChatBskyConvoDefs.ReactionViewSender[];
count: number;
};
type Tab = Omit<Reaction, 'senders'>;
export function ReactionsDialog({ handle }: { handle: Dialog.DialogHandle<ChatBskyConvoDefs.MessageView> }) {
return (
<Dialog.Root handle={handle}>
{({ payload }: { payload: ChatBskyConvoDefs.MessageView | undefined }) =>
payload ? (
<Dialog.Popup size="medium" label={m['components.dms.reaction.title']()} scroll="body">
<DialogInner close={() => handle.close()} message={payload} />
</Dialog.Popup>
) : null
}
</Dialog.Root>
);
}
function DialogInner({
close,
message: snapshot,
}: {
close: () => void;
message: ChatBskyConvoDefs.MessageView;
}) {
const { currentAccount } = useSession();
const convo = useConvoActive();
const moderationOpts = useModerationOpts();
const [selected, setSelected] = useState('all');
// `snapshot` is frozen from when the dialog opened; read the live message out of the convo items so
// optimistic reaction removals (e.g. "Tap to remove") reflect without reopening.
let message = snapshot;
for (const item of convo.items) {
if ((item.type === 'message' || item.type === 'pendingMessage') && item.message.id === snapshot.id) {
message = item.message;
break;
}
}
const reactions = filterBlockedReactions(message.reactions, convo.relatedProfiles);
const groupedReactions = groupReactions(reactions);
const filteredReactions = reactions
.filter((r) => selected === 'all' || r.value === selected)
// oxlint-disable-next-line unicorn/no-array-sort -- sorting the array `filter` just returned
.sort((a, b) => {
if (a.sender.did === currentAccount?.did) {
return -1;
}
if (b.sender.did === currentAccount?.did) {
return 1;
}
return 0;
});
const tabs: Tab[] = [
{ key: 'all', value: m['common.status.all'](), count: reactions.length },
...groupedReactions,
];
return (
<>
<div className={css.header}>
<Text className={css.title} numberOfLines={1} size="lg" weight="semiBold">
{m['components.dms.reaction.title']()}
</Text>
<Button
className={css.closeButton}
color="secondary"
label={m['common.action.close']()}
onClick={close}
shape="round"
size="small"
variant="ghost"
>
<ButtonIcon icon={XIcon} />
</Button>
</div>
<div className={css.tabs}>
<TabScroller.Root activeKey={selected} gutterWidth={space.lg}>
{tabs.map((tab) => (
<TabScroller.Tab
active={selected === tab.key}
aria-label={
tab.key === 'all'
? m['components.dms.reaction.a11y.showAll']()
: m['components.dms.reaction.a11y.show']({ value: tab.value })
}
key={tab.key}
onClick={() => setSelected(tab.key)}
>
<Text className={tabLabel} size="md_sub" weight="medium">
{tab.value}
</Text>
<Text className={tabLabel} size="md_sub" weight="medium">
{tab.count}
</Text>
</TabScroller.Tab>
))}
</TabScroller.Root>
</div>
<Dialog.List
className={css.list}
data={filteredReactions}
estimateHeight={REACTION_ITEM_HEIGHT_ESTIMATE}
keyExtractor={(reaction) => reaction.sender.did + '-' + reaction.value}
renderItem={({ item: reaction }) => {
const sender = convo.relatedProfiles.get(reaction.sender.did);
if (!sender || !moderationOpts) {
return null;
}
return (
<ReactionRow
allReactions={reactions}
close={close}
convo={convo}
currentAccount={currentAccount}
message={message}
moderationOpts={moderationOpts}
profile={sender}
reaction={reaction}
selected={selected}
setSelected={setSelected}
/>
);
}}
/>
</>
);
}
function ReactionRow({
allReactions,
close,
convo,
currentAccount,
message,
moderationOpts,
profile,
reaction,
selected,
setSelected,
}: {
allReactions: ChatBskyConvoDefs.ReactionView[];
close: () => void;
convo: ActiveConvoStates;
currentAccount?: SessionAccount;
message: ChatBskyConvoDefs.MessageView;
moderationOpts: ModerationOptions;
profile: AnyProfileView;
reaction: ChatBskyConvoDefs.ReactionView;
selected: string;
setSelected: Dispatch<SetStateAction<string>>;
}) {
const isFromSelf = currentAccount?.did === profile.did;
const onPress = () => {
const remainingReactions = allReactions.filter(
(r) => !(r.value === reaction.value && r.sender.did === currentAccount?.did),
);
if (remainingReactions.length === 0) {
close();
} else if (selected !== 'all' && !remainingReactions.some((r) => r.value === reaction.value)) {
// the tab we're viewing no longer exists; fall back to "all"
setSelected('all');
}
convo
.removeReaction(message.id, reaction.value)
.catch(() => Toast.show(m['components.dms.reaction.error.remove']()));
};
const inner = (
<ProfileCard.Header>
<ProfileCard.Avatar disabledPreview moderationOpts={moderationOpts} profile={profile} />
<div className={css.nameColumn}>
<ProfileCard.Handle profile={profile} />
{isFromSelf ? (
<Text color="textContrastMedium" numberOfLines={1} size="md_sub">
{m['components.dms.reaction.a11y.remove']()}
</Text>
) : (
<ProfileCard.Name moderationOpts={moderationOpts} profile={profile} />
)}
</div>
<span className={css.emojiGlyph}>{reaction.value}</span>
</ProfileCard.Header>
);
if (isFromSelf) {
return (
<button
type="button"
aria-label={m['components.dms.reaction.a11y.removeReaction']({ value: reaction.value })}
className={css.rowButton}
onClick={onPress}
>
{inner}
</button>
);
}
return <ProfileCard.Outer className={css.row}>{inner}</ProfileCard.Outer>;
}
export function groupReactions(reactions: ChatBskyConvoDefs.ReactionView[] | undefined): Reaction[] {
const grouped = new Map<string, Reaction>();
for (const reaction of reactions ?? []) {
if (!reaction) {
continue;
}
const existing = grouped.get(reaction.value);
if (existing) {
existing.senders.push(reaction.sender);
existing.count++;
} else {
grouped.set(reaction.value, {
key: reaction.value,
value: reaction.value,
senders: [reaction.sender],
count: 1,
});
}
}
return Array.from(grouped.values());
}