Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions components/chat/ChatInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,10 @@ import { useForm } from "vee-validate";
import { toTypedSchema } from "~/utilities/vee-validate-zod";
import { chatEnterAction } from "~/utilities/chatInputKeys";
import { toastChatError } from "~/utilities/chatErrors";
import { CHAT_MESSAGE_MAX_LENGTH } from "~/constants/chat";

const REMAINING_HINT_AT = 200;
import {
CHAT_MESSAGE_MAX_LENGTH,
CHAT_REMAINING_HINT_AT,
} from "~/constants/chat";

export interface ChatInputChannel {
value: string;
Expand Down Expand Up @@ -232,7 +233,7 @@ export default {
return CHAT_MESSAGE_MAX_LENGTH - message.length;
},
showRemaining(): boolean {
return this.remainingCharacters <= REMAINING_HINT_AT;
return this.remainingCharacters <= CHAT_REMAINING_HINT_AT;
},
activeChannelValue() {
return this.destination ?? this.channels[0]?.value;
Expand Down
98 changes: 95 additions & 3 deletions components/chat/ChatMessage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { dateLocale } from "~/utilities/dateLocale";
import TimeAgo from "~/components/TimeAgo.vue";
import PlayerDisplay from "~/components/PlayerDisplay.vue";
import ChatMessageActions from "~/components/chat/ChatMessageActions.vue";
import ChatMessageEditor from "~/components/chat/ChatMessageEditor.vue";
import FiveStackToolTip from "~/components/FiveStackToolTip.vue";
</script>

<template>
Expand Down Expand Up @@ -74,30 +76,63 @@ import ChatMessageActions from "~/components/chat/ChatMessageActions.vue";
{{ $t("chat.team_tag") }}
</span>
</div>
<p class="text-[11px] leading-snug break-words whitespace-pre-wrap">
{{ message.message }}
<ChatMessageEditor
v-if="editing && room"
:message="message"
:room="room"
@close="endEdit"
/>
<p
v-else
class="text-[11px] leading-snug break-words whitespace-pre-wrap"
>
<span>{{ message.message }}</span>
<FiveStackToolTip
v-if="editedAtLabel"
as-child
:delay-duration="120"
side="top"
>
<template #trigger>
<span
tabindex="0"
class="ml-1 whitespace-nowrap rounded-sm text-[9px] text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
data-chat-edited
>
{{ $t("chat.edited") }}
</span>
</template>
{{ $t("chat.edited_at", { time: editedAtLabel }) }}
</FiveStackToolTip>
</p>
</div>

<!-- Centred on the first line rather than hung off its top: the trigger is
taller than a line of chat. -->
<ChatMessageActions
v-if="hasActions"
v-if="hasActions && !editing"
ref="actions"
class="absolute right-0 z-10"
:style="{ top: `calc(${padTopRem}rem - 0.25rem)` }"
:message="message"
:room="room"
:permissions="permissions"
:own="isOwnMessage"
@open="recheckPermissions"
@expired="permissionsCheckedAt = Date.now()"
@edit="$emit('edit')"
/>
</div>
</template>

<script lang="ts">
import type { PropType } from "vue";
import type { ChatType } from "~/web-sockets/Socket";
import { toast } from "@/components/ui/toast";
import {
chatMessagePermissions,
hasChatMessageActions,
isOwnChatMessage,
} from "~/utilities/chatMessageActions";

export default {
Expand All @@ -124,13 +159,70 @@ export default {
type: Boolean,
default: false,
},
editing: {
type: Boolean,
default: false,
},
},
emits: ["edit", "edit-end"],
data() {
return {
// Not a ticking clock: the menu refreshes it when it opens.
permissionsCheckedAt: Date.now(),
};
},
methods: {
// A menu opened after the window closed has nothing left to offer, so the
// trigger goes and the reason is given instead of an empty menu.
recheckPermissions() {
this.permissionsCheckedAt = Date.now();

if (!hasChatMessageActions(this.permissions)) {
toast({ title: this.$t("chat.own_message_window_closed") });
}
},
// Focus goes back to the trigger the edit came from, if the window still
// leaves it anything to offer, rather than falling to the page.
endEdit() {
this.permissionsCheckedAt = Date.now();
this.$emit("edit-end");

void this.$nextTick(() => {
const actions = this.$refs.actions as
| { focusTrigger?: () => void }
| undefined;

actions?.focusTrigger?.();
});
},
},
computed: {
viewerSteamId() {
return useAuthStore().me?.steam_id ?? null;
},
isOwnMessage() {
return isOwnChatMessage(this.message, this.viewerSteamId);
},
permissions() {
return chatMessagePermissions({
message: this.message,
viewerSteamId: this.viewerSteamId,
viewerGagged: !!useAuthStore().me?.is_gagged,
canModerate: this.canModerate,
roomType: this.room?.type ?? "",
now: this.permissionsCheckedAt,
});
},
editedAtLabel() {
const editedAt = new Date(this.message?.edited_at ?? NaN);

if (Number.isNaN(editedAt.getTime())) {
return "";
}

return editedAt.toLocaleString(dateLocale(), {
dateStyle: "medium",
timeStyle: "short",
});
},
hasActions() {
Expand Down
84 changes: 65 additions & 19 deletions components/chat/ChatMessageActions.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from "vue";
import { MoreVertical, Trash2 } from "lucide-vue-next";
import { MoreVertical, Pencil, Trash2 } from "lucide-vue-next";
import { useRightSidebar } from "~/composables/useRightSidebar";
import socket, { type ChatType, type LobbyMessage } from "~/web-sockets/Socket";
import {
Expand All @@ -16,32 +16,57 @@ const props = defineProps<{
// the room the panel was opened for.
room: { type: ChatType; id: string };
permissions: ChatMessagePermissions;
own?: boolean;
}>();

const emit = defineEmits<{
open: [];
expired: [];
edit: [];
}>();

const triggerRef = ref<{ $el?: HTMLElement } | null>(null);

defineExpose({
focusTrigger() {
triggerRef.value?.$el?.focus();
},
});

const menuOpen = ref(false);
const confirmDelete = ref(false);

// Before the menu renders, so the row re-checks the ten minute window against
// the clock now rather than when the line first rendered.
function setMenuOpen(open: boolean) {
if (open) {
emit("open");
}

menuOpen.value = open;
}

// The menu and the confirm after it sit over the right hub, which closes itself
// when the pointer leaves. Both count as still interacting with it.
function holdRightHub(open: boolean, wasOpen: boolean) {
const rightSidebar = useRightSidebar();

if (open && !wasOpen) {
rightSidebar.suspendHoverClose();
} else if (!open && wasOpen) {
rightSidebar.resumeHoverClose();
// when the pointer leaves. Both count as still interacting with it. The row can
// unmount this before a watcher sees the menu open, so only locks actually
// taken are released.
const hubHolds = new Set<"menu" | "confirm">();

function holdRightHub(hold: "menu" | "confirm", open: boolean) {
if (open && !hubHolds.has(hold)) {
hubHolds.add(hold);
useRightSidebar().suspendHoverClose();
} else if (!open && hubHolds.delete(hold)) {
useRightSidebar().resumeHoverClose();
}
}

watch(menuOpen, holdRightHub);
watch(confirmDelete, holdRightHub);
watch(menuOpen, (open) => holdRightHub("menu", open));
watch(confirmDelete, (open) => holdRightHub("confirm", open));

onBeforeUnmount(() => {
for (const open of [menuOpen.value, confirmDelete.value]) {
if (open) {
useRightSidebar().resumeHoverClose();
}
}
holdRightHub("menu", false);
holdRightHub("confirm", false);
});

async function deleteMessage() {
Expand All @@ -59,6 +84,12 @@ async function deleteMessage() {
} catch (error) {
toastChatError(error as ChatError);

if ((error as ChatError)?.code === "window_closed") {
confirmDelete.value = false;
emit("expired");
return;
}

if (!chatErrorFailed(error as ChatError)) {
confirmDelete.value = false;
}
Expand All @@ -70,9 +101,10 @@ async function deleteMessage() {
<div
class="opacity-0 transition-opacity duration-150 focus-within:opacity-100 group-hover/chat-message:opacity-100 has-[[data-state=open]]:opacity-100 motion-reduce:transition-none [@media(hover:none)]:opacity-100"
>
<DropdownMenu v-model:open="menuOpen" :modal="false">
<DropdownMenu :open="menuOpen" :modal="false" @update:open="setMenuOpen">
<DropdownMenuTrigger as-child>
<Button
ref="triggerRef"
variant="ghost"
size="icon"
class="h-6 w-6 text-muted-foreground hover:bg-[hsl(var(--tac-amber)/0.12)] hover:text-[hsl(var(--tac-amber))] [&_svg]:size-3.5"
Expand All @@ -81,7 +113,18 @@ async function deleteMessage() {
<MoreVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-48" data-right-hub-interactive>
<DropdownMenuContent
align="end"
class="w-48"
data-right-hub-interactive
>
<DropdownMenuItem v-if="permissions.canEdit" @select="emit('edit')">
<Pencil />
<span>{{ $t("chat.edit_message") }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator
v-if="permissions.canEdit && permissions.canDelete"
/>
<DropdownMenuItem
v-if="permissions.canDelete"
class="text-destructive focus:text-destructive"
Expand All @@ -99,7 +142,10 @@ async function deleteMessage() {
<AlertDialogTitle>
{{ $t("chat.delete_confirm_title") }}
</AlertDialogTitle>
<AlertDialogDescription>
<AlertDialogDescription v-if="own">
{{ $t("chat.delete_own_confirm_description") }}
</AlertDialogDescription>
<AlertDialogDescription v-else>
{{
$t("chat.delete_confirm_description", {
name: message.from?.name || $t("common.unknown"),
Expand Down
Loading
Loading