import {
ChecksIcon,
CircleNotchIcon,
MegaphoneIcon,
WarningCircleIcon,
} from '@phosphor-icons/react'
import classNames from 'classnames'
import {
MessageStatus,
useChannelStateContext,
useMessageContext,
} from 'stream-chat-react'
import type { OutboundClickHandler, ResolvedOutbound } from '../../types'
import { hasPaidDeliveryTag } from './MessageTag'
import { isTipMessage } from './TipMessage'
/**
* Message status stamps for sending and terminal `MessageStatus` states
* (sent / delivered / read), plus failed messages.
*
* Two reasons this is more than a static label:
*
* 1. `MessageStatus` picks its `delivered`/`read` branches ahead of `sent`
* whenever Stream has `deliveredTo`/`readBy`, and — unlike `sent` — those
* branches aren't gated to the last message. So we override all three
* terminal slots with this same label (never Stream's default delivered
* icon / read avatars — Read is out of scope) and re-apply the "latest
* message in the thread" gate ourselves.
*
* 2. We derive "latest message" from **live channel state**, not the message
* context's `lastOwnMessage`/`lastReceivedId`. Stream's `Message` is
* memoised by `areMessagePropsEqual`, which does not compare those props;
* a previous own row whose message/grouping props are unchanged (e.g.
* after an intervening incoming message) never re-renders, so the old
* bubble would keep showing "Delivered" beside the new one.
* `useChannelStateContext` gets a fresh `messages` array on every new
* message and, being a context, re-renders this leaf straight through the
* stale `Message`/`MessageStatus` memos — so the label reliably clears
* once a reply lands. We still skip non-participant rows (`system`,
* `ephemeral`, `deleted`) when finding the last real message so a trailing
* system event does not clear the label.
*
* Rendered inside stream's `.str-chat__message-status` span, which supplies
* the flex layout; icon and copy are styled via that span in `styles.css`.
*/
const useIsLatestRealMessage = () => {
const { message } = useMessageContext('SentMessageDeliveryStatus')
const { messages } = useChannelStateContext('SentMessageDeliveryStatus')
const lastMessageId = (() => {
if (!messages) {
return undefined
}
for (let index = messages.length - 1; index >= 0; index -= 1) {
const lastMessage = messages[index]
if (
lastMessage.type === 'system' ||
lastMessage.type === 'ephemeral' ||
lastMessage.type === 'deleted'
) {
continue
}
return lastMessage.id
}
return undefined
})()
return message.id === lastMessageId
}
const DeliveredStatus = () => {
if (!useIsLatestRealMessage()) return null
return (
<>
Delivered
>
)
}
const SendingStatus = () => {
if (!useIsLatestRealMessage()) return null
return (
<>
Sending
>
)
}
const OutboundStatus = ({
outbound,
onOutboundClick,
}: {
outbound: ResolvedOutbound
onOutboundClick?: OutboundClickHandler
}) => {
const name = outbound.name || 'Broadcast'
// Underline the name only in the clickable variant (it reads as a link).
// The name's colour still comes from the `.str-chat`-scoped grid rule in
// styles.css; we only add the underline here.
const isLink = Boolean(onOutboundClick)
const content = (
<>
{name}
>
)
return onOutboundClick ? (
) : (
content
)
}
const FailedStatus = () => (
Failed to send
)
/**
* Delivery status beneath the viewer's most recent sent message (MES-1036):
* a right-aligned "✓✓ Delivered" that appears once Stream confirms the
* message is stored and updates live.
*
* Built on stream-chat-react's `MessageStatus`, which owns the own-message /
* not-error gating and only surfaces a terminal (sending/sent/delivered/read)
* state once the message is stored. Stream's `threadList` guard covers
* sent/delivered/read only — not `sending` — so we suppress the whole stamp
* (including failed) in thread lists ourselves. Sending uses the same
* latest-message-in-thread gate; failed messages bypass `MessageStatus`
* because it has no failed slot and are shown on every own, non-thread
* failed message.
*
* Messages whose footer tag already reads "Delivered with $10 message" skip
* this label so delivery is only claimed once (MES-1480); failed and broadcast
* stamps still apply.
* Tips read as delivered while still pending; a failed tip still shows Failed.
*/
export const SentMessageDeliveryStatus = ({
onOutboundClick,
resolvedOutbound,
}: {
onOutboundClick?: OutboundClickHandler
resolvedOutbound?: ResolvedOutbound
}) => {
const { isMyMessage, message, threadList } = useMessageContext(
'SentMessageDeliveryStatus'
)
const isOwnMessage = isMyMessage()
const isLatestRealMessage = useIsLatestRealMessage()
// Stream's MessageStatus still mounts MessageSendingStatus when
// threadList is true; keep all of our stamps out of thread replies.
if (threadList) {
return null
}
if (message.status === 'failed' && isOwnMessage && message.type !== 'error') {
return
}
if (!isOwnMessage) {
return null
}
const outboundRecordId = message.metadata?.outbound_id
const outboundStatus =
message.status !== 'sending' &&
message.status !== 'failed' &&
outboundRecordId &&
resolvedOutbound ? (
) : null
const messageStatus = hasPaidDeliveryTag(message) ? null : (
)
if (!outboundStatus) {
return messageStatus
}
return (
{messageStatus}
{messageStatus && isLatestRealMessage && (
•
)}
{outboundStatus}
)
}