149 lines
4.8 KiB
TypeScript
149 lines
4.8 KiB
TypeScript
import { memo } from 'react';
|
|
import type { ReactElement } from 'react';
|
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
import type { Message } from '@altricade/core';
|
|
import { OPTIMISTIC_SEQ } from '@altricade/core';
|
|
import { useTheme } from '@/theme';
|
|
import { spacing, radius, fontSize } from '@/theme';
|
|
import { MessageMeta } from './MessageMeta';
|
|
import type { SendStatus } from './MessageMeta';
|
|
import { ReplyQuote } from './ReplyQuote';
|
|
import { ForwardedHeader } from './ForwardedHeader';
|
|
import { Reactions } from './Reactions';
|
|
import { MediaContent } from './MediaContent';
|
|
|
|
export interface BubbleCallbacks {
|
|
onLongPress: (message: Message) => void;
|
|
onReplyPress: (messageId: string) => void;
|
|
onToggleReaction: (message: Message, emoji: string) => void;
|
|
onOpenViewer: (url: string, isVideo: boolean) => void;
|
|
onPressUser: (username: string) => void;
|
|
}
|
|
|
|
interface Props extends BubbleCallbacks {
|
|
message: Message;
|
|
meId: string;
|
|
isGroup: boolean;
|
|
peerReadSeq: number;
|
|
highlighted: boolean;
|
|
}
|
|
|
|
const statusOf = (message: Message, outgoing: boolean, peerReadSeq: number): SendStatus => {
|
|
if (message.seq === OPTIMISTIC_SEQ) return 'pending';
|
|
if (outgoing && peerReadSeq >= message.seq) return 'read';
|
|
return 'sent';
|
|
};
|
|
|
|
const MessageBubbleInner = ({
|
|
message,
|
|
meId,
|
|
isGroup,
|
|
peerReadSeq,
|
|
highlighted,
|
|
onLongPress,
|
|
onReplyPress,
|
|
onToggleReaction,
|
|
onOpenViewer,
|
|
onPressUser,
|
|
}: Props): ReactElement => {
|
|
const { colors } = useTheme();
|
|
const outgoing = message.senderId === meId;
|
|
const deleted = message.deletedAt !== null;
|
|
const showSender = isGroup && !outgoing && !deleted;
|
|
const bubbleColor = outgoing ? colors.bubbleOut : colors.bubbleIn;
|
|
const textColor = outgoing ? colors.onAccent : colors.text;
|
|
const hasText = message.content.length > 0 && !deleted;
|
|
|
|
return (
|
|
<View style={[styles.line, outgoing ? styles.lineOut : styles.lineIn]}>
|
|
<Pressable
|
|
onLongPress={() => {
|
|
if (!deleted) {
|
|
onLongPress(message);
|
|
}
|
|
}}
|
|
delayLongPress={280}
|
|
style={[
|
|
styles.bubble,
|
|
{ backgroundColor: bubbleColor, borderColor: colors.border },
|
|
outgoing ? styles.bubbleOut : styles.bubbleIn,
|
|
highlighted && { borderColor: colors.accent, borderWidth: 2 },
|
|
]}
|
|
>
|
|
{message.forwarded ? (
|
|
<ForwardedHeader origin={message.forwardedFrom} outgoing={outgoing} onPressUser={onPressUser} />
|
|
) : null}
|
|
{showSender ? (
|
|
<Text style={[styles.sender, { color: colors.accent }]} numberOfLines={1}>
|
|
{message.sender.displayName}
|
|
</Text>
|
|
) : null}
|
|
{message.replyTo !== null ? (
|
|
<ReplyQuote
|
|
reply={message.replyTo}
|
|
outgoing={outgoing}
|
|
onPress={() => {
|
|
if (message.replyTo !== null) {
|
|
onReplyPress(message.replyTo.id);
|
|
}
|
|
}}
|
|
/>
|
|
) : null}
|
|
{message.media !== null && !deleted ? (
|
|
<View style={styles.media}>
|
|
<MediaContent
|
|
conversationId={message.conversationId}
|
|
messageId={message.id}
|
|
media={message.media}
|
|
outgoing={outgoing}
|
|
onOpenViewer={onOpenViewer}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
{deleted ? (
|
|
<Text style={[styles.deleted, { color: outgoing ? colors.onAccentMuted : colors.textFaint }]}>
|
|
Message deleted
|
|
</Text>
|
|
) : hasText ? (
|
|
<Text style={[styles.text, { color: textColor }]}>{message.content}</Text>
|
|
) : null}
|
|
<MessageMeta
|
|
createdAt={message.createdAt}
|
|
edited={message.editedAt !== null}
|
|
outgoing={outgoing}
|
|
status={statusOf(message, outgoing, peerReadSeq)}
|
|
/>
|
|
</Pressable>
|
|
{!deleted ? (
|
|
<Reactions
|
|
reactions={message.reactions}
|
|
outgoing={outgoing}
|
|
onToggle={(emoji) => {
|
|
onToggleReaction(message, emoji);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export const MessageBubble = memo(MessageBubbleInner);
|
|
|
|
const styles = StyleSheet.create({
|
|
line: { paddingHorizontal: spacing.md, marginVertical: 2, maxWidth: '100%' },
|
|
lineOut: { alignItems: 'flex-end' },
|
|
lineIn: { alignItems: 'flex-start' },
|
|
bubble: {
|
|
maxWidth: '82%',
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.sm,
|
|
borderRadius: radius.lg,
|
|
gap: 2,
|
|
},
|
|
bubbleOut: { borderBottomRightRadius: radius.sm },
|
|
bubbleIn: { borderBottomLeftRadius: radius.sm, borderWidth: StyleSheet.hairlineWidth },
|
|
sender: { fontSize: fontSize.sm, fontWeight: '700', marginBottom: 1 },
|
|
text: { fontSize: fontSize.base, lineHeight: 21 },
|
|
deleted: { fontSize: fontSize.base, fontStyle: 'italic' },
|
|
media: { marginBottom: 2 },
|
|
});
|