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>
This commit is contained in:
prism 2026-01-31 22:33:38 +01:00 • committed by GitHub
parent 76f06b9f96
commit ebfd36c1d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 323 additions and 0 deletions

View file

@ -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<ActivityTextProps> = findComponentByCodeLazy("hasQuest:", "hideEmoji:");
const ONE_HOUR_MS = 60 * 60 * 1000;
type AttachmentType = "image" | "gif" | "video" | "file";
type IconType = AttachmentType | "voice" | "sticker";
const Icons: Record<IconType, React.ComponentType<{ size: string; className: string; }>> = {
image: findExportedComponentLazy("ImageIcon"),
file: findExportedComponentLazy("AttachmentIcon"),
voice: findExportedComponentLazy("MicrophoneIcon"),
sticker: findExportedComponentLazy("StickerIcon"),
gif: findExportedComponentLazy("GifIcon"),
video: findExportedComponentLazy("VideoIcon"),
};
const ATTACHMENT_LABELS: Record<AttachmentType, string> = {
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 (
<div className={classes(ActivityClasses.container, ActivityClasses.textXs, cl("preview"))}>
<span className={ActivityClasses.truncated}>{authorName}: {content.text}</span>
{Icon && (
<span className={cl("icon")}>
<Icon size="xxs" className={ActivityClasses.icon} />
</span>
)}
</div>
);
}
function SubText({ channel, user, activities, applicationStream, voiceChannel, showActivity }: PrivateChannelProps & { showActivity: boolean; }) {
if (showActivity) {
return (
<ActivityText
user={user}
activities={activities}
voiceChannel={voiceChannel}
applicationStream={applicationStream}
/>
);
}
const activityIcons = getActivityIcons(activities, user);
if (activityIcons) {
return (
<div className={PrivateChannelClasses.subtext}>
<div className={cl("activity-row")}>
<MessagePreviewContent channel={channel} />
{activityIcons}
</div>
</div>
);
}
return (
<div className={PrivateChannelClasses.subtext}>
<MessagePreviewContent channel={channel} />
</div>
);
}
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 <span className={cl("timestamp")}>{formatRelativeTime(timestamp)}</span>;
}
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 <Timestamp channel={channel} />;
},
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 (
<SubText
channel={channel}
user={user}
activities={activities}
status={status}
applicationStream={applicationStream}
voiceChannel={voiceChannel}
showActivity={showActivity}
/>
);
}
});

View file

@ -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;
}