From ebfd36c1d648e85b9f3d5f6c66b7f3f9eab3df62 Mon Sep 17 00:00:00 2001 From: prism Date: Sat, 31 Jan 2026 22:33:38 +0100 Subject: [PATCH] feat(MessagePeek): add plugin (#711) * feat(MessagePeek): add plugin * bunch of fixes and improvements * add better icon for voice messages * MessagePeek: support activity * MessagePeek: support BetterActivity plugin :bleh: * Update index.tsx * bleh --------- Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com> --- src/equicordplugins/messagePeek/index.tsx | 276 ++++++++++++++++++++++ src/equicordplugins/messagePeek/style.css | 47 ++++ 2 files changed, 323 insertions(+) create mode 100644 src/equicordplugins/messagePeek/index.tsx create mode 100644 src/equicordplugins/messagePeek/style.css diff --git a/src/equicordplugins/messagePeek/index.tsx b/src/equicordplugins/messagePeek/index.tsx new file mode 100644 index 000000000..c16144299 --- /dev/null +++ b/src/equicordplugins/messagePeek/index.tsx @@ -0,0 +1,276 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./style.css"; + +import { DecoratorProps } from "@api/MemberListDecorators"; +import { isPluginEnabled } from "@api/PluginManager"; +import betterActivities from "@equicordplugins/betterActivities"; +import { Devs, EquicordDevs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; +import { classes } from "@utils/misc"; +import definePlugin from "@utils/types"; +import { Activity, ApplicationStream, Channel, Message, OnlineStatus, User } from "@vencord/discord-types"; +import { MessageFlags } from "@vencord/discord-types/enums"; +import { findByCodeLazy, findByPropsLazy, findComponentByCodeLazy, findCssClassesLazy, findExportedComponentLazy } from "@webpack"; +import { ChannelStore, MessageStore, SnowflakeUtils, UserStore, useStateFromStores } from "@webpack/common"; + +const cl = classNameFactory("vc-message-peek-"); + +const PrivateChannelClasses = findCssClassesLazy("subtext", "channel", "interactive"); +const ActivityClasses = findCssClassesLazy("textWithIconContainer", "icon", "truncated", "container", "textXs"); +const MessageActions = findByPropsLazy("fetchMessages", "sendMessage"); + +const hasRelevantActivity: (props: ActivityCheckProps) => boolean = findByCodeLazy(".OFFLINE||", ".INVISIBLE)return!1"); +const ActivityText: React.ComponentType = findComponentByCodeLazy("hasQuest:", "hideEmoji:"); + +const ONE_HOUR_MS = 60 * 60 * 1000; + +type AttachmentType = "image" | "gif" | "video" | "file"; +type IconType = AttachmentType | "voice" | "sticker"; + +const Icons: Record> = { + image: findExportedComponentLazy("ImageIcon"), + file: findExportedComponentLazy("AttachmentIcon"), + voice: findExportedComponentLazy("MicrophoneIcon"), + sticker: findExportedComponentLazy("StickerIcon"), + gif: findExportedComponentLazy("GifIcon"), + video: findExportedComponentLazy("VideoIcon"), +}; + +const ATTACHMENT_LABELS: Record = { + gif: "GIF", + image: "image", + video: "video", + file: "file" +}; + +interface ActivityCheckProps { + activities: Activity[] | null; + status: OnlineStatus; + applicationStream: ApplicationStream | null; + voiceChannel: Channel | null; +} + +interface ActivityTextProps { + user: User; + activities: Activity[] | null; + applicationStream: ApplicationStream | null; + voiceChannel: Channel | null; +} + +interface PrivateChannelProps extends ActivityCheckProps { + channel: Channel; + user: User; +} + +interface MessageContent { + text: string; + icon?: IconType; +} + +function getActivityIcons(activities: Activity[] | null, user: User): React.ReactNode { + if (!activities?.length) return null; + + if (!isPluginEnabled(betterActivities.name)) return null; + + return betterActivities.patchActivityList({ + activities, + user, + hideTooltip: false + }); +} + +function getAttachmentType(contentType = ""): AttachmentType { + if (contentType === "image/gif") return "gif"; + if (contentType.startsWith("image/")) return "image"; + if (contentType.startsWith("video/")) return "video"; + return "file"; +} + +function formatRelativeTime(timestamp: number): string { + const diff = Date.now() - timestamp; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (days >= 365) return `${Math.floor(days / 365)}y`; + if (days >= 30) return `${Math.floor(days / 30)}mo`; + if (days > 0) return `${days}d`; + if (hours > 0) return `${hours}h`; + return `${Math.max(1, minutes)}m`; +} + +function pluralize(count: number, singular: string, plural = singular + "s") { + return count === 1 ? `1 ${singular}` : `${count} ${plural}`; +} + +function getMessageContent(message: Message): MessageContent | null { + if (message.content) { + if (/https?:\/\/(\S+\.gif|tenor\.com|giphy\.com)/i.test(message.content)) { + return { text: "sent a GIF", icon: "gif" }; + } + return { text: message.content }; + } + + if (message.flags & MessageFlags.IS_VOICE_MESSAGE) { + return { text: "voice message", icon: "voice" }; + } + + if (message.attachments?.length) { + const types = message.attachments.map(a => getAttachmentType(a.content_type)); + const count = types.length; + const firstType = types[0]; + const allSameType = types.every(t => t === firstType); + + if (allSameType) { + return { text: pluralize(count, ATTACHMENT_LABELS[firstType]), icon: firstType }; + } + return { text: pluralize(count, "file"), icon: "file" }; + } + + if (message.stickerItems?.length) { + return { text: message.stickerItems[0].name, icon: "sticker" }; + } + + return null; +} + +function MessagePreviewContent({ channel }: { channel: Channel; }) { + const lastMessage = useStateFromStores( + [MessageStore], + () => MessageStore.getLastMessage(channel.id) as Message | undefined + ); + + if (channel.isSystemDM()) { + return <>Official Discord Message; + } + + if (channel.isMultiUserDM()) { + return <>{channel.recipients.length + 1} Members; + } + + if (!lastMessage) return null; + + const content = getMessageContent(lastMessage); + if (!content) return null; + + const currentUserId = UserStore.getCurrentUser()?.id; + const isOwnMessage = lastMessage.author.id === currentUserId; + const authorName = isOwnMessage ? "You" : (lastMessage.author.globalName ?? lastMessage.author.username); + const Icon = content.icon ? Icons[content.icon] : null; + + return ( +
+ {authorName}: {content.text} + {Icon && ( + + + + )} +
+ ); +} + +function SubText({ channel, user, activities, applicationStream, voiceChannel, showActivity }: PrivateChannelProps & { showActivity: boolean; }) { + if (showActivity) { + return ( + + ); + } + + const activityIcons = getActivityIcons(activities, user); + + if (activityIcons) { + return ( +
+
+ + {activityIcons} +
+
+ ); + } + + return ( +
+ +
+ ); +} + +function Timestamp({ channel }: { channel: Channel; }) { + const lastMessage = useStateFromStores( + [MessageStore], + () => MessageStore.getLastMessage(channel.id) as Message | undefined + ); + + if (!lastMessage) return null; + + const timestamp = SnowflakeUtils.extractTimestamp(lastMessage.id); + return {formatRelativeTime(timestamp)}; +} + +function shouldShowActivity(lastMessage: Message | undefined, hasActivity: boolean): boolean { + if (!hasActivity) return false; + if (!lastMessage) return true; + + const messageTimestamp = SnowflakeUtils.extractTimestamp(lastMessage.id); + return Date.now() - messageTimestamp > ONE_HOUR_MS; +} + +export default definePlugin({ + name: "MessagePeek", + description: "Shows the last message preview and timestamp in the Direct Messages list.", + authors: [Devs.prism, EquicordDevs.justjxke], + patches: [ + { + find: "PrivateChannel.renderAvatar", + replacement: { + match: /,subText:(\i)\.isSystemDM\(\).{0,500}:null,(?=name:)/, + replace: ",subText:$self.getSubText(arguments[0])," + } + } + ], + + async start() { + const channels = ChannelStore.getSortedPrivateChannels(); + for (const channel of channels) { + if (!MessageStore.getLastMessage(channel.id)) { + await MessageActions.fetchMessages({ channelId: channel.id, limit: 1 }); + } + } + }, + + renderMemberListDecorator({ channel }: DecoratorProps) { + if (!channel) return null; + return ; + }, + + getSubText(props: PrivateChannelProps) { + const { channel, user, activities, status, applicationStream, voiceChannel } = props; + const lastMessage = MessageStore.getLastMessage(channel.id) as Message | undefined; + const hasActivity = hasRelevantActivity({ activities, status, applicationStream, voiceChannel }); + const showActivity = shouldShowActivity(lastMessage, hasActivity); + + return ( + + ); + } +}); diff --git a/src/equicordplugins/messagePeek/style.css b/src/equicordplugins/messagePeek/style.css new file mode 100644 index 000000000..ec4c06db8 --- /dev/null +++ b/src/equicordplugins/messagePeek/style.css @@ -0,0 +1,47 @@ +.vc-message-peek-timestamp { + position: absolute; + top: 8px; + right: 8px; + font-size: 12px; + font-weight: 500; + line-height: 16px; + color: var(--text-normal); +} + +[class*="interactiveSelected__"] .vc-message-peek-timestamp { + color: var(--white); +} + +[class*="interactive__"]:hover .vc-message-peek-timestamp { + display: none; +} + +.vc-message-peek-preview { + display: flex; + align-items: center; + gap: 4px; +} + +.vc-message-peek-icon { + display: flex; + margin-top: 0.8px; +} + +[class*="interactive__"]:hover .vc-message-peek-icon svg path { + fill: var(--white) !important; +} + +.vc-message-peek-activity-row { + display: flex; + align-items: center; + gap: 4px; +} + +.vc-message-peek-activity-row>[class*="textXs"] { + flex: 1; + min-width: 0; +} + +.vc-message-peek-activity-row>[class*="vc-bactivities-row"] { + flex-shrink: 0; +}