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
133 changes: 122 additions & 11 deletions components/chat/ChatLobby.vue
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ import Empty from "~/components/ui/empty/Empty.vue";
:is-minimized="isMinimized"
class="min-h-0 flex-1 overflow-y-auto"
:last-read-count="lastReadMessageCount"
:message-room="messageRoom"
:can-moderate="canModerate"
@bottom-state-change="handleBottomStateChange"
/>
<Empty v-else class="flex-1 text-muted-foreground">
Expand Down Expand Up @@ -147,7 +149,7 @@ import Empty from "~/components/ui/empty/Empty.vue";
</button>
</div>
<ChatInput
v-if="canSend"
v-if="composerEnabled"
ref="chatInputRef"
variant="global"
:channels="chatChannels"
Expand All @@ -160,7 +162,7 @@ import Empty from "~/components/ui/empty/Empty.vue";
</template>
</ChatInput>
<div v-else class="px-3 py-2 text-center text-xs text-muted-foreground">
{{ readonlyHint || $t("chat.readonly") }}
{{ readonlyText }}
</div>
</div>
</div>
Expand Down Expand Up @@ -278,6 +280,8 @@ import Empty from "~/components/ui/empty/Empty.vue";
variant="embedded"
class="min-h-0 flex-1 overflow-y-auto"
:last-read-count="tracksReadPosition ? lastReadMessageCount : 0"
:message-room="messageRoom"
:can-moderate="canModerate"
@bottom-state-change="handleBottomStateChange"
/>
<Empty v-else key="empty" class="min-h-0 flex-1 text-muted-foreground">
Expand Down Expand Up @@ -342,7 +346,7 @@ import Empty from "~/components/ui/empty/Empty.vue";
<!-- Composer <-> readonly hint are different heights; measured swap. -->
<HeightSwap>
<ChatInput
v-if="canSend"
v-if="composerEnabled"
key="composer"
ref="chatInputRef"
variant="embedded"
Expand All @@ -363,7 +367,7 @@ import Empty from "~/components/ui/empty/Empty.vue";
key="hint"
class="px-3 py-2 text-center text-xs text-muted-foreground"
>
{{ readonlyHint || $t("chat.readonly") }}
{{ readonlyText }}
</div>
</HeightSwap>
</div>
Expand All @@ -373,7 +377,13 @@ import Empty from "~/components/ui/empty/Empty.vue";
<script lang="ts">
import { markRaw, type PropType } from "vue";
import socket, { chatMessageKey, chatMessageTime } from "~/web-sockets/Socket";
import type { ChatType, Lobby, LobbyMessage } from "~/web-sockets/Socket";
import type {
ChatType,
Lobby,
LobbyMessage,
LobbyMessageDeleted,
} from "~/web-sockets/Socket";
import { e_player_roles_enum } from "~/generated/zeus";

