Files
thelounge/client/components/Message.vue
2026-05-02 13:56:38 -07:00

431 lines
12 KiB
Vue

<template>
<div
:id="'msg-' + message.id"
:class="[
'msg',
{
self: message.self,
highlight: message.highlight || focused,
'previous-source': isPreviousSource,
multiline: message.multiline,
},
]"
:data-type="message.type"
:data-command="message.command"
:data-from="message.from && message.from.nick"
:data-msgid="message.msgid"
@touchstart.passive="onTouchStart"
@touchend="onTouchEnd"
@touchmove="onTouchCancel"
@touchcancel="onTouchCancel"
>
<span
aria-hidden="true"
:aria-label="messageTimeLocale"
class="time tooltipped tooltipped-e"
>{{ `${messageTime}&#32;` }}
</span>
<template v-if="message.type === 'unhandled'">
<span class="from">[{{ message.command }}]</span>
<span class="content">
<span v-for="(param, id) in message.params" :key="id">{{
`&#32;${param}&#32;`
}}</span>
</span>
</template>
<template v-else-if="isAction()">
<span class="from"><span class="only-copy" aria-hidden="true">***&nbsp;</span></span>
<component :is="messageComponent" :network="network" :message="message" />
</template>
<template v-else-if="message.type === 'action'">
<span class="from"><span class="only-copy">*&nbsp;</span></span>
<span class="content" dir="auto">
<ReplyContext
v-if="message.replyTo"
:message="message"
:parent-in-history="parentInHistory"
@scroll-to-parent="scrollToParent"
/>
<StatusmsgMarker :group="message.statusmsgGroup" />
<Username
:user="message.from"
:network="network"
:channel="channel"
dir="auto"
/>&#32;<ParsedMessage :message="message" />
<LinkPreview
v-for="preview in message.previews"
:key="preview.link"
:keep-scroll-position="keepScrollPosition"
:link="preview"
:channel="channel"
/>
<MessageReactions
v-if="message.reactions && message.msgid"
:reactions="message.reactions"
:my-nick="network.nick"
:read-only="!canReact"
@toggle="onToggleChip"
/>
</span>
</template>
<template v-else>
<span v-if="message.type === 'message'" class="from">
<template v-if="message.from && message.from.nick">
<span class="only-copy" aria-hidden="true">&lt;</span>
<Username :user="message.from" :network="network" :channel="channel" />
<span class="only-copy" aria-hidden="true">&gt;&nbsp;</span>
</template>
</span>
<span v-else-if="message.type === 'plugin'" class="from">
<template v-if="message.from && message.from.nick">
<span class="only-copy" aria-hidden="true">[</span>
{{ message.from.nick }}
<span class="only-copy" aria-hidden="true">]&nbsp;</span>
</template>
</span>
<span v-else class="from">
<template v-if="message.from && message.from.nick">
<span class="only-copy" aria-hidden="true">-</span>
<Username :user="message.from" :network="network" :channel="channel" />
<span class="only-copy" aria-hidden="true">-&nbsp;</span>
</template>
</span>
<span class="content" dir="auto">
<ReplyContext
v-if="message.replyTo"
:message="message"
:parent-in-history="parentInHistory"
@scroll-to-parent="scrollToParent"
/>
<span
v-if="message.showInActive"
aria-label="This message was shown in your active channel"
class="msg-shown-in-active tooltipped tooltipped-e"
><span></span
></span>
<StatusmsgMarker :group="message.statusmsgGroup" />
<ParsedMessage :network="network" :message="message" />
<LinkPreview
v-for="preview in message.previews"
:key="preview.link"
:keep-scroll-position="keepScrollPosition"
:link="preview"
:channel="channel"
/>
<MessageReactions
v-if="message.reactions && message.msgid"
:reactions="message.reactions"
:my-nick="network.nick"
:read-only="!canReact"
@toggle="onToggleChip"
/>
</span>
</template>
<div
v-if="(canReply || canReact) && message.msgid"
class="msg-actions"
:class="{'msg-actions-open': pickerOpen}"
>
<span
v-if="canReact"
class="tooltipped tooltipped-w tooltipped-no-touch"
aria-label="Add reaction"
><button
ref="reactBtnEl"
class="msg-action-react"
aria-label="Add reaction"
:aria-expanded="pickerOpen"
:aria-controls="pickerOpen ? pickerId : undefined"
@click.stop="togglePicker"
/></span>
<span
v-if="canReply"
class="tooltipped tooltipped-w tooltipped-no-touch"
aria-label="Reply"
><button class="msg-action-reply" aria-label="Reply" @click.stop="startReply"
/></span>
<EmojiPicker v-if="pickerOpen" ref="pickerEl" :id="pickerId" @pick="onPickEmoji" />
</div>
</div>
</template>
<script lang="ts">
import {computed, defineComponent, onBeforeUnmount, PropType, ref} from "vue";
import dayjs from "dayjs";
import constants from "../js/constants";
import localetime from "../js/helpers/localetime";
import eventbus from "../js/eventbus";
import Username from "./Username.vue";
import LinkPreview from "./LinkPreview.vue";
import ParsedMessage from "./ParsedMessage.vue";
import ReplyContext from "./ReplyContext.vue";
import MessageReactions from "./MessageReactions.vue";
import EmojiPicker from "./EmojiPicker.vue";
import MessageTypes from "./MessageTypes";
import StatusmsgMarker from "./StatusmsgMarker.vue";
import socket from "../js/socket";
import type {ClientChan, ClientMessage, ClientNetwork} from "../js/types";
import {MessageType} from "../../shared/types/msg";
import {useStore} from "../js/store";
MessageTypes.ParsedMessage = ParsedMessage;
MessageTypes.LinkPreview = LinkPreview;
MessageTypes.Username = Username;
export default defineComponent({
name: "Message",
components: {
...MessageTypes,
ReplyContext,
MessageReactions,
EmojiPicker,
StatusmsgMarker,
},
props: {
message: {type: Object as PropType<ClientMessage>, required: true},
channel: {type: Object as PropType<ClientChan>, required: false},
network: {type: Object as PropType<ClientNetwork>, required: true},
keepScrollPosition: Function as PropType<() => void>,
isPreviousSource: Boolean,
focused: Boolean,
},
setup(props) {
const store = useStore();
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let highlightTimer: ReturnType<typeof setTimeout> | null = null;
const timeFormat = computed(() => {
let format: keyof typeof constants.timeFormats;
if (store.state.settings.use12hClock) {
format = store.state.settings.showSeconds ? "msg12hWithSeconds" : "msg12h";
} else {
format = store.state.settings.showSeconds ? "msgWithSeconds" : "msgDefault";
}
return constants.timeFormats[format];
});
const messageTime = computed(() => {
return dayjs(props.message.time).format(timeFormat.value);
});
const messageTimeLocale = computed(() => {
return localetime(props.message.time);
});
const messageComponent = computed(() => {
return "message-" + (props.message.type || "invalid"); // TODO: force existence of type in sharedmsg
});
const canReply = computed(() => {
if (!props.network.serverOptions.supportsReply) {
return false;
}
const t = props.message.type;
return (
t === MessageType.MESSAGE || t === MessageType.ACTION || t === MessageType.NOTICE
);
});
const canReact = computed(() => {
return canReply.value && props.network.serverOptions.supportsReact;
});
const pickerOpen = ref(false);
const pickerEl = ref<{$el: HTMLElement} | null>(null);
const reactBtnEl = ref<HTMLButtonElement | null>(null);
const pickerId = computed(() => `emoji-picker-${props.message.msgid ?? ""}`);
const onDocumentMouseDown = (e: MouseEvent) => {
const root = pickerEl.value?.$el;
if (root && !root.contains(e.target as Node)) {
closePicker();
}
};
const onEscape = () => closePicker(true);
const togglePicker = () => {
pickerOpen.value = !pickerOpen.value;
if (pickerOpen.value) {
document.addEventListener("mousedown", onDocumentMouseDown);
eventbus.on("escapekey", onEscape);
} else {
document.removeEventListener("mousedown", onDocumentMouseDown);
eventbus.off("escapekey", onEscape);
}
};
const closePicker = (restoreFocus = false) => {
if (!pickerOpen.value) {
return;
}
pickerOpen.value = false;
document.removeEventListener("mousedown", onDocumentMouseDown);
eventbus.off("escapekey", onEscape);
if (restoreFocus) {
reactBtnEl.value?.focus();
}
};
const sendReaction = (reaction: string, action: "react" | "unreact") => {
if (!props.message.msgid || !props.channel) {
return;
}
socket.emit("react", {
target: props.channel.id,
msgid: props.message.msgid,
reaction,
action,
});
};
const onPickEmoji = (emoji: string) => {
// Dismiss the toolbar entirely after a pick — keeping focus on the
// React button would trap the toolbar visible via :focus-within.
closePicker();
(document.activeElement as HTMLElement | null)?.blur();
const myNick = props.network.nick;
const existing = props.message.reactions?.[emoji];
const alreadyReacted = !!existing && existing.includes(myNick);
sendReaction(emoji, alreadyReacted ? "unreact" : "react");
};
const onToggleChip = (reaction: string) => {
const myNick = props.network.nick;
const existing = props.message.reactions?.[reaction];
const mine = !!existing && existing.includes(myNick);
sendReaction(reaction, mine ? "unreact" : "react");
};
const parentInHistory = computed(() => {
if (!props.message.replyTo || !props.channel) {
return false;
}
return props.channel.messages.some((m) => m.msgid === props.message.replyTo);
});
const isAction = () => {
if (!props.message.type) {
return false;
}
return typeof MessageTypes["message-" + props.message.type] !== "undefined";
};
const startReply = () => {
eventbus.emit("reply:start", {
msgid: props.message.msgid,
nick: props.message.from?.nick,
text: props.message.text,
});
};
const scrollToParent = () => {
if (!props.message.replyTo) {
return;
}
const el = document.querySelector(
`.msg[data-msgid="${CSS.escape(props.message.replyTo)}"]`
);
if (el) {
const wasHighlighted = el.classList.contains("highlight");
el.scrollIntoView({block: "center", behavior: "smooth"});
el.classList.add("highlight");
if (highlightTimer !== null) {
clearTimeout(highlightTimer);
}
highlightTimer = setTimeout(() => {
highlightTimer = null;
// We don't want to reset e.g. pings/keyword matches
if (!wasHighlighted) {
el.classList.remove("highlight");
}
}, 2000);
}
};
const onTouchStart = () => {
if (props.message.msgid && canReply.value) {
longPressTimer = setTimeout(() => {
longPressTimer = null;
startReply();
}, 500);
}
};
const clearLongPress = () => {
if (longPressTimer !== null) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
};
const onTouchEnd = (e: TouchEvent) => {
if (longPressTimer === null && props.message.msgid && canReply.value) {
e.preventDefault();
}
clearLongPress();
};
const onTouchCancel = () => {
clearLongPress();
};
onBeforeUnmount(() => {
clearLongPress();
closePicker();
if (highlightTimer !== null) {
clearTimeout(highlightTimer);
}
});
return {
timeFormat,
messageTime,
messageTimeLocale,
messageComponent,
canReply,
canReact,
pickerOpen,
pickerEl,
pickerId,
reactBtnEl,
togglePicker,
closePicker,
onPickEmoji,
onToggleChip,
parentInHistory,
isAction,
startReply,
scrollToParent,
onTouchStart,
onTouchEnd,
onTouchCancel,
};
},
});
</script>