// SPDX-License-Identifier: AGPL-3.0-or-later import type {Channel} from '@app/features/channel/models/Channel'; import Emoji from '@app/features/emoji/state/Emoji'; import styles from '@app/features/forum/components/Forum.module.css'; import {ForumPostMenuSheet, openForumPostContextMenu} from '@app/features/forum/components/ForumPostContextMenu'; import {ForumEmoji, ForumTagPill} from '@app/features/forum/components/ForumTagPill'; import ForumPosts from '@app/features/forum/state/ForumPosts'; import ForumReadState from '@app/features/forum/state/ForumReadState'; import {getDefaultReaction, getForumTags} from '@app/features/forum/utils/ForumChannelUtils'; import * as D from '@app/features/forum/utils/ForumMessageDescriptors'; import {isPinnedPost} from '@app/features/forum/utils/ForumPostList'; import {MESSAGE_COUNT_DESCRIPTOR as D_POST_COUNT_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import * as ReactionCommands from '@app/features/messaging/commands/ReactionCommands'; import {Message} from '@app/features/messaging/models/MessagingMessage'; import {buildFitInsideMediaProxyURL, resolvePreferredImageFormat} from '@app/features/messaging/utils/MediaProxyUtils'; import Navigation from '@app/features/navigation/state/Navigation'; import {buildMessageNotificationBody} from '@app/features/notification/utils/MessageNotificationPreview'; import Permission from '@app/features/permissions/state/Permission'; import ReadStates from '@app/features/read_state/state/ReadStates'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {openThread} from '@app/features/threads/commands/ThreadNavigation'; import {lastActivityId} from '@app/features/threads/state/ChannelThreads'; import ThreadMemberships from '@app/features/threads/state/ThreadMemberships'; import FocusRing from '@app/features/ui/focus_ring/FocusRing'; import MobileLayout from '@app/features/ui/state/MobileLayout'; import Users from '@app/features/user/state/Users'; import * as DateUtils from '@app/features/user/utils/DateFormatting'; import * as NicknameUtils from '@app/features/user/utils/NicknameUtils'; import {MessageAttachmentFlags, Permissions} from '@fluxer/constants/src/ChannelConstants'; import type {DefaultReactionEmojiResponse} from '@fluxer/schema/src/domains/channel/ThreadSchemas'; import type { MessageAttachment, Message as WireMessage, } from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils'; import {useLingui} from '@lingui/react/macro'; import {ChatCircleIcon, PlayIcon, PushPinSimpleIcon} from '@phosphor-icons/react'; import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; import type React from 'react'; import {useCallback, useMemo, useState} from 'react'; const MAX_CARD_TAGS = 3; const LIST_THUMBNAIL_SIZE = 96; const GRID_THUMBNAIL_SIZE = 480; function isVisualAttachment(attachment: MessageAttachment): boolean { const type = attachment.content_type ?? ''; return ( (type.startsWith('image/') || type.startsWith('video/')) && (attachment.flags & MessageAttachmentFlags.IS_SPOILER) === 0 && !attachment.nsfw && !attachment.expired ); } function firstMedia(message: WireMessage | null | undefined): {src: string; video: boolean} | null { const attachment = message?.attachments?.find(isVisualAttachment); const source = attachment?.proxy_url ?? attachment?.url; if (!attachment || !source) return null; return {src: source, video: (attachment.content_type ?? '').startsWith('video/')}; } function defaultReactionState( message: WireMessage | null | undefined, reaction: DefaultReactionEmojiResponse, ): {count: number; me: boolean} { const match = message?.reactions?.find((entry) => reaction.emoji_id != null ? entry.emoji.id === reaction.emoji_id : entry.emoji.name === reaction.emoji_name, ); return {count: match?.count ?? 0, me: match?.me ?? false}; } const DefaultReactionButton = observer( ({forum, post, message}: {forum: Channel; post: Channel; message: WireMessage | null | undefined}) => { const {i18n} = useLingui(); const reaction = getDefaultReaction(forum); if (!reaction || !message) return null; const state = defaultReactionState(message, reaction); const canReact = Permission.can(Permissions.ADD_REACTIONS, forum) || state.me || state.count > 0; const handleClick = (event: React.MouseEvent) => { event.stopPropagation(); if (!canReact) return; const emoji = { id: reaction.emoji_id, name: reaction.emoji_name ?? (reaction.emoji_id ? (Emoji.getEmojiById(reaction.emoji_id)?.name ?? 'emoji') : ''), }; if (state.me) { ReactionCommands.removeReaction(i18n, post.id, message.id, emoji); } else { ReactionCommands.addReaction(i18n, post.id, message.id, emoji); } }; return ( ); }, ); interface ForumPostCardProps { forum: Channel; post: Channel; grid: boolean; } export const ForumPostCard = observer(({forum, post, grid}: ForumPostCardProps) => { const {i18n} = useLingui(); const [sheetOpen, setSheetOpen] = useState(false); const firstMessage = ForumPosts.getFirstMessage(post.id); const preview = useMemo(() => { if (firstMessage === undefined) return null; if (firstMessage === null) return i18n._(D.ORIGINAL_MESSAGE_DELETED_DESCRIPTOR); const record = new Message(firstMessage, {skipReactionHydration: true}); return buildMessageNotificationBody(record, i18n); }, [firstMessage, i18n]); const media = firstMedia(firstMessage); const authorId = firstMessage?.author.id ?? post.ownerId; const author = authorId ? Users.getUser(authorId) : undefined; const authorName = author ? NicknameUtils.getNickname(author, forum.guildId, forum.id) : null; const tags = getForumTags(forum, post.appliedTags); const joined = ThreadMemberships.isMember(post.id); const postUnreadCount = joined ? 0 : ForumReadState.getPostUnreadCount(post.id); const unread = (joined && ReadStates.hasUnread(post.id)) || postUnreadCount > 0; const unreadCount = joined && unread ? ReadStates.getUnreadCount(post.id) : postUnreadCount; const isNew = !unread && ForumReadState.isNewPost(post); const pinned = isPinnedPost(post); const open = Navigation.threadId === post.id; const lastActivity = new Date(SnowflakeUtils.extractTimestamp(lastActivityId(post))); const handleOpen = useCallback(() => openThread(post), [post]); const handleContextMenu = useCallback( (event: React.MouseEvent) => { if (MobileLayout.enabled) { event.preventDefault(); setSheetOpen(true); return; } openForumPostContextMenu(event, post); }, [post], ); const thumbnail = media ? buildFitInsideMediaProxyURL(media.src, { format: resolvePreferredImageFormat(), width: grid ? GRID_THUMBNAIL_SIZE : LIST_THUMBNAIL_SIZE, height: grid ? GRID_THUMBNAIL_SIZE : LIST_THUMBNAIL_SIZE, }) : null; return ( <>
{ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); handleOpen(); } }} onContextMenu={handleContextMenu} data-flx="forum.forum-post-card.card.open" > {grid && (
{thumbnail ? ( ) : (
)} {media?.video && ( )}
)}
{pinned && ( )} {isNew && ( {i18n._(D.NEW_BADGE_DESCRIPTOR)} )} {tags.slice(0, MAX_CARD_TAGS).map((tag) => ( ))} {tags.length > MAX_CARD_TAGS && ( {`+${tags.length - MAX_CARD_TAGS}`} )}
{post.name}
{preview != null && (
{authorName && ( {`${authorName}: `} )} {preview}
)}
{i18n._(D_POST_COUNT_DESCRIPTOR, {count: post.messageCount})} {unread && ( {unreadCount > 0 ? i18n._(D.UNREAD_MESSAGES_DESCRIPTOR, {count: unreadCount}) : i18n._(D.HAS_UNREAD_MESSAGES_DESCRIPTOR)} )} {DateUtils.getRelativeDateString(lastActivity, i18n)}
{!grid && thumbnail && (
{media?.video && ( )}
)}
{sheetOpen && ( setSheetOpen(false)} data-flx="forum.forum-post-card.forum-post-menu-sheet" /> )} ); });