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: 131 additions & 2 deletions components/hub/ChatPanel.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { ref, computed, watch, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import { useMediaQuery } from "@vueuse/core";
import { SIDEBAR_MOBILE_QUERY } from "~/components/ui/sidebar/utils";
Expand All @@ -19,16 +19,24 @@ import ChatParticipants from "~/components/chat/ChatParticipants.vue";
import TournamentChatEndedStamp from "~/components/chat/TournamentChatEndedStamp.vue";
import NewConversation from "~/components/hub/NewConversation.vue";
import NewChatRailButton from "~/components/hub/NewChatRailButton.vue";
import MessageRequests from "~/components/hub/MessageRequests.vue";
import MessageRequestsRailButton from "~/components/hub/MessageRequestsRailButton.vue";
import HubEmptyState from "~/components/hub/HubEmptyState.vue";
import FadeSwap from "~/components/ui/transitions/FadeSwap.vue";
import { toast } from "~/components/ui/toast";
import AnimatedStat from "~/components/AnimatedStat.vue";
import { useChatTabs, type ChatTab } from "~/composables/useChatTabs";
import { cancelChatTabRestore } from "~/composables/useChatTabPersistence";
import { useDirectConversationBar } from "~/composables/useDirectConversationBar";
import {
directTabId,
pendingComposerFocus,
useDirectMessages,
} from "~/composables/useDirectMessages";
import {
useMessageRequests,
type MessageRequest,
} from "~/composables/useMessageRequests";
import { orderChatTabs } from "~/utilities/chatTabOrder";
import { hapticTap } from "~/utilities/haptics";
import {
Expand Down Expand Up @@ -592,6 +600,77 @@ function onDock(tab: ChatTab) {
// picked, Esc is pressed, or another conversation is opened from the rail.
const composing = ref(false);

// Message requests take over the thread area the same way, one at a time.
const viewingRequests = ref(false);
const {
requests,
requestCount,
isRequest,
isAwaitingReply,
acceptIfReplied,
decline,
} = useMessageRequests();

watch(composing, (isComposing) => {
if (isComposing) {
viewingRequests.value = false;
}
});

watch(viewingRequests, (viewing) => {
if (viewing) {
composing.value = false;
}
});

// Nothing left to show once the last request is answered or denied.
watch(requestCount, (count) => {
if (count === 0) {
viewingRequests.value = false;
}
});

// Replying is what accepts a request, so it leaves the list as soon as this
// player's message is in the room.
watchEffect(() => {
for (const request of requests.value) {
acceptIfReplied(request.roomId);
}
});

function openRequest(request: MessageRequest) {
viewingRequests.value = false;
useDirectMessages().openConversation(request.peer);
}

const activeRequest = computed(() =>
activeTab.value?.type === "direct" && isRequest(activeTab.value.lobbyId)
? activeTab.value
: null,
);

async function denyActiveRequest() {
const tab = activeRequest.value;

if (!tab) {
return;
}

try {
await decline(tab.lobbyId);
} catch {
toast({
title: t("layouts.chat_panel.requests.deny_failed"),
variant: "destructive",
});
}
}

// The sender of a request writes once, then waits for a reply.
function awaitingReply(tab: ChatTab) {
return tab.type === "direct" && isAwaitingReply(tab.lobbyId, tab.steamId);
}

const lobbies: Record<string, { focusComposer?: () => void } | null> = {};

// A conversation just started -- from New message or a Message button --
Expand Down Expand Up @@ -624,6 +703,7 @@ function handleRoomClick(tab: ChatTab) {
}

composing.value = false;
viewingRequests.value = false;

// In editing mode a tap is "I am done", not "open this".
if (wiggling.value) {
Expand Down Expand Up @@ -798,6 +878,12 @@ function handlePopOut() {
:active="composing"
@click="composing = true"
/>
<MessageRequestsRailButton
v-if="tab.id === firstDirectTabId && requestCount > 0"
:active="viewingRequests"
:count="requestCount"
@click="viewingRequests = true"
/>
<!-- ContextMenu wraps the pair: its root renders no element, so
it cannot sit under TooltipTrigger's as-child. The two triggers
chain onto the one button instead. -->
Expand Down Expand Up @@ -936,21 +1022,36 @@ function handlePopOut() {
:active="composing"
@click="composing = true"
/>
<MessageRequestsRailButton
v-if="requestCount > 0"
:active="viewingRequests"
:count="requestCount"
@click="viewingRequests = true"
/>
</template>
</TooltipProvider>
</div>

<!-- No conversations yet: the way to start one is the whole rail. -->
<div v-else class="flex flex-1 flex-col items-center">
<NewChatRailButton :active="composing" @click="composing = true" />
<MessageRequestsRailButton
v-if="requestCount > 0"
:active="viewingRequests"
:count="requestCount"
@click="viewingRequests = true"
/>
</div>
</div>

<!-- Right chat area. Closing the last conversation dissolves to the empty
state instead of cutting; both branches fill the column, so a plain
crossfade is the right tool. -->
<FadeSwap class="flex-1 min-w-0">
<div v-if="composing" key="compose" class="h-full">
<div v-if="viewingRequests" key="requests" class="h-full">
<MessageRequests @close="viewingRequests = false" @open="openRequest" />
</div>
<div v-else-if="composing" key="compose" class="h-full">
<NewConversation @close="composing = false" />
</div>
<div
Expand Down Expand Up @@ -1100,6 +1201,26 @@ function handlePopOut() {
</div>
</Transition>

<div
v-if="activeRequest"
class="flex shrink-0 items-center gap-3 border-b border-[hsl(var(--tac-amber)/0.3)] bg-[hsl(var(--tac-amber)/0.08)] px-3 py-2"
>
<p class="min-w-0 flex-1 text-xs text-muted-foreground">
{{
$t("layouts.chat_panel.requests.banner", {
name: activeRequest.label,
})
}}
</p>
<button
type="button"
class="inline-flex h-7 shrink-0 items-center rounded-md border border-border bg-card/50 px-2 text-xs text-muted-foreground transition-colors hover:border-destructive/60 hover:text-destructive"
@click="denyActiveRequest"
>
{{ $t("layouts.chat_panel.requests.deny") }}
</button>
</div>

<div class="flex-1 min-h-0 flex flex-col">
<ChatLobby
v-for="tab in tabs"
Expand All @@ -1114,6 +1235,14 @@ function handlePopOut() {
:frameless="true"
:is-global-context="true"
:hide-participants-summary="true"
:can-send="!awaitingReply(tab)"
:readonly-hint="
awaitingReply(tab)
? $t('layouts.chat_panel.requests.awaiting_reply', {
name: tab.label,
})
: undefined
"
:disable-auto-focus-on-activate="isMobile"
:is-active-tab="
tab.id === activeChatId && isSidebarOpen && isTabActive
Expand Down
122 changes: 122 additions & 0 deletions components/hub/MessageRequests.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import { X } from "lucide-vue-next";
import { Avatar, AvatarImage, AvatarFallback } from "~/components/ui/avatar";
import HubEmptyState from "~/components/hub/HubEmptyState.vue";
import { toast } from "~/components/ui/toast";
import {
useMessageRequests,
type MessageRequest,
} from "~/composables/useMessageRequests";
import { resolveAvatarUrl } from "~/utilities/avatarUrl";

// First messages from players who are not friends, inside Chat itself like
// New message. Opening one shows the conversation; replying there accepts it.
// Denying hides it here without the sender being told.
const emit = defineEmits<{
(e: "close"): void;
(e: "open", request: MessageRequest): void;
}>();

const { t } = useI18n();
const { requests, decline } = useMessageRequests();
const apiDomain = useRuntimeConfig().public.apiDomain as string;
const declining = ref<string | null>(null);

function avatar(request: MessageRequest) {
return resolveAvatarUrl(request.peer?.avatar_url, apiDomain);
}

function nameOf(request: MessageRequest) {
return request.peer?.name ?? request.peer?.steam_id ?? "";
}

async function deny(request: MessageRequest) {
declining.value = request.roomId;

try {
await decline(request.roomId);
} catch {
toast({
title: t("layouts.chat_panel.requests.deny_failed"),
variant: "destructive",
});
} finally {
declining.value = null;
}
}
</script>

<template>
<div class="flex h-full flex-col">
<div
class="flex items-center justify-between gap-3 border-b border-border bg-card/30 px-3 py-3"
>
<div class="min-w-0">
<div class="truncate text-xs font-semibold text-foreground">
{{ $t("layouts.chat_panel.requests.title") }}
</div>
<div class="truncate text-[10px] text-muted-foreground">
{{ $t("layouts.chat_panel.requests.hint") }}
</div>
</div>
<button
type="button"
class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border bg-card/50 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
:aria-label="$t('layouts.chat_panel.requests.close')"
@click="emit('close')"
>
<X class="h-3.5 w-3.5" />
</button>
</div>

<div class="flex min-h-0 flex-1 flex-col overflow-y-auto px-2 pb-2 pt-2">
<div
v-for="request in requests"
:key="request.roomId"
class="flex w-full items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-white/[0.04]"
>
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md"
@click="emit('open', request)"
>
<Avatar shape="square" class="h-8 w-8 shrink-0 text-[0.6rem]">
<AvatarImage
v-if="avatar(request)"
:src="avatar(request)!"
:alt="nameOf(request)"
/>
<AvatarFallback>
{{ (nameOf(request) || "?").slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm text-foreground">
{{ nameOf(request) }}
</span>
<span class="block truncate text-[0.7rem] text-muted-foreground">
{{ $t("layouts.chat_panel.requests.wants_to_message") }}
</span>
</span>
</button>
<button
type="button"
class="inline-flex h-7 shrink-0 items-center gap-1 rounded-md border border-border bg-card/50 px-2 text-xs text-muted-foreground transition-colors hover:border-destructive/60 hover:text-destructive disabled:opacity-50"
:disabled="declining === request.roomId"
@click="deny(request)"
>
<X class="h-3 w-3" />
{{ $t("layouts.chat_panel.requests.deny") }}
</button>
</div>

<HubEmptyState
v-if="!requests.length"
:title="$t('layouts.chat_panel.requests.empty_title')"
:description="$t('layouts.chat_panel.requests.empty_description')"
/>
</div>
</div>
</template>
40 changes: 40 additions & 0 deletions components/hub/MessageRequestsRailButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { Inbox } from "lucide-vue-next";

// The chat rail's way into message requests. Shown only while some wait, with
// how many -- in amber, not the red of an unread badge, since a request is not
// something anyone is owed an answer to.
defineProps<{ active?: boolean; count: number }>();
defineEmits<{ (e: "click"): void }>();
</script>

<template>
<button
type="button"
class="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:class="
active
? 'text-[hsl(var(--tac-amber))]'
: 'text-zinc-500 hover:bg-zinc-800/60 hover:text-[hsl(var(--tac-amber))]'
"
:aria-label="$t('layouts.chat_panel.requests.open', { count })"
:aria-pressed="active"
@click="$emit('click')"
>
<span
class="flex h-7 w-7 items-center justify-center rounded-md border transition-colors"
:class="
active
? 'border-[hsl(var(--tac-amber)/0.6)] bg-[hsl(var(--tac-amber)/0.12)]'
: 'border-zinc-700'
"
>
<Inbox class="h-3.5 w-3.5" />
</span>
<span
class="absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-[hsl(var(--tac-amber))] px-1 text-[0.6rem] font-bold tabular-nums leading-none text-black"
>
{{ count > 99 ? "99+" : count }}
</span>
</button>
</template>
5 changes: 2 additions & 3 deletions components/hub/NewConversation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ import { setActiveHub } from "~/composables/useHubState";
import { resolveAvatarUrl } from "~/utilities/avatarUrl";

// Starting a conversation, inside Chat itself: the thread area becomes a
// "To:" field over your friends, online first. Direct messages are between
// accepted friends only (the server enforces the same), so this lists friends
// rather than searching every player and refusing most of them.
// "To:" field over your friends, online first. Anyone else is messaged from
// their profile or name, where a first message goes out as a request.
const emit = defineEmits<{ (e: "close"): void }>();

const matchmakingStore = useMatchmakingStore();
Expand Down
Loading
Loading