/* eslint-disable react-hooks/set-state-in-effect -- pre-existing pattern flagged by the React Compiler rules added in eslint-plugin-react-hooks v7; refactoring it is out of scope for the lint toolchain upgrade. */ import { useEffect, useState } from 'react' import type { Channel as ChannelType, ChannelMemberResponse } from 'stream-chat' import { useMessagingContext } from '../providers/MessagingProvider' // Blocked user from Stream Chat API type BlockedUser = { blocked_user_id: string } const REPORT_URL = 'https://linktr.ee/s/about/trust-center/report' export interface UseChannelModerationActionsParams { channel: ChannelType participant: ChannelMemberResponse | undefined /** * When false, the blocked-status lookup is skipped (the result would be * unused, e.g. the Linktree official channel does not offer blocking). * Defaults to true. */ showBlockParticipant?: boolean /** * When false, the blocked-status lookup is deferred. Useful for surfaces * that mount the actions ahead of time (e.g. a closed popover) and only * need block state once visible. Defaults to true. */ enabled?: boolean onLeaveConversation?: (channel: ChannelType) => void onBlockParticipant?: (participantId?: string) => void onDeleteConversationClick?: () => void onBlockParticipantClick?: () => void onReportParticipantClick?: () => void /** * Called after an action completes successfully (e.g. to close the * surrounding dialog or popover). */ onActionComplete?: () => void /** Prefix used for debug logging. */ logLabel?: string } export interface ChannelModerationActions { isParticipantBlocked: boolean /** * True while the initial blocked-status lookup is in flight. Until this is * false, `isParticipantBlocked` has not yet been resolved and the * block/unblock action should not be acted on. */ isCheckingBlockedStatus: boolean isLeaving: boolean isUpdatingBlockStatus: boolean handleLeaveConversation: () => Promise handleBlockUser: () => Promise handleUnblockUser: () => Promise handleReportUser: () => void } /** * Encapsulates the conversation moderation actions (leave/delete, block, * unblock, report) shared by the channel info dialog and the channel actions * popover menu. Keeping the logic in one place ensures both surfaces stay in * sync. */ export const useChannelModerationActions = ({ channel, participant, showBlockParticipant = true, enabled = true, onLeaveConversation, onBlockParticipant, onDeleteConversationClick, onBlockParticipantClick, onReportParticipantClick, onActionComplete, logLabel = 'useChannelModerationActions', }: UseChannelModerationActionsParams): ChannelModerationActions => { const { service, debug } = useMessagingContext() const participantId = participant?.user?.id const willLookup = Boolean( enabled && showBlockParticipant && service && participantId ) const [isParticipantBlocked, setIsParticipantBlocked] = useState(false) // Tracks the participant + service the most recent lookup has completed // for. Keying on both so a service swap (e.g. MessagingProvider rebuilding // its StreamChatService when config/apiKey/debug changes) re-triggers the // loading state for the same participant — otherwise the menu would // briefly show actionable Block/Unblock against the new service with the // old service's blocked result. Computing the flag at render time — rather // than from a useState updated inside useEffect — also closes the brief // window where the menu would render the regular (enabled) Block button // before the effect flipped the disabled placeholder on. const [resolvedFor, setResolvedFor] = useState<{ participantId: string service: unknown } | null>(null) const [isLeaving, setIsLeaving] = useState(false) const [isUpdatingBlockStatus, setIsUpdatingBlockStatus] = useState(false) const isCheckingBlockedStatus = willLookup && (resolvedFor?.participantId !== participantId || resolvedFor?.service !== service) // Resolve whether the participant is blocked whenever the participant or // surface visibility changes. useEffect(() => { // When the lookup is skipped (Block action hidden, surface disabled, or no // participant), clear any stale blocked state so a previous participant's // value can't leak into the next conversation. if (!willLookup || !service || !participantId) { setIsParticipantBlocked(false) setResolvedFor(null) return } let cancelled = false void (async () => { try { const blockedUsers = await service.getBlockedUsers() if (cancelled) return setIsParticipantBlocked( blockedUsers.some( (user: BlockedUser) => user.blocked_user_id === participantId ) ) } catch (error) { if (!cancelled) { console.error(`[${logLabel}] Failed to check blocked status:`, error) } } finally { // Mark the lookup as resolved regardless of success/failure so a // rejected `getBlockedUsers()` doesn't leave the menu stuck in the // disabled-spinner state. On failure the blocked flag stays at its // default (false), matching the prior behavior — the user can attempt // to block/unblock and the server rejects if the state is wrong. if (!cancelled) setResolvedFor({ participantId, service }) } })() // Ignore an in-flight result if the participant/surface changes first. return () => { cancelled = true } }, [willLookup, service, participantId, logLabel]) const handleLeaveConversation = async () => { if (isLeaving) return // Fire analytics callback before action onDeleteConversationClick?.() if (debug) { console.log(`[${logLabel}] Leave conversation`, channel.cid) } setIsLeaving(true) try { const actingUserId = channel._client?.userID ?? null await channel.hide(actingUserId, false) if (onLeaveConversation) { await onLeaveConversation(channel) } onActionComplete?.() } catch (error) { console.error(`[${logLabel}] Failed to leave conversation`, error) } finally { setIsLeaving(false) } } const handleBlockUser = async () => { if (isUpdatingBlockStatus || !service) return // Fire analytics callback before action onBlockParticipantClick?.() if (debug) { console.log(`[${logLabel}] Block member`, participant?.user?.id) } setIsUpdatingBlockStatus(true) try { await service.blockUser(participant?.user?.id) if (onBlockParticipant) { await onBlockParticipant(participant?.user?.id) } onActionComplete?.() } catch (error) { console.error(`[${logLabel}] Failed to block member`, error) } finally { setIsUpdatingBlockStatus(false) } } const handleUnblockUser = async () => { if (isUpdatingBlockStatus || !service) return // Fire analytics callback before action onBlockParticipantClick?.() if (debug) { console.log(`[${logLabel}] Unblock member`, participant?.user?.id) } setIsUpdatingBlockStatus(true) try { await service.unBlockUser(participant?.user?.id) if (onBlockParticipant) { await onBlockParticipant(participant?.user?.id) } onActionComplete?.() } catch (error) { console.error(`[${logLabel}] Failed to unblock member`, error) } finally { setIsUpdatingBlockStatus(false) } } const handleReportUser = () => { // Fire analytics callback before action onReportParticipantClick?.() onActionComplete?.() window.open(REPORT_URL, '_blank', 'noopener,noreferrer') } return { isParticipantBlocked, isCheckingBlockedStatus, isLeaving, isUpdatingBlockStatus, handleLeaveConversation, handleBlockUser, handleUnblockUser, handleReportUser, } }