import { useRightSidebar } from "~/composables/useRightSidebar";
import { useSound } from "~/composables/useSound";
Expand Down Expand Up @@ -506,9 +516,13 @@ export default {
// how a team callout ends up in front of the other side.
sendTo: "everyone" as "everyone" | "team",
viewFilter: "all" as "all" | "everyone" | "team",
// Counted per room, but only while the filter is hiding that room --
// Held per room, but only while the filter is hiding that room --
// everything on screen has by definition been offered to the reader.
unseen: { everyone: 0, team: 0 } as Record<string, number>,
// Kept as message keys so a deleted line takes its mark with it.
unseen: { everyone: [], team: [] } as Record<
"everyone" | "team",
string[]
>,
};
},
computed: {
Expand Down Expand Up @@ -562,6 +576,40 @@ export default {
);
});
},
// A conversation between two people is theirs; the api refuses to moderate
// one, even for an administrator.
canModerate() {
return (
this.type !== "direct" &&
useAuthStore().isRoleAbove(e_player_roles_enum.moderator)
);
},
// One object per room, kept for as long as the rooms are, so handing it to
// every row costs no re-render.
messageRooms(): Record<
"everyone" | "team",
{ type: ChatType; id: string } | null
> {
return {
everyone: this.channelTarget("everyone"),
team: this.teamLobbyId ? this.channelTarget("team") : null,
};
},
// A gag covers group chat only, and the api refuses the send either way --
// this just says so before the player has typed anything.
isGagged() {
return this.type !== "direct" && !!useAuthStore().me?.is_gagged;
},
composerEnabled() {
return this.canSend && !this.isGagged;
},
readonlyText() {
if (!this.canSend) {
return this.readonlyHint || this.$t("chat.readonly");
}

return this.$t("chat.gagged");
},
// Only a single lineup's room is private; the match room admits both sides.
isTeamContext() {
return this.type === "match_team";
Expand Down Expand Up @@ -734,16 +782,71 @@ export default {
}

if (option === "all") {
return this.unseen.everyone + this.unseen.team;
return this.unseen.everyone.length + this.unseen.team.length;
}

return this.unseen[option] ?? 0;
return this.unseen[option as "everyone" | "team"]?.length ?? 0;
},
channelTarget(channel: "everyone" | "team") {
return channel === "team" && this.teamLobbyId
? { type: "match_team" as ChatType, id: this.teamLobbyId }
: { type: this.type as ChatType, id: this.lobbyId };
},
messageRoom(message: LobbyMessage) {
return this.messageRooms[message?.__channel ?? "everyone"] ?? null;
},
// Where the removed line sat in what this panel renders. `index` is its
// place in its own room; merged, the other room's earlier lines come first.
renderedIndex(
{ message, index }: LobbyMessageDeleted,
channel: "everyone" | "team",
) {
if (!this.isMerged) {
return index;
}

if (!this.channelVisible(channel)) {
return -1;
}

const other = channel === "team" ? "everyone" : "team";
if (!this.channelVisible(other)) {
return index;
}

const otherLobby = other === "team" ? this.teamLobby : this.lobby;
const time = chatMessageTime(message);

// Ties sort everyone ahead of team, as the merged stream does.
const earlier = (otherLobby?.messages ?? NO_MESSAGES).filter(
(candidate) => {
const candidateTime = chatMessageTime(candidate);
return (
candidateTime < time ||
(candidateTime === time && other === "everyone")
);
},
).length;

return index + earlier;
},
// The "New" line is a count from the top, so a line removed above it would
// otherwise drag it down onto a message that was already read.
handleDeletedMessage(
deleted: LobbyMessageDeleted,
channel: "everyone" | "team",
) {
const position = this.renderedIndex(deleted, channel);

if (position !== -1 && position < this.lastReadMessageCount) {
this.lastReadMessageCount = Math.max(0, this.lastReadMessageCount - 1);
}

const key = chatMessageKey(deleted.message);
this.unseen[channel] = this.unseen[channel].filter(
(unseenKey) => unseenKey !== key,
);
},
handleIncomingMessage(
message: LobbyMessage,
channel: "everyone" | "team" = "everyone",
Expand All @@ -762,7 +865,7 @@ export default {
// Only what the filter is actively hiding. A message in a room that is
// on screen has been shown, whether or not it was read.
if (this.isMerged && !isOwnMessage && !this.channelVisible(channel)) {
this.unseen[channel]++;
this.unseen[channel].push(chatMessageKey(message));
}

// Auto-scroll only when already at the bottom.
Expand Down Expand Up @@ -861,7 +964,7 @@ export default {
handler() {
for (const channel of ["everyone", "team"] as const) {
if (this.channelVisible(channel)) {
this.unseen[channel] = 0;
this.unseen[channel] = [];
}
}
},
Expand Down Expand Up @@ -891,6 +994,10 @@ export default {
lobby.on("lobby:chat", (message: LobbyMessage) => {
this.handleIncomingMessage(message, "everyone");
});

lobby.on("lobby:deleted", (deleted: LobbyMessageDeleted) => {
this.handleDeletedMessage(deleted, "everyone");
});
},
},
// The second room. joinLobby is refcounted per call and hands back its own
Expand Down Expand Up @@ -924,6 +1031,10 @@ export default {
lobby.on("lobby:chat", (message: LobbyMessage) => {
this.handleIncomingMessage(message, "team");
});

lobby.on("lobby:deleted", (deleted: LobbyMessageDeleted) => {
this.handleDeletedMessage(deleted, "team");
});
},
},
messages: {
Expand Down
54 changes: 52 additions & 2 deletions components/chat/ChatMessage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { dateLocale } from "~/utilities/dateLocale";
import TimeAgo from "~/components/TimeAgo.vue";
import PlayerDisplay from "~/components/PlayerDisplay.vue";
import ChatMessageActions from "~/components/chat/ChatMessageActions.vue";
</script>

<template>
Expand Down Expand Up @@ -77,10 +78,28 @@ import PlayerDisplay from "~/components/PlayerDisplay.vue";
{{ message.message }}
</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"
class="absolute right-0 z-10"
:style="{ top: `calc(${padTopRem}rem - 0.25rem)` }"
:message="message"
:room="room"
:permissions="permissions"
/>
</div>
</template>

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

export default {
props: {
message: {
Expand All @@ -95,8 +114,28 @@ export default {
type: Object,
required: false,
},
// The room this line lives in. Without one there is nothing to act on, so
// no actions are offered.
room: {
type: Object as PropType<{ type: ChatType; id: string } | null>,
default: null,
},
canModerate: {
type: Boolean,
default: false,
},
},
computed: {
permissions() {
return chatMessagePermissions({
message: this.message,
canModerate: this.canModerate,
roomType: this.room?.type ?? "",
});
},
hasActions() {
return !!this.room && hasChatMessageActions(this.permissions);
},
// Stamped by ChatLobby when it merges the match room with a lineup room.
// Absent everywhere else, which is what keeps every other chat surface
// rendering exactly as before.
Expand Down Expand Up @@ -144,7 +183,15 @@ export default {
// Same gutter either way. The rail is drawn inside it rather than added
// to it, so a team line starts on the same column as every other line and
// the avatars stay in one straight edge down the list.
const classes = ["group relative pl-12 text-[11px] leading-snug"];
const classes = [
"group group/chat-message relative pl-12 text-[11px] leading-snug",
];

// Room for the actions trigger kept whether or not it is showing, so a
// hover never rewraps the line under the pointer.
if (this.hasActions) {
classes.push("pr-7");
}

if (!this.isTeamMessage) {
classes.push(
Expand All @@ -153,7 +200,10 @@ export default {
return classes;
}

classes.push("pr-2 bg-[hsl(var(--tac-amber)/0.05)]");
classes.push("bg-[hsl(var(--tac-amber)/0.05)]");
if (!this.hasActions) {
classes.push("pr-2");
}

// Inside a run the separation between speakers is padding, not margin, so
// the tinted block and the rail stay unbroken. The amount comes from
Expand Down
Loading
Loading