v3: added a chatbox
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
This commit is contained in:
340
src/App.css
340
src/App.css
@@ -1162,6 +1162,75 @@ body.has-fab .page-content {
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
|
||||
/* Chat FAB — mirrors .dump-fab and stacks directly above it (the "+" dump
|
||||
button keeps the bottom slot; chat sits one button-height up) so the two
|
||||
never overlap. */
|
||||
.chat-fab {
|
||||
position: fixed;
|
||||
--fab-size: 3.5rem;
|
||||
/* Share the dump-fab's column. */
|
||||
right: max(1.25rem, calc(50% - var(--fab-lane, 860px) / 2 - var(--fab-size) - 1rem));
|
||||
/* One button-height + gap above the dump-fab's bottom-anchored resting spot. */
|
||||
top: calc(100vh - var(--fab-size) * 2 - 2rem - env(safe-area-inset-bottom, 0px));
|
||||
top: calc(100dvh - var(--fab-size) * 2 - 2rem - env(safe-area-inset-bottom, 0px));
|
||||
z-index: 900;
|
||||
width: var(--fab-size);
|
||||
height: var(--fab-size);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--fab-radius, 50%);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px color-mix(in srgb, var(--color-accent) 45%, transparent),
|
||||
0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transform: translateY(1rem) scale(0.9);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, background 0.15s ease,
|
||||
top 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.chat-fab-icon {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
}
|
||||
|
||||
.chat-fab--visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.chat-fab:hover {
|
||||
background: var(--color-accent-hover);
|
||||
transform: translateY(-2px) scale(1);
|
||||
box-shadow: 0 6px 20px color-mix(in srgb, var(--color-accent) 55%, transparent),
|
||||
0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.chat-fab:active {
|
||||
transform: translateY(0) scale(0.96);
|
||||
}
|
||||
|
||||
/* Match the dump-fab's clearance shift when a player is mounted. */
|
||||
body.has-player .chat-fab {
|
||||
top: calc(1.25rem + env(safe-area-inset-top, 0px));
|
||||
/* Stack below the dump-fab in the top-right corner to avoid overlap. */
|
||||
right: max(1.25rem, calc(50% - var(--fab-lane, 860px) / 2 - var(--fab-size) - 1rem));
|
||||
top: calc(1.25rem + var(--fab-size) + 0.75rem + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
/* The badge sits on the circular FAB; nudge it onto the rim. */
|
||||
.chat-fab .notification-badge {
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
box-shadow: 0 0 0 2px var(--color-accent);
|
||||
}
|
||||
|
||||
.rich-content-thumbnail-btn {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
@@ -2148,6 +2217,7 @@ body.has-fab .page-content {
|
||||
/* Icon-only controls are square, so the row reads as a tidy set. */
|
||||
.app-header-nav .nav-search-btn,
|
||||
.app-header-nav .notification-bell,
|
||||
.app-header-nav .chat-button,
|
||||
.app-header-nav .user-menu-trigger {
|
||||
width: 2.25rem;
|
||||
padding: 0;
|
||||
@@ -3221,6 +3291,255 @@ body.has-fab .page-content {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Live chat ── */
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: min(60vh, 480px);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
/* Reserve a gutter for the scrollbar so it never sits on top of the
|
||||
messages — without this, overlay scrollbars overlap the content. */
|
||||
scrollbar-gutter: stable;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
margin: auto 0;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.chat-load-older {
|
||||
text-align: center;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: flex-start;
|
||||
/* Each message animates once, when its element first mounts — so newly
|
||||
arriving messages slide in while existing ones stay put (React reuses
|
||||
keyed DOM nodes). */
|
||||
animation: chat-msg-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
@keyframes chat-msg-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-message {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-message-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.chat-message-author {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chat-message-author:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.chat-message-time {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.chat-message-text {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.chat-message-text.md {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.chat-message-edited {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Edit/Delete actions: bare inline icons that follow the author/time in the
|
||||
meta row, sitting right next to the message they belong to. Faded in on hover
|
||||
(or focus, for keyboard use) so they stay unobtrusive. */
|
||||
.chat-message-actions {
|
||||
margin-left: 0.35rem;
|
||||
display: inline-flex;
|
||||
gap: 0.15rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.chat-message:hover .chat-message-actions,
|
||||
.chat-message:focus-within .chat-message-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
/* No hover on touch devices — keep the actions visible. */
|
||||
.chat-message-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0 0.1rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.chat-icon-btn:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.chat-icon-btn--delete:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.chat-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.chat-edit-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Compact buttons for the inline edit box — the full-size form buttons are too
|
||||
bulky for the narrow message column. */
|
||||
.chat-edit-actions .btn-primary,
|
||||
.chat-edit-actions .btn-secondary {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.chat-composer {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-composer .mention-textarea-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Reserve room on the input's bottom-right for the char counter. `display:
|
||||
block` removes the inline-block baseline descender gap below the textarea —
|
||||
that phantom space was making the wrapper taller than the textarea, so the
|
||||
bottom-aligned Send button landed a few px low. */
|
||||
.chat-input {
|
||||
padding-right: 3.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Pin the counter inside the textarea instead of letting it add a row below —
|
||||
that extra height is what knocked the Send button out of alignment. */
|
||||
.chat-composer .text-editor-count {
|
||||
position: absolute;
|
||||
right: 0.55rem;
|
||||
bottom: 0.4rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-send-btn {
|
||||
flex-shrink: 0;
|
||||
/* Match the input's resting height so the two line up exactly (both edges),
|
||||
while the input is free to grow taller as you type. */
|
||||
height: 2.4rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* The composer's popovers (mention list, emoji picker) anchor to the input,
|
||||
which sits at the very bottom of the modal. The modal body must not clip them
|
||||
— let them escape the body entirely so they're never cropped. The message
|
||||
list keeps its own scroll, so nothing else relies on the body clipping. */
|
||||
.modal-card:has(.chat) .modal-body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Open the mention list upward, into the roomy message area above the input. */
|
||||
.chat-composer .mention-dropdown {
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
/* Very narrow viewports: stack the input and Send button. */
|
||||
@media (max-width: 400px) {
|
||||
.chat-composer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.chat-send-btn {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The author/time/edited row gets cramped when squeezed side-by-side here —
|
||||
stack the metadata instead so each piece stays readable. Keep the stacked
|
||||
lines tight so they don't balloon the message height. */
|
||||
.chat-message-meta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* No longer trailing the time inline; align it with the stacked metadata. */
|
||||
.chat-message-actions {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-modal-message {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
@@ -4359,6 +4678,27 @@ body.has-fab .page-content {
|
||||
box-shadow: 0 0 0 2px var(--color-bg);
|
||||
}
|
||||
|
||||
/* ── Header chat button ── */
|
||||
.chat-button {
|
||||
position: relative;
|
||||
background: var(--color-header-user-bg);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.chat-button:hover {
|
||||
background: var(--color-header-user-bg-hover);
|
||||
}
|
||||
.chat-button-icon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ── Notifications page ── */
|
||||
.notifications-page {
|
||||
max-width: 680px;
|
||||
|
||||
@@ -18,8 +18,10 @@ import { WSProvider } from "./contexts/WSProvider.tsx";
|
||||
import { FollowProvider } from "./contexts/FollowProvider.tsx";
|
||||
import { CategoriesProvider } from "./contexts/CategoriesProvider.tsx";
|
||||
import { ThemeProvider } from "./contexts/ThemeProvider.tsx";
|
||||
import { ChatProvider } from "./contexts/ChatProvider.tsx";
|
||||
import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
|
||||
import { DumpFab } from "./components/DumpFab.tsx";
|
||||
import { ChatFab } from "./components/ChatFab.tsx";
|
||||
|
||||
import "./App.css";
|
||||
|
||||
@@ -162,6 +164,7 @@ function AppRoutes() {
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<DumpFab />
|
||||
<ChatFab />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -175,8 +178,10 @@ function App() {
|
||||
<FollowProvider>
|
||||
<CategoriesProvider>
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
<GlobalPlayer />
|
||||
<ChatProvider>
|
||||
<AppRoutes />
|
||||
<GlobalPlayer />
|
||||
</ChatProvider>
|
||||
</BrowserRouter>
|
||||
</CategoriesProvider>
|
||||
</FollowProvider>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { NotificationBell } from "./NotificationBell.tsx";
|
||||
import { ChatButton } from "./ChatButton.tsx";
|
||||
import { UserMenu } from "./UserMenu.tsx";
|
||||
import { SearchBar } from "./SearchBar.tsx";
|
||||
import { SITE_EMOJI, SITE_NAME } from "../hooks/useDocumentTitle.ts";
|
||||
@@ -91,6 +92,7 @@ export function AppHeader(
|
||||
<UserMenu user={user} />
|
||||
</div>
|
||||
|
||||
<ChatButton />
|
||||
<NotificationBell />
|
||||
<button
|
||||
type="button"
|
||||
|
||||
27
src/components/ChatButton.tsx
Normal file
27
src/components/ChatButton.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useChat } from "../hooks/useChat.ts";
|
||||
|
||||
/** Header chat button with an unread-message badge. Opens the app-wide chat. */
|
||||
export function ChatButton() {
|
||||
const { unreadChatCount } = useWS();
|
||||
const { openChat } = useChat();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-button"
|
||||
onClick={openChat}
|
||||
aria-label={unreadChatCount > 0
|
||||
? t`Chat (${unreadChatCount} unread)`
|
||||
: t`Chat`}
|
||||
>
|
||||
<span className="chat-button-icon">💬</span>
|
||||
{unreadChatCount > 0 && (
|
||||
<span className="notification-badge">
|
||||
{unreadChatCount > 99 ? "99+" : unreadChatCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
74
src/components/ChatFab.tsx
Normal file
74
src/components/ChatFab.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useChat } from "../hooks/useChat.ts";
|
||||
|
||||
/**
|
||||
* Floating chat button shown once the header has scrolled out of view, mirroring
|
||||
* {@link DumpFab}. Opens the single app-wide chat modal (owned by ChatProvider).
|
||||
* Only rendered for signed-in users.
|
||||
*/
|
||||
export function ChatFab() {
|
||||
const { user } = useAuth();
|
||||
const { unreadChatCount } = useWS();
|
||||
const { openChat } = useChat();
|
||||
const location = useLocation();
|
||||
const [headerHidden, setHeaderHidden] = useState(false);
|
||||
|
||||
// Reveal the button once the header's bottom edge scrolls above the viewport.
|
||||
// Re-reading the header on every tick keeps this correct across navigation
|
||||
// and lazily-mounted pages, where the header element is replaced.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const update = () => {
|
||||
const header = document.querySelector(".app-header");
|
||||
setHeaderHidden(!!header && header.getBoundingClientRect().bottom <= 0);
|
||||
};
|
||||
update();
|
||||
globalThis.addEventListener("scroll", update, { passive: true });
|
||||
globalThis.addEventListener("resize", update);
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(document.body);
|
||||
return () => {
|
||||
globalThis.removeEventListener("scroll", update);
|
||||
globalThis.removeEventListener("resize", update);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [user, location.pathname]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`chat-fab${headerHidden ? " chat-fab--visible" : ""}`}
|
||||
aria-label={unreadChatCount > 0
|
||||
? t`Chat (${unreadChatCount} unread)`
|
||||
: t`Chat`}
|
||||
title={t`Chat`}
|
||||
aria-hidden={!headerHidden}
|
||||
tabIndex={headerHidden ? 0 : -1}
|
||||
onClick={openChat}
|
||||
>
|
||||
<svg
|
||||
className="chat-fab-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-8.5 8.5 8.5 8.5 0 0 1-3.8-.9L3 21l1.9-5.7a8.5 8.5 0 0 1-.9-3.8A8.38 8.38 0 0 1 12.5 3a8.38 8.38 0 0 1 8.5 8.5z" />
|
||||
</svg>
|
||||
{unreadChatCount > 0 && (
|
||||
<span className="notification-badge">
|
||||
{unreadChatCount > 99 ? "99+" : unreadChatCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
375
src/components/ChatModal.tsx
Normal file
375
src/components/ChatModal.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Link } from "react-router";
|
||||
import { Modal } from "./Modal.tsx";
|
||||
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
import { ConfirmModal } from "./ConfirmModal.tsx";
|
||||
import { Tooltip } from "./Tooltip.tsx";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { can } from "../utils/permissions.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import {
|
||||
type ChatMessage,
|
||||
deserializeChatMessage,
|
||||
type PublicUser,
|
||||
type RawChatMessage,
|
||||
} from "../model.ts";
|
||||
|
||||
// Keep in sync with MAX_CHAT_LENGTH in api/services/chat-service.ts.
|
||||
const MAX_CHAT_LENGTH = 2000;
|
||||
// Matches the server's default page size; a full page means there may be more.
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
interface ChatMessageItemProps {
|
||||
message: ChatMessage;
|
||||
currentUser: PublicUser | null;
|
||||
canModerate: boolean;
|
||||
onEdit: (id: string, body: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
function ChatMessageItem(
|
||||
{ message, currentUser, canModerate, onEdit, onDelete }: ChatMessageItemProps,
|
||||
) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editDraft, setEditDraft] = useState(message.body);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const editorRef = useRef<TextEditorHandle>(null);
|
||||
|
||||
// Owners may edit their own messages; moderators may edit any. Deletion is a
|
||||
// moderation action only (mirrors api/services/chat-service.ts).
|
||||
const isOwn = !!currentUser && currentUser.id === message.userId;
|
||||
const canEdit = isOwn || canModerate;
|
||||
const canDelete = canModerate;
|
||||
|
||||
const openEdit = () => {
|
||||
setEditDraft(message.body);
|
||||
setEditing(true);
|
||||
setTimeout(() => editorRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
const saveEdit = () => {
|
||||
const body = editDraft.trim();
|
||||
if (!body) return;
|
||||
onEdit(message.id, body);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-message">
|
||||
<Avatar
|
||||
userId={message.userId}
|
||||
username={message.authorUsername}
|
||||
hasAvatar={!!message.authorAvatarMime}
|
||||
size={32}
|
||||
/>
|
||||
<div className="chat-message-body">
|
||||
<div className="chat-message-meta">
|
||||
<Link
|
||||
to={`/users/${message.authorUsername}`}
|
||||
className="chat-message-author"
|
||||
>
|
||||
{message.authorUsername}
|
||||
</Link>
|
||||
<Tooltip text={message.createdAt.toLocaleString()}>
|
||||
<time
|
||||
className="chat-message-time"
|
||||
dateTime={message.createdAt.toISOString()}
|
||||
>
|
||||
{relativeTime(message.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
{message.updatedAt && (
|
||||
<Tooltip text={t`Edited ${message.updatedAt.toLocaleString()}`}>
|
||||
<span className="chat-message-edited">
|
||||
<Trans>edited {relativeTime(message.updatedAt)}</Trans>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!editing && (canEdit || canDelete) && (
|
||||
<span className="chat-message-actions">
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-icon-btn"
|
||||
onClick={openEdit}
|
||||
title={t`Edit`}
|
||||
aria-label={t`Edit`}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-icon-btn chat-icon-btn--delete"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
title={t`Delete`}
|
||||
aria-label={t`Delete`}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing
|
||||
? (
|
||||
<div className="chat-edit">
|
||||
<TextEditor
|
||||
ref={editorRef}
|
||||
value={editDraft}
|
||||
onChange={setEditDraft}
|
||||
rows={2}
|
||||
autoResize
|
||||
maxLength={MAX_CHAT_LENGTH}
|
||||
className="comment-reply-textarea chat-input"
|
||||
onSubmit={saveEdit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
saveEdit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="chat-edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={saveEdit}
|
||||
disabled={!editDraft.trim()}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={() => setEditing(false)}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: <Markdown className="chat-message-text">{message.body}</Markdown>}
|
||||
|
||||
{confirmDelete && (
|
||||
<ConfirmModal
|
||||
message={t`Delete this message?`}
|
||||
confirmLabel={t`Delete`}
|
||||
onConfirm={() => {
|
||||
setConfirmDelete(false);
|
||||
onDelete(message.id);
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatModal({ onClose }: { onClose: () => void }) {
|
||||
const {
|
||||
chatMessages,
|
||||
deletedChatIds,
|
||||
sendChatMessage,
|
||||
editChatMessage,
|
||||
deleteChatMessage,
|
||||
clearUnreadChat,
|
||||
setChatOpen,
|
||||
} = useWS();
|
||||
const { authFetch, user } = useAuth();
|
||||
const canModerate = can(user, "chat:moderate");
|
||||
|
||||
const [history, setHistory] = useState<ChatMessage[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const composerRef = useRef<TextEditorHandle>(null);
|
||||
// Whether the view is pinned to the bottom — controls auto-scroll on new
|
||||
// messages so reading older history isn't yanked away.
|
||||
const stickToBottomRef = useRef(true);
|
||||
|
||||
// Focus the composer when the chat opens so you can type straight away.
|
||||
useEffect(() => {
|
||||
composerRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Merge fetched history with the live buffer, drop deleted ones, dedupe by id
|
||||
// (live buffer wins so edits show), sort ascending.
|
||||
const messages = useMemo(() => {
|
||||
const byId = new Map<string, ChatMessage>();
|
||||
for (const m of history) byId.set(m.id, m);
|
||||
for (const m of chatMessages) byId.set(m.id, m);
|
||||
return [...byId.values()]
|
||||
.filter((m) => !deletedChatIds.has(m.id))
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
}, [history, chatMessages, deletedChatIds]);
|
||||
|
||||
// Initial history load.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/chat?limit=${PAGE_SIZE}`);
|
||||
const json = await res.json();
|
||||
if (cancelled || !json.success) return;
|
||||
const items = (json.data as RawChatMessage[]).map(deserializeChatMessage);
|
||||
setHistory(items);
|
||||
setHasMore(items.length === PAGE_SIZE);
|
||||
} catch {
|
||||
// Live buffer still renders; the user can retry by reopening.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [authFetch]);
|
||||
|
||||
// Clear the unread badge on open and as new messages arrive while open.
|
||||
useEffect(() => {
|
||||
clearUnreadChat();
|
||||
}, [chatMessages, clearUnreadChat]);
|
||||
|
||||
// Tell the server the chatbox is open so it skips redundant mention
|
||||
// notifications while the user is reading; clear the flag on close.
|
||||
useEffect(() => {
|
||||
setChatOpen(true);
|
||||
return () => setChatOpen(false);
|
||||
}, [setChatOpen]);
|
||||
|
||||
// Auto-scroll to the bottom when pinned (initial load + new messages).
|
||||
useEffect(() => {
|
||||
if (!stickToBottomRef.current) return;
|
||||
const el = listRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
stickToBottomRef.current = distanceFromBottom < 80;
|
||||
}, []);
|
||||
|
||||
const loadOlder = useCallback(async () => {
|
||||
const oldest = messages[0];
|
||||
if (!oldest || loadingOlder) return;
|
||||
setLoadingOlder(true);
|
||||
const el = listRef.current;
|
||||
const prevHeight = el?.scrollHeight ?? 0;
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/chat?limit=${PAGE_SIZE}&before=${
|
||||
encodeURIComponent(oldest.createdAt.toISOString())
|
||||
}`,
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!json.success) return;
|
||||
const older = (json.data as RawChatMessage[]).map(deserializeChatMessage);
|
||||
setHasMore(older.length === PAGE_SIZE);
|
||||
if (older.length > 0) {
|
||||
stickToBottomRef.current = false;
|
||||
setHistory((prev) => [...older, ...prev]);
|
||||
// Preserve scroll position after older messages are prepended.
|
||||
requestAnimationFrame(() => {
|
||||
const node = listRef.current;
|
||||
if (node) node.scrollTop = node.scrollHeight - prevHeight;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore — user can retry
|
||||
} finally {
|
||||
setLoadingOlder(false);
|
||||
}
|
||||
}, [authFetch, messages, loadingOlder]);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const body = draft.trim();
|
||||
if (!body) return;
|
||||
sendChatMessage(body);
|
||||
setDraft("");
|
||||
stickToBottomRef.current = true;
|
||||
}, [draft, sendChatMessage]);
|
||||
|
||||
return (
|
||||
<Modal title={t`Chat`} onClose={onClose} wide>
|
||||
<div className="chat">
|
||||
<div className="chat-messages" ref={listRef} onScroll={handleScroll}>
|
||||
{hasMore && (
|
||||
<div className="chat-load-older">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={loadOlder}
|
||||
disabled={loadingOlder}
|
||||
>
|
||||
{loadingOlder
|
||||
? <Trans>Loading…</Trans>
|
||||
: <Trans>Load older messages</Trans>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{messages.length === 0
|
||||
? (
|
||||
<p className="chat-empty">
|
||||
<Trans>No messages yet. Say hello!</Trans>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
messages.map((m) => (
|
||||
<ChatMessageItem
|
||||
key={m.id}
|
||||
message={m}
|
||||
currentUser={user}
|
||||
canModerate={canModerate}
|
||||
onEdit={editChatMessage}
|
||||
onDelete={deleteChatMessage}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="chat-composer">
|
||||
<TextEditor
|
||||
ref={composerRef}
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
placeholder={t`Type a message…`}
|
||||
rows={2}
|
||||
autoResize
|
||||
maxLength={MAX_CHAT_LENGTH}
|
||||
className="comment-reply-textarea chat-input"
|
||||
onSubmit={handleSend}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary chat-send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={!draft.trim()}
|
||||
>
|
||||
<Trans>Send</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
13
src/contexts/ChatContext.ts
Normal file
13
src/contexts/ChatContext.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export interface ChatContextValue {
|
||||
isChatOpen: boolean;
|
||||
openChat: () => void;
|
||||
closeChat: () => void;
|
||||
}
|
||||
|
||||
export const ChatContext = createContext<ChatContextValue>({
|
||||
isChatOpen: false,
|
||||
openChat: () => {},
|
||||
closeChat: () => {},
|
||||
});
|
||||
57
src/contexts/ChatProvider.tsx
Normal file
57
src/contexts/ChatProvider.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
lazy,
|
||||
type ReactNode,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { ChatContext, type ChatContextValue } from "./ChatContext.ts";
|
||||
|
||||
const ChatModal = lazy(() =>
|
||||
import("../components/ChatModal.tsx").then((m) => ({ default: m.ChatModal }))
|
||||
);
|
||||
|
||||
/**
|
||||
* Owns the single live-chat modal instance so the header button, the floating
|
||||
* button, and notification deep-links all drive one modal. Open state is derived
|
||||
* (no effects): a manual open, or a `?chat=open` query param (used by mention
|
||||
* notifications), opens it — and it's only ever open for signed-in users.
|
||||
*/
|
||||
export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const [manuallyOpen, setManuallyOpen] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const deepLinkOpen = searchParams.get("chat") === "open";
|
||||
const isChatOpen = !!user && (manuallyOpen || deepLinkOpen);
|
||||
|
||||
const openChat = useCallback(() => setManuallyOpen(true), []);
|
||||
const closeChat = useCallback(() => {
|
||||
setManuallyOpen(false);
|
||||
// Strip the deep-link param so a reload/back-nav doesn't reopen the modal.
|
||||
if (searchParams.get("chat") === "open") {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("chat");
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const value: ChatContextValue = useMemo(
|
||||
() => ({ isChatOpen, openChat, closeChat }),
|
||||
[isChatOpen, openChat, closeChat],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatContext.Provider value={value}>
|
||||
{children}
|
||||
{isChatOpen && (
|
||||
<Suspense>
|
||||
<ChatModal onClose={closeChat} />
|
||||
</Suspense>
|
||||
)}
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext } from "react";
|
||||
import type {
|
||||
ChatMessage,
|
||||
Comment,
|
||||
Dump,
|
||||
Notification,
|
||||
@@ -58,6 +59,12 @@ export interface WSContextValue {
|
||||
lastUserEvent: UserEvent | null;
|
||||
unreadNotificationCount: number;
|
||||
lastNotification: Notification | null;
|
||||
/** Live buffer of chat messages received this session (capped). */
|
||||
chatMessages: ChatMessage[];
|
||||
/** Ids of chat messages deleted live this session, so fetched history can
|
||||
* filter them out. */
|
||||
deletedChatIds: Set<string>;
|
||||
unreadChatCount: number;
|
||||
/** Increments on each WS reconnect so pages can backfill missed events. */
|
||||
connectionEpoch: number;
|
||||
castVote: (dumpId: string) => void;
|
||||
@@ -66,6 +73,13 @@ export interface WSContextValue {
|
||||
removeCommentLike: (commentId: string) => void;
|
||||
injectDump: (dump: Dump) => void;
|
||||
clearUnreadNotifications: () => void;
|
||||
sendChatMessage: (body: string) => void;
|
||||
editChatMessage: (id: string, body: string) => void;
|
||||
deleteChatMessage: (id: string) => void;
|
||||
clearUnreadChat: () => void;
|
||||
/** Inform the server whether the chatbox is open (suppresses redundant
|
||||
* mention notifications while the user is reading chat). */
|
||||
setChatOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const WSContext = createContext<WSContextValue>({
|
||||
@@ -87,6 +101,9 @@ export const WSContext = createContext<WSContextValue>({
|
||||
lastUserEvent: null,
|
||||
unreadNotificationCount: 0,
|
||||
lastNotification: null,
|
||||
chatMessages: [],
|
||||
deletedChatIds: new Set(),
|
||||
unreadChatCount: 0,
|
||||
connectionEpoch: 0,
|
||||
castVote: () => {},
|
||||
removeVote: () => {},
|
||||
@@ -94,4 +111,9 @@ export const WSContext = createContext<WSContextValue>({
|
||||
removeCommentLike: () => {},
|
||||
injectDump: () => {},
|
||||
clearUnreadNotifications: () => {},
|
||||
sendChatMessage: () => {},
|
||||
editChatMessage: () => {},
|
||||
deleteChatMessage: () => {},
|
||||
clearUnreadChat: () => {},
|
||||
setChatOpen: () => {},
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "./AvatarOverrideContext.ts";
|
||||
import { WS_URL } from "../config/api.ts";
|
||||
import type {
|
||||
ChatMessage,
|
||||
Dump,
|
||||
IncomingWSMessage,
|
||||
Notification,
|
||||
@@ -29,6 +30,7 @@ import type {
|
||||
OutgoingWSMessage,
|
||||
} from "../model.ts";
|
||||
import {
|
||||
deserializeChatMessage,
|
||||
deserializeComment,
|
||||
deserializeDump,
|
||||
deserializeNotification,
|
||||
@@ -49,6 +51,9 @@ const CONNECT_TIMEOUT = 2_500;
|
||||
// bound. Feeds merge these on top of their own paginated list, so a generous
|
||||
// cap is plenty — older live arrivals are reachable via normal pagination.
|
||||
const MAX_RECENT_DUMPS = 100;
|
||||
// Cap the live chat buffer so a long-lived session can't grow it without bound.
|
||||
// Older messages remain reachable via the /api/chat history endpoint.
|
||||
const MAX_CHAT_MESSAGES = 200;
|
||||
|
||||
interface PendingVote {
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
@@ -111,6 +116,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
const [lastNotification, setLastNotification] = useState<Notification | null>(
|
||||
null,
|
||||
);
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||
const [deletedChatIds, setDeletedChatIds] = useState<Set<string>>(new Set());
|
||||
const [unreadChatCount, setUnreadChatCount] = useState(0);
|
||||
// Live avatar overrides for site-wide Avatar refresh (own narrow context).
|
||||
const [avatarOverrides, setAvatarOverrides] = useState<
|
||||
Record<string, AvatarOverride>
|
||||
@@ -145,6 +153,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
setLastUserEvent(null);
|
||||
setUnreadNotificationCount(0);
|
||||
setLastNotification(null);
|
||||
setChatMessages([]);
|
||||
setDeletedChatIds(new Set());
|
||||
setUnreadChatCount(0);
|
||||
setAvatarOverrides({});
|
||||
}
|
||||
|
||||
@@ -167,6 +178,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
});
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
// Tracks whether the chatbox is open so it can be re-announced to the server
|
||||
// after a reconnect (the fresh socket starts with the flag cleared).
|
||||
const chatOpenRef = useRef(false);
|
||||
// Tracks pending optimistic votes: dumpId → pending rollback handler
|
||||
const pendingRef = useRef<Map<string, PendingVote>>(
|
||||
new Map(),
|
||||
@@ -291,6 +305,15 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
// dumps/comments they couldn't receive while disconnected.
|
||||
welcomeCount += 1;
|
||||
if (welcomeCount > 1) setConnectionEpoch((n) => n + 1);
|
||||
// Restore the chat-open flag on the fresh connection so mention
|
||||
// suppression keeps working across reconnects.
|
||||
if (chatOpenRef.current) {
|
||||
ws.send(
|
||||
JSON.stringify(
|
||||
{ type: "chat_focus", open: true } satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "presence_update":
|
||||
@@ -474,6 +497,38 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
break;
|
||||
}
|
||||
|
||||
case "chat_message": {
|
||||
const message = deserializeChatMessage(msg.message);
|
||||
setChatMessages((prev) => {
|
||||
if (prev.some((m) => m.id === message.id)) return prev;
|
||||
return [...prev, message].slice(-MAX_CHAT_MESSAGES);
|
||||
});
|
||||
// Count as unread unless it's the current user's own message.
|
||||
if (message.userId !== userIdRef.current) {
|
||||
setUnreadChatCount((prev) => prev + 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "chat_message_updated": {
|
||||
const message = deserializeChatMessage(msg.message);
|
||||
setChatMessages((prev) => {
|
||||
const idx = prev.findIndex((m) => m.id === message.id);
|
||||
if (idx === -1) return [...prev, message];
|
||||
const next = [...prev];
|
||||
next[idx] = message;
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "chat_message_deleted": {
|
||||
const { id } = msg;
|
||||
setDeletedChatIds((prev) => new Set(prev).add(id));
|
||||
setChatMessages((prev) => prev.filter((m) => m.id !== id));
|
||||
break;
|
||||
}
|
||||
|
||||
case "force_logout":
|
||||
onForceLogoutRef.current();
|
||||
break;
|
||||
@@ -704,6 +759,55 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
setUnreadNotificationCount(0);
|
||||
}, []);
|
||||
|
||||
const sendChatMessage = useCallback((body: string) => {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return;
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
JSON.stringify(
|
||||
{ type: "chat_send", body: trimmed } satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const editChatMessage = useCallback((id: string, body: string) => {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return;
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
JSON.stringify(
|
||||
{ type: "chat_edit", id, body: trimmed } satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteChatMessage = useCallback((id: string) => {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
JSON.stringify(
|
||||
{ type: "chat_delete", id } satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearUnreadChat = useCallback(() => {
|
||||
setUnreadChatCount(0);
|
||||
}, []);
|
||||
|
||||
const setChatOpen = useCallback((open: boolean) => {
|
||||
chatOpenRef.current = open;
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
JSON.stringify(
|
||||
{ type: "chat_focus", open } satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value: WSContextValue = useMemo(() => ({
|
||||
wsStatus,
|
||||
wsErrorMessage,
|
||||
@@ -723,6 +827,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
lastUserEvent,
|
||||
unreadNotificationCount,
|
||||
lastNotification,
|
||||
chatMessages,
|
||||
deletedChatIds,
|
||||
unreadChatCount,
|
||||
connectionEpoch,
|
||||
castVote,
|
||||
removeVote,
|
||||
@@ -730,6 +837,11 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
removeCommentLike,
|
||||
injectDump,
|
||||
clearUnreadNotifications,
|
||||
sendChatMessage,
|
||||
editChatMessage,
|
||||
deleteChatMessage,
|
||||
clearUnreadChat,
|
||||
setChatOpen,
|
||||
}), [
|
||||
wsStatus,
|
||||
wsErrorMessage,
|
||||
@@ -749,6 +861,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
lastUserEvent,
|
||||
unreadNotificationCount,
|
||||
lastNotification,
|
||||
chatMessages,
|
||||
deletedChatIds,
|
||||
unreadChatCount,
|
||||
connectionEpoch,
|
||||
castVote,
|
||||
removeVote,
|
||||
@@ -756,6 +871,11 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
removeCommentLike,
|
||||
injectDump,
|
||||
clearUnreadNotifications,
|
||||
sendChatMessage,
|
||||
editChatMessage,
|
||||
deleteChatMessage,
|
||||
clearUnreadChat,
|
||||
setChatOpen,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
6
src/hooks/useChat.ts
Normal file
6
src/hooks/useChat.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { useContext } from "react";
|
||||
import { ChatContext } from "../contexts/ChatContext.ts";
|
||||
|
||||
export function useChat() {
|
||||
return useContext(ChatContext);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -47,8 +47,8 @@ msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||
msgid "← Back"
|
||||
msgstr "← Back"
|
||||
|
||||
#: src/pages/Dump.tsx:261
|
||||
#: src/pages/Dump.tsx:491
|
||||
#: src/pages/Dump.tsx:264
|
||||
#: src/pages/Dump.tsx:494
|
||||
#: src/pages/DumpEdit.tsx:179
|
||||
msgid "← Back to all dumps"
|
||||
msgstr "← Back to all dumps"
|
||||
@@ -67,61 +67,61 @@ msgstr "+ Invite someone"
|
||||
msgid "+ New playlist"
|
||||
msgstr "+ New playlist"
|
||||
|
||||
#: src/pages/Dump.tsx:332
|
||||
#: src/pages/Dump.tsx:335
|
||||
msgid "+ Playlist"
|
||||
msgstr "+ Playlist"
|
||||
|
||||
#. placeholder {0}: d.commenterUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:195
|
||||
#: src/pages/Notifications.tsx:196
|
||||
msgid "<0>{0}</0> commented on <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> commented on <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.followerUsername
|
||||
#. placeholder {1}: d.playlistTitle
|
||||
#: src/pages/Notifications.tsx:155
|
||||
#: src/pages/Notifications.tsx:156
|
||||
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.likerUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:205
|
||||
#: src/pages/Notifications.tsx:206
|
||||
msgid "<0>{0}</0> liked your comment on <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> liked your comment on <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.mentionerUsername
|
||||
#: src/pages/Notifications.tsx:217
|
||||
#: src/pages/Notifications.tsx:222
|
||||
msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
|
||||
msgstr "<0>{0}</0> mentioned you in <1>{where}</1>"
|
||||
|
||||
#. placeholder {0}: d.dumperUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:165
|
||||
#: src/pages/Notifications.tsx:166
|
||||
msgid "<0>{0}</0> posted <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> posted <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.followerUsername
|
||||
#: src/pages/Notifications.tsx:146
|
||||
#: src/pages/Notifications.tsx:147
|
||||
msgid "<0>{0}</0> started following you"
|
||||
msgstr "<0>{0}</0> started following you"
|
||||
|
||||
#. placeholder {0}: d.voterUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:185
|
||||
#: src/pages/Notifications.tsx:186
|
||||
msgid "<0>{0}</0> upvoted <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> upvoted <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.dumpTitle
|
||||
#. placeholder {1}: d.playlistTitle
|
||||
#: src/pages/Notifications.tsx:175
|
||||
#: src/pages/Notifications.tsx:176
|
||||
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> was added to <1>{1}</1>"
|
||||
|
||||
#: src/pages/Notifications.tsx:215
|
||||
#: src/pages/Notifications.tsx:217
|
||||
msgid "a comment"
|
||||
msgstr "a comment"
|
||||
|
||||
#: src/pages/Notifications.tsx:215
|
||||
#: src/pages/Notifications.tsx:220
|
||||
msgid "a post"
|
||||
msgstr "a post"
|
||||
|
||||
@@ -180,17 +180,18 @@ msgstr "Auto"
|
||||
msgid "Back to login"
|
||||
msgstr "Back to login"
|
||||
|
||||
#: src/contexts/WSProvider.tsx:211
|
||||
#: src/contexts/WSProvider.tsx:438
|
||||
#: src/contexts/WSProvider.tsx:268
|
||||
#: src/contexts/WSProvider.tsx:570
|
||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||
|
||||
#: src/components/CategoryManager.tsx:210
|
||||
#: src/components/CategoryManager.tsx:211
|
||||
#: src/components/ChatModal.tsx:158
|
||||
#: src/components/CommentThread.tsx:124
|
||||
#: src/components/ConfirmModal.tsx:32
|
||||
#: src/components/form/FormActions.tsx:32
|
||||
#: src/pages/Dump.tsx:373
|
||||
#: src/pages/Dump.tsx:376
|
||||
#: src/pages/DumpEdit.tsx:463
|
||||
#: src/pages/PlaylistDetail.tsx:920
|
||||
#: src/pages/UserPublicProfile.tsx:1674
|
||||
@@ -202,7 +203,7 @@ msgstr "Cancel"
|
||||
msgid "Cancel removal"
|
||||
msgstr "Cancel removal"
|
||||
|
||||
#: src/components/AppHeader.tsx:65
|
||||
#: src/components/AppHeader.tsx:66
|
||||
msgid "Cancel search"
|
||||
msgstr "Cancel search"
|
||||
|
||||
@@ -228,6 +229,18 @@ msgstr "Change password"
|
||||
msgid "Change password…"
|
||||
msgstr "Change password…"
|
||||
|
||||
#: src/components/ChatButton.tsx:17
|
||||
#: src/components/ChatFab.tsx:49
|
||||
#: src/components/ChatFab.tsx:50
|
||||
#: src/components/ChatModal.tsx:308
|
||||
msgid "Chat"
|
||||
msgstr "Chat"
|
||||
|
||||
#: src/components/ChatButton.tsx:16
|
||||
#: src/components/ChatFab.tsx:48
|
||||
msgid "Chat ({unreadChatCount} unread)"
|
||||
msgstr "Chat ({unreadChatCount} unread)"
|
||||
|
||||
#: src/pages/UserRegister.tsx:85
|
||||
msgid "Checking invite…"
|
||||
msgstr "Checking invite…"
|
||||
@@ -298,6 +311,9 @@ msgstr "Default tab"
|
||||
|
||||
#: src/components/CategoryManager.tsx:221
|
||||
#: src/components/CategoryManager.tsx:222
|
||||
#: src/components/ChatModal.tsx:112
|
||||
#: src/components/ChatModal.tsx:113
|
||||
#: src/components/ChatModal.tsx:168
|
||||
#: src/components/CommentThread.tsx:376
|
||||
#: src/components/CommentThread.tsx:382
|
||||
#: src/components/ConfirmModal.tsx:16
|
||||
@@ -333,6 +349,10 @@ msgstr "Delete this comment?"
|
||||
msgid "Delete this dump? This cannot be undone."
|
||||
msgstr "Delete this dump? This cannot be undone."
|
||||
|
||||
#: src/components/ChatModal.tsx:167
|
||||
msgid "Delete this message?"
|
||||
msgstr "Delete this message?"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:757
|
||||
#: src/pages/UserPlaylists.tsx:467
|
||||
msgid "Delete this playlist? This cannot be undone."
|
||||
@@ -355,7 +375,7 @@ msgstr "Drop a file here"
|
||||
msgid "Drop a replacement here"
|
||||
msgstr "Drop a replacement here"
|
||||
|
||||
#: src/components/AppHeader.tsx:103
|
||||
#: src/components/AppHeader.tsx:105
|
||||
msgid "Dump"
|
||||
msgstr "Dump"
|
||||
|
||||
@@ -379,12 +399,14 @@ msgstr "Dumps"
|
||||
msgid "Dumps ({0}{1})"
|
||||
msgstr "Dumps ({0}{1})"
|
||||
|
||||
#: src/pages/Notifications.tsx:395
|
||||
#: src/pages/Notifications.tsx:400
|
||||
msgid "Earlier"
|
||||
msgstr "Earlier"
|
||||
|
||||
#: src/components/ChatModal.tsx:101
|
||||
#: src/components/ChatModal.tsx:102
|
||||
#: src/components/CommentThread.tsx:367
|
||||
#: src/pages/Dump.tsx:487
|
||||
#: src/pages/Dump.tsx:490
|
||||
#: src/pages/PlaylistDetail.tsx:625
|
||||
msgid "Edit"
|
||||
msgstr "Edit"
|
||||
@@ -400,18 +422,20 @@ msgstr "Edit title"
|
||||
|
||||
#. placeholder {0}: relativeTime(comment.updatedAt)
|
||||
#. placeholder {0}: relativeTime(dump.updatedAt)
|
||||
#. placeholder {0}: relativeTime(playlist.updatedAt)
|
||||
#. placeholder {0}: relativeTime(message.updatedAt)
|
||||
#: src/components/ChatModal.tsx:90
|
||||
#: src/components/CommentThread.tsx:317
|
||||
#: src/pages/Dump.tsx:426
|
||||
#: src/pages/Dump.tsx:429
|
||||
#: src/pages/PlaylistDetail.tsx:664
|
||||
msgid "edited {0}"
|
||||
msgstr "edited {0}"
|
||||
|
||||
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
||||
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
||||
#. placeholder {0}: playlist.updatedAt.toLocaleString()
|
||||
#. placeholder {0}: message.updatedAt.toLocaleString()
|
||||
#: src/components/ChatModal.tsx:88
|
||||
#: src/components/CommentThread.tsx:315
|
||||
#: src/pages/Dump.tsx:424
|
||||
#: src/pages/Dump.tsx:427
|
||||
#: src/pages/PlaylistDetail.tsx:661
|
||||
msgid "Edited {0}"
|
||||
msgstr "Edited {0}"
|
||||
@@ -455,7 +479,7 @@ msgstr "Failed to generate invite"
|
||||
#: src/pages/index/HotFeed.tsx:36
|
||||
#: src/pages/index/JournalFeed.tsx:33
|
||||
#: src/pages/index/NewFeed.tsx:36
|
||||
#: src/pages/Notifications.tsx:369
|
||||
#: src/pages/Notifications.tsx:374
|
||||
#: src/pages/UserPublicProfile.tsx:1022
|
||||
#: src/pages/UserPublicProfile.tsx:1064
|
||||
#: src/pages/UserPublicProfile.tsx:1109
|
||||
@@ -619,20 +643,24 @@ msgstr "Light"
|
||||
msgid "Like"
|
||||
msgstr "Like"
|
||||
|
||||
#: src/contexts/WSProvider.tsx:437
|
||||
#: src/contexts/WSProvider.tsx:569
|
||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||
|
||||
#: src/components/AppHeader.tsx:130
|
||||
#: src/components/AppHeader.tsx:132
|
||||
msgid "Live updates unavailable."
|
||||
msgstr "Live updates unavailable."
|
||||
|
||||
#: src/components/UserListPopover.tsx:231
|
||||
#: src/pages/Notifications.tsx:442
|
||||
#: src/pages/Notifications.tsx:447
|
||||
msgid "Load more"
|
||||
msgstr "Load more"
|
||||
|
||||
#: src/pages/Dump.tsx:237
|
||||
#: src/components/ChatModal.tsx:321
|
||||
msgid "Load older messages"
|
||||
msgstr "Load older messages"
|
||||
|
||||
#: src/pages/Dump.tsx:240
|
||||
#: src/pages/DumpEdit.tsx:155
|
||||
msgid "Loading dump…"
|
||||
msgstr "Loading dump…"
|
||||
@@ -658,6 +686,7 @@ msgid "Loading profile…"
|
||||
msgstr "Loading profile…"
|
||||
|
||||
#: src/components/CategoryManager.tsx:52
|
||||
#: src/components/ChatModal.tsx:320
|
||||
#: src/components/PlaylistMembershipPanel.tsx:28
|
||||
#: src/components/TextEditor.tsx:289
|
||||
#: src/components/UserListPopover.tsx:192
|
||||
@@ -666,8 +695,8 @@ msgstr "Loading profile…"
|
||||
#: src/pages/index/HotFeed.tsx:32
|
||||
#: src/pages/index/JournalFeed.tsx:29
|
||||
#: src/pages/index/NewFeed.tsx:32
|
||||
#: src/pages/Notifications.tsx:365
|
||||
#: src/pages/Notifications.tsx:441
|
||||
#: src/pages/Notifications.tsx:370
|
||||
#: src/pages/Notifications.tsx:446
|
||||
#: src/pages/UserDumps.tsx:54
|
||||
#: src/pages/UserPlaylists.tsx:345
|
||||
#: src/pages/UserPublicProfile.tsx:1016
|
||||
@@ -677,7 +706,7 @@ msgstr "Loading profile…"
|
||||
msgid "Loading…"
|
||||
msgstr "Loading…"
|
||||
|
||||
#: src/components/AppHeader.tsx:111
|
||||
#: src/components/AppHeader.tsx:113
|
||||
#: src/pages/UserLogin.tsx:34
|
||||
#: src/pages/UserLogin.tsx:59
|
||||
#: src/pages/UserLogin.tsx:78
|
||||
@@ -722,7 +751,7 @@ msgstr "Moderator"
|
||||
msgid "Name"
|
||||
msgstr "Name"
|
||||
|
||||
#: src/pages/Notifications.tsx:358
|
||||
#: src/pages/Notifications.tsx:363
|
||||
msgid "new"
|
||||
msgstr "new"
|
||||
|
||||
@@ -776,6 +805,10 @@ msgstr "No followed playlists yet."
|
||||
msgid "No invitees yet."
|
||||
msgstr "No invitees yet."
|
||||
|
||||
#: src/components/ChatModal.tsx:328
|
||||
msgid "No messages yet. Say hello!"
|
||||
msgstr "No messages yet. Say hello!"
|
||||
|
||||
#: src/components/UserListPopover.tsx:200
|
||||
msgid "No one yet."
|
||||
msgstr "No one yet."
|
||||
@@ -798,7 +831,7 @@ msgstr "No users match \"{q}\"."
|
||||
msgid "Not following anyone yet."
|
||||
msgstr "Not following anyone yet."
|
||||
|
||||
#: src/pages/Notifications.tsx:376
|
||||
#: src/pages/Notifications.tsx:381
|
||||
#: src/pages/UserDumps.tsx:99
|
||||
#: src/pages/UserPublicProfile.tsx:1338
|
||||
#: src/pages/UserPublicProfile.tsx:1460
|
||||
@@ -807,8 +840,8 @@ msgid "Nothing here yet."
|
||||
msgstr "Nothing here yet."
|
||||
|
||||
#: src/components/NotificationBell.tsx:42
|
||||
#: src/pages/Notifications.tsx:260
|
||||
#: src/pages/Notifications.tsx:354
|
||||
#: src/pages/Notifications.tsx:265
|
||||
#: src/pages/Notifications.tsx:359
|
||||
msgid "Notifications"
|
||||
msgstr "Notifications"
|
||||
|
||||
@@ -855,7 +888,7 @@ msgstr "Passwords do not match"
|
||||
msgid "Playlist title"
|
||||
msgstr "Playlist title"
|
||||
|
||||
#: src/components/AppHeader.tsx:85
|
||||
#: src/components/AppHeader.tsx:86
|
||||
#: src/components/UserMenu.tsx:62
|
||||
#: src/pages/Search.tsx:177
|
||||
#: src/pages/UserPlaylists.tsx:371
|
||||
@@ -890,7 +923,7 @@ msgstr "Posting…"
|
||||
#: src/components/JournalCard.tsx:121
|
||||
#: src/components/PlaylistCard.tsx:73
|
||||
#: src/components/PlaylistMembershipPanel.tsx:55
|
||||
#: src/pages/Dump.tsx:432
|
||||
#: src/pages/Dump.tsx:435
|
||||
#: src/pages/PlaylistDetail.tsx:644
|
||||
msgid "private"
|
||||
msgstr "private"
|
||||
@@ -932,7 +965,7 @@ msgstr "Registering…"
|
||||
msgid "Registration failed"
|
||||
msgstr "Registration failed"
|
||||
|
||||
#: src/pages/Dump.tsx:499
|
||||
#: src/pages/Dump.tsx:502
|
||||
msgid "Related"
|
||||
msgstr "Related"
|
||||
|
||||
@@ -977,7 +1010,7 @@ msgstr "Reset password"
|
||||
msgid "Reset to default"
|
||||
msgstr "Reset to default"
|
||||
|
||||
#: src/pages/Dump.tsx:254
|
||||
#: src/pages/Dump.tsx:257
|
||||
#: src/pages/DumpEdit.tsx:172
|
||||
msgid "Retry"
|
||||
msgstr "Retry"
|
||||
@@ -986,8 +1019,9 @@ msgstr "Retry"
|
||||
msgid "Role"
|
||||
msgstr "Role"
|
||||
|
||||
#: src/components/ChatModal.tsx:151
|
||||
#: src/components/CommentThread.tsx:328
|
||||
#: src/pages/Dump.tsx:365
|
||||
#: src/pages/Dump.tsx:368
|
||||
#: src/pages/DumpEdit.tsx:466
|
||||
#: src/pages/PlaylistDetail.tsx:927
|
||||
#: src/pages/UserPublicProfile.tsx:1666
|
||||
@@ -997,7 +1031,7 @@ msgstr "Save"
|
||||
|
||||
#: src/components/ChangePasswordModal.tsx:100
|
||||
#: src/components/CommentThread.tsx:329
|
||||
#: src/pages/Dump.tsx:364
|
||||
#: src/pages/Dump.tsx:367
|
||||
#: src/pages/PlaylistDetail.tsx:923
|
||||
#: src/pages/ResetPassword.tsx:126
|
||||
#: src/pages/UserPublicProfile.tsx:1663
|
||||
@@ -1005,7 +1039,7 @@ msgstr "Save"
|
||||
msgid "Saving…"
|
||||
msgstr "Saving…"
|
||||
|
||||
#: src/components/AppHeader.tsx:65
|
||||
#: src/components/AppHeader.tsx:66
|
||||
#: src/components/SearchBar.tsx:78
|
||||
#: src/pages/Search.tsx:50
|
||||
msgid "Search"
|
||||
@@ -1023,6 +1057,10 @@ msgstr "Search failed"
|
||||
msgid "Searching…"
|
||||
msgstr "Searching…"
|
||||
|
||||
#: src/components/ChatModal.tsx:369
|
||||
msgid "Send"
|
||||
msgstr "Send"
|
||||
|
||||
#: src/pages/UserLogin.tsx:141
|
||||
msgid "Send reset link"
|
||||
msgstr "Send reset link"
|
||||
@@ -1031,7 +1069,7 @@ msgstr "Send reset link"
|
||||
msgid "Sending…"
|
||||
msgstr "Sending…"
|
||||
|
||||
#: src/components/AppHeader.tsx:100
|
||||
#: src/components/AppHeader.tsx:102
|
||||
msgid "Server unreachable"
|
||||
msgstr "Server unreachable"
|
||||
|
||||
@@ -1061,6 +1099,10 @@ msgstr "Style"
|
||||
msgid "Submit search"
|
||||
msgstr "Submit search"
|
||||
|
||||
#: src/pages/Notifications.tsx:219
|
||||
msgid "the chat"
|
||||
msgstr "the chat"
|
||||
|
||||
#: src/pages/UserRegister.tsx:97
|
||||
msgid "This invite link is missing, expired, or already used."
|
||||
msgstr "This invite link is missing, expired, or already used."
|
||||
@@ -1091,10 +1133,14 @@ msgstr "Title"
|
||||
msgid "Title is required."
|
||||
msgstr "Title is required."
|
||||
|
||||
#: src/pages/Notifications.tsx:392
|
||||
#: src/pages/Notifications.tsx:397
|
||||
msgid "Today"
|
||||
msgstr "Today"
|
||||
|
||||
#: src/components/ChatModal.tsx:350
|
||||
msgid "Type a message…"
|
||||
msgstr "Type a message…"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:747
|
||||
msgid "Undo"
|
||||
msgstr "Undo"
|
||||
@@ -1194,11 +1240,11 @@ msgstr "Why?"
|
||||
msgid "Write a reply…"
|
||||
msgstr "Write a reply…"
|
||||
|
||||
#: src/pages/Notifications.tsx:394
|
||||
#: src/pages/Notifications.tsx:399
|
||||
msgid "Yesterday"
|
||||
msgstr "Yesterday"
|
||||
|
||||
#: src/pages/Notifications.tsx:379
|
||||
#: src/pages/Notifications.tsx:384
|
||||
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -47,8 +47,8 @@ msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
|
||||
msgid "← Back"
|
||||
msgstr "← Retour"
|
||||
|
||||
#: src/pages/Dump.tsx:261
|
||||
#: src/pages/Dump.tsx:491
|
||||
#: src/pages/Dump.tsx:264
|
||||
#: src/pages/Dump.tsx:494
|
||||
#: src/pages/DumpEdit.tsx:179
|
||||
msgid "← Back to all dumps"
|
||||
msgstr "← Retour à toutes les recos"
|
||||
@@ -67,61 +67,61 @@ msgstr "+ Inviter quelqu'un"
|
||||
msgid "+ New playlist"
|
||||
msgstr "+ Nouvelle collection"
|
||||
|
||||
#: src/pages/Dump.tsx:332
|
||||
#: src/pages/Dump.tsx:335
|
||||
msgid "+ Playlist"
|
||||
msgstr "+ Collection"
|
||||
|
||||
#. placeholder {0}: d.commenterUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:195
|
||||
#: src/pages/Notifications.tsx:196
|
||||
msgid "<0>{0}</0> commented on <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a commenté sur <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.followerUsername
|
||||
#. placeholder {1}: d.playlistTitle
|
||||
#: src/pages/Notifications.tsx:155
|
||||
#: src/pages/Notifications.tsx:156
|
||||
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a suivi votre collection <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.likerUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:205
|
||||
#: src/pages/Notifications.tsx:206
|
||||
msgid "<0>{0}</0> liked your comment on <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a aimé votre commentaire sur <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.mentionerUsername
|
||||
#: src/pages/Notifications.tsx:217
|
||||
#: src/pages/Notifications.tsx:222
|
||||
msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
|
||||
msgstr "<0>{0}</0> vous a mentionné dans <1>{where}</1>"
|
||||
|
||||
#. placeholder {0}: d.dumperUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:165
|
||||
#: src/pages/Notifications.tsx:166
|
||||
msgid "<0>{0}</0> posted <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a publié <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.followerUsername
|
||||
#: src/pages/Notifications.tsx:146
|
||||
#: src/pages/Notifications.tsx:147
|
||||
msgid "<0>{0}</0> started following you"
|
||||
msgstr "<0>{0}</0> a commencé à vous suivre"
|
||||
|
||||
#. placeholder {0}: d.voterUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:185
|
||||
#: src/pages/Notifications.tsx:186
|
||||
msgid "<0>{0}</0> upvoted <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a voté pour <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.dumpTitle
|
||||
#. placeholder {1}: d.playlistTitle
|
||||
#: src/pages/Notifications.tsx:175
|
||||
#: src/pages/Notifications.tsx:176
|
||||
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
|
||||
|
||||
#: src/pages/Notifications.tsx:215
|
||||
#: src/pages/Notifications.tsx:217
|
||||
msgid "a comment"
|
||||
msgstr "un commentaire"
|
||||
|
||||
#: src/pages/Notifications.tsx:215
|
||||
#: src/pages/Notifications.tsx:220
|
||||
msgid "a post"
|
||||
msgstr "une publication"
|
||||
|
||||
@@ -180,17 +180,18 @@ msgstr "Auto"
|
||||
msgid "Back to login"
|
||||
msgstr "Retour à la connexion"
|
||||
|
||||
#: src/contexts/WSProvider.tsx:211
|
||||
#: src/contexts/WSProvider.tsx:438
|
||||
#: src/contexts/WSProvider.tsx:268
|
||||
#: src/contexts/WSProvider.tsx:570
|
||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
|
||||
|
||||
#: src/components/CategoryManager.tsx:210
|
||||
#: src/components/CategoryManager.tsx:211
|
||||
#: src/components/ChatModal.tsx:158
|
||||
#: src/components/CommentThread.tsx:124
|
||||
#: src/components/ConfirmModal.tsx:32
|
||||
#: src/components/form/FormActions.tsx:32
|
||||
#: src/pages/Dump.tsx:373
|
||||
#: src/pages/Dump.tsx:376
|
||||
#: src/pages/DumpEdit.tsx:463
|
||||
#: src/pages/PlaylistDetail.tsx:920
|
||||
#: src/pages/UserPublicProfile.tsx:1674
|
||||
@@ -202,7 +203,7 @@ msgstr "Annuler"
|
||||
msgid "Cancel removal"
|
||||
msgstr "Annuler la suppression"
|
||||
|
||||
#: src/components/AppHeader.tsx:65
|
||||
#: src/components/AppHeader.tsx:66
|
||||
msgid "Cancel search"
|
||||
msgstr "Annuler la recherche"
|
||||
|
||||
@@ -228,6 +229,18 @@ msgstr "Changer le mot de passe"
|
||||
msgid "Change password…"
|
||||
msgstr "Changer le mot de passe…"
|
||||
|
||||
#: src/components/ChatButton.tsx:17
|
||||
#: src/components/ChatFab.tsx:49
|
||||
#: src/components/ChatFab.tsx:50
|
||||
#: src/components/ChatModal.tsx:308
|
||||
msgid "Chat"
|
||||
msgstr "Tribune"
|
||||
|
||||
#: src/components/ChatButton.tsx:16
|
||||
#: src/components/ChatFab.tsx:48
|
||||
msgid "Chat ({unreadChatCount} unread)"
|
||||
msgstr "{unreadChatCount} messages non lus"
|
||||
|
||||
#: src/pages/UserRegister.tsx:85
|
||||
msgid "Checking invite…"
|
||||
msgstr "Vérification de l'invitation…"
|
||||
@@ -298,6 +311,9 @@ msgstr "Onglet par défaut"
|
||||
|
||||
#: src/components/CategoryManager.tsx:221
|
||||
#: src/components/CategoryManager.tsx:222
|
||||
#: src/components/ChatModal.tsx:112
|
||||
#: src/components/ChatModal.tsx:113
|
||||
#: src/components/ChatModal.tsx:168
|
||||
#: src/components/CommentThread.tsx:376
|
||||
#: src/components/CommentThread.tsx:382
|
||||
#: src/components/ConfirmModal.tsx:16
|
||||
@@ -333,6 +349,10 @@ msgstr "Supprimer ce commentaire ?"
|
||||
msgid "Delete this dump? This cannot be undone."
|
||||
msgstr "Supprimer cette reco ? Cette action est irréversible."
|
||||
|
||||
#: src/components/ChatModal.tsx:167
|
||||
msgid "Delete this message?"
|
||||
msgstr "Supprimer ce message ?"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:757
|
||||
#: src/pages/UserPlaylists.tsx:467
|
||||
msgid "Delete this playlist? This cannot be undone."
|
||||
@@ -355,7 +375,7 @@ msgstr "Déposez un fichier ici"
|
||||
msgid "Drop a replacement here"
|
||||
msgstr "Déposez un fichier de remplacement ici"
|
||||
|
||||
#: src/components/AppHeader.tsx:103
|
||||
#: src/components/AppHeader.tsx:105
|
||||
msgid "Dump"
|
||||
msgstr "Reco"
|
||||
|
||||
@@ -379,12 +399,14 @@ msgstr "Recos"
|
||||
msgid "Dumps ({0}{1})"
|
||||
msgstr "Recos ({0}{1})"
|
||||
|
||||
#: src/pages/Notifications.tsx:395
|
||||
#: src/pages/Notifications.tsx:400
|
||||
msgid "Earlier"
|
||||
msgstr "Plus tôt"
|
||||
|
||||
#: src/components/ChatModal.tsx:101
|
||||
#: src/components/ChatModal.tsx:102
|
||||
#: src/components/CommentThread.tsx:367
|
||||
#: src/pages/Dump.tsx:487
|
||||
#: src/pages/Dump.tsx:490
|
||||
#: src/pages/PlaylistDetail.tsx:625
|
||||
msgid "Edit"
|
||||
msgstr "Modifier"
|
||||
@@ -400,18 +422,20 @@ msgstr "Modifier le titre"
|
||||
|
||||
#. placeholder {0}: relativeTime(comment.updatedAt)
|
||||
#. placeholder {0}: relativeTime(dump.updatedAt)
|
||||
#. placeholder {0}: relativeTime(playlist.updatedAt)
|
||||
#. placeholder {0}: relativeTime(message.updatedAt)
|
||||
#: src/components/ChatModal.tsx:90
|
||||
#: src/components/CommentThread.tsx:317
|
||||
#: src/pages/Dump.tsx:426
|
||||
#: src/pages/Dump.tsx:429
|
||||
#: src/pages/PlaylistDetail.tsx:664
|
||||
msgid "edited {0}"
|
||||
msgstr "modifié {0}"
|
||||
|
||||
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
||||
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
||||
#. placeholder {0}: playlist.updatedAt.toLocaleString()
|
||||
#. placeholder {0}: message.updatedAt.toLocaleString()
|
||||
#: src/components/ChatModal.tsx:88
|
||||
#: src/components/CommentThread.tsx:315
|
||||
#: src/pages/Dump.tsx:424
|
||||
#: src/pages/Dump.tsx:427
|
||||
#: src/pages/PlaylistDetail.tsx:661
|
||||
msgid "Edited {0}"
|
||||
msgstr "Modifié le {0}"
|
||||
@@ -455,7 +479,7 @@ msgstr "Impossible de générer une invitation"
|
||||
#: src/pages/index/HotFeed.tsx:36
|
||||
#: src/pages/index/JournalFeed.tsx:33
|
||||
#: src/pages/index/NewFeed.tsx:36
|
||||
#: src/pages/Notifications.tsx:369
|
||||
#: src/pages/Notifications.tsx:374
|
||||
#: src/pages/UserPublicProfile.tsx:1022
|
||||
#: src/pages/UserPublicProfile.tsx:1064
|
||||
#: src/pages/UserPublicProfile.tsx:1109
|
||||
@@ -619,20 +643,24 @@ msgstr "Clair"
|
||||
msgid "Like"
|
||||
msgstr "Aimer"
|
||||
|
||||
#: src/contexts/WSProvider.tsx:437
|
||||
#: src/contexts/WSProvider.tsx:569
|
||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
||||
|
||||
#: src/components/AppHeader.tsx:130
|
||||
#: src/components/AppHeader.tsx:132
|
||||
msgid "Live updates unavailable."
|
||||
msgstr "Mises à jour en direct indisponibles."
|
||||
|
||||
#: src/components/UserListPopover.tsx:231
|
||||
#: src/pages/Notifications.tsx:442
|
||||
#: src/pages/Notifications.tsx:447
|
||||
msgid "Load more"
|
||||
msgstr "Charger plus"
|
||||
|
||||
#: src/pages/Dump.tsx:237
|
||||
#: src/components/ChatModal.tsx:321
|
||||
msgid "Load older messages"
|
||||
msgstr "Charger les messages plus anciens"
|
||||
|
||||
#: src/pages/Dump.tsx:240
|
||||
#: src/pages/DumpEdit.tsx:155
|
||||
msgid "Loading dump…"
|
||||
msgstr "Chargement de la reco…"
|
||||
@@ -658,6 +686,7 @@ msgid "Loading profile…"
|
||||
msgstr "Chargement du profil…"
|
||||
|
||||
#: src/components/CategoryManager.tsx:52
|
||||
#: src/components/ChatModal.tsx:320
|
||||
#: src/components/PlaylistMembershipPanel.tsx:28
|
||||
#: src/components/TextEditor.tsx:289
|
||||
#: src/components/UserListPopover.tsx:192
|
||||
@@ -666,8 +695,8 @@ msgstr "Chargement du profil…"
|
||||
#: src/pages/index/HotFeed.tsx:32
|
||||
#: src/pages/index/JournalFeed.tsx:29
|
||||
#: src/pages/index/NewFeed.tsx:32
|
||||
#: src/pages/Notifications.tsx:365
|
||||
#: src/pages/Notifications.tsx:441
|
||||
#: src/pages/Notifications.tsx:370
|
||||
#: src/pages/Notifications.tsx:446
|
||||
#: src/pages/UserDumps.tsx:54
|
||||
#: src/pages/UserPlaylists.tsx:345
|
||||
#: src/pages/UserPublicProfile.tsx:1016
|
||||
@@ -677,7 +706,7 @@ msgstr "Chargement du profil…"
|
||||
msgid "Loading…"
|
||||
msgstr "Chargement…"
|
||||
|
||||
#: src/components/AppHeader.tsx:111
|
||||
#: src/components/AppHeader.tsx:113
|
||||
#: src/pages/UserLogin.tsx:34
|
||||
#: src/pages/UserLogin.tsx:59
|
||||
#: src/pages/UserLogin.tsx:78
|
||||
@@ -722,7 +751,7 @@ msgstr "Modérateur"
|
||||
msgid "Name"
|
||||
msgstr "Nom"
|
||||
|
||||
#: src/pages/Notifications.tsx:358
|
||||
#: src/pages/Notifications.tsx:363
|
||||
msgid "new"
|
||||
msgstr "nouveau"
|
||||
|
||||
@@ -776,6 +805,10 @@ msgstr "Pas encore de collections suivies."
|
||||
msgid "No invitees yet."
|
||||
msgstr "Aucun invité pour le moment."
|
||||
|
||||
#: src/components/ChatModal.tsx:328
|
||||
msgid "No messages yet. Say hello!"
|
||||
msgstr "Aucun message pour l'instant. Dites bonjour !"
|
||||
|
||||
#: src/components/UserListPopover.tsx:200
|
||||
msgid "No one yet."
|
||||
msgstr "Personne pour le moment."
|
||||
@@ -798,7 +831,7 @@ msgstr "Aucun utilisateur ne correspond à « {q} »."
|
||||
msgid "Not following anyone yet."
|
||||
msgstr "Aucun abonnement pour le moment."
|
||||
|
||||
#: src/pages/Notifications.tsx:376
|
||||
#: src/pages/Notifications.tsx:381
|
||||
#: src/pages/UserDumps.tsx:99
|
||||
#: src/pages/UserPublicProfile.tsx:1338
|
||||
#: src/pages/UserPublicProfile.tsx:1460
|
||||
@@ -807,8 +840,8 @@ msgid "Nothing here yet."
|
||||
msgstr "Rien ici pour l'instant."
|
||||
|
||||
#: src/components/NotificationBell.tsx:42
|
||||
#: src/pages/Notifications.tsx:260
|
||||
#: src/pages/Notifications.tsx:354
|
||||
#: src/pages/Notifications.tsx:265
|
||||
#: src/pages/Notifications.tsx:359
|
||||
msgid "Notifications"
|
||||
msgstr "Notifications"
|
||||
|
||||
@@ -855,7 +888,7 @@ msgstr "Les mots de passe ne correspondent pas"
|
||||
msgid "Playlist title"
|
||||
msgstr "Titre de la collection"
|
||||
|
||||
#: src/components/AppHeader.tsx:85
|
||||
#: src/components/AppHeader.tsx:86
|
||||
#: src/components/UserMenu.tsx:62
|
||||
#: src/pages/Search.tsx:177
|
||||
#: src/pages/UserPlaylists.tsx:371
|
||||
@@ -890,7 +923,7 @@ msgstr "Publication…"
|
||||
#: src/components/JournalCard.tsx:121
|
||||
#: src/components/PlaylistCard.tsx:73
|
||||
#: src/components/PlaylistMembershipPanel.tsx:55
|
||||
#: src/pages/Dump.tsx:432
|
||||
#: src/pages/Dump.tsx:435
|
||||
#: src/pages/PlaylistDetail.tsx:644
|
||||
msgid "private"
|
||||
msgstr "privé"
|
||||
@@ -932,7 +965,7 @@ msgstr "Inscription…"
|
||||
msgid "Registration failed"
|
||||
msgstr "Inscription échouée"
|
||||
|
||||
#: src/pages/Dump.tsx:499
|
||||
#: src/pages/Dump.tsx:502
|
||||
msgid "Related"
|
||||
msgstr "Connexe"
|
||||
|
||||
@@ -977,7 +1010,7 @@ msgstr "Réinitialiser le mot de passe"
|
||||
msgid "Reset to default"
|
||||
msgstr "Réinitialiser par défaut"
|
||||
|
||||
#: src/pages/Dump.tsx:254
|
||||
#: src/pages/Dump.tsx:257
|
||||
#: src/pages/DumpEdit.tsx:172
|
||||
msgid "Retry"
|
||||
msgstr "Réessayer"
|
||||
@@ -986,8 +1019,9 @@ msgstr "Réessayer"
|
||||
msgid "Role"
|
||||
msgstr "Rôle"
|
||||
|
||||
#: src/components/ChatModal.tsx:151
|
||||
#: src/components/CommentThread.tsx:328
|
||||
#: src/pages/Dump.tsx:365
|
||||
#: src/pages/Dump.tsx:368
|
||||
#: src/pages/DumpEdit.tsx:466
|
||||
#: src/pages/PlaylistDetail.tsx:927
|
||||
#: src/pages/UserPublicProfile.tsx:1666
|
||||
@@ -997,7 +1031,7 @@ msgstr "Enregistrer"
|
||||
|
||||
#: src/components/ChangePasswordModal.tsx:100
|
||||
#: src/components/CommentThread.tsx:329
|
||||
#: src/pages/Dump.tsx:364
|
||||
#: src/pages/Dump.tsx:367
|
||||
#: src/pages/PlaylistDetail.tsx:923
|
||||
#: src/pages/ResetPassword.tsx:126
|
||||
#: src/pages/UserPublicProfile.tsx:1663
|
||||
@@ -1005,7 +1039,7 @@ msgstr "Enregistrer"
|
||||
msgid "Saving…"
|
||||
msgstr "Enregistrement…"
|
||||
|
||||
#: src/components/AppHeader.tsx:65
|
||||
#: src/components/AppHeader.tsx:66
|
||||
#: src/components/SearchBar.tsx:78
|
||||
#: src/pages/Search.tsx:50
|
||||
msgid "Search"
|
||||
@@ -1023,6 +1057,10 @@ msgstr "Recherche échouée"
|
||||
msgid "Searching…"
|
||||
msgstr "Recherche…"
|
||||
|
||||
#: src/components/ChatModal.tsx:369
|
||||
msgid "Send"
|
||||
msgstr "Envoyer"
|
||||
|
||||
#: src/pages/UserLogin.tsx:141
|
||||
msgid "Send reset link"
|
||||
msgstr "Envoyer le lien de réinitialisation"
|
||||
@@ -1031,7 +1069,7 @@ msgstr "Envoyer le lien de réinitialisation"
|
||||
msgid "Sending…"
|
||||
msgstr "Envoi…"
|
||||
|
||||
#: src/components/AppHeader.tsx:100
|
||||
#: src/components/AppHeader.tsx:102
|
||||
msgid "Server unreachable"
|
||||
msgstr "Serveur inaccessible"
|
||||
|
||||
@@ -1061,6 +1099,10 @@ msgstr "Style"
|
||||
msgid "Submit search"
|
||||
msgstr "Lancer la recherche"
|
||||
|
||||
#: src/pages/Notifications.tsx:219
|
||||
msgid "the chat"
|
||||
msgstr "la tribune"
|
||||
|
||||
#: src/pages/UserRegister.tsx:97
|
||||
msgid "This invite link is missing, expired, or already used."
|
||||
msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé."
|
||||
@@ -1091,10 +1133,14 @@ msgstr "Titre"
|
||||
msgid "Title is required."
|
||||
msgstr "Un titre est requis."
|
||||
|
||||
#: src/pages/Notifications.tsx:392
|
||||
#: src/pages/Notifications.tsx:397
|
||||
msgid "Today"
|
||||
msgstr "Aujourd'hui"
|
||||
|
||||
#: src/components/ChatModal.tsx:350
|
||||
msgid "Type a message…"
|
||||
msgstr "Écrivez un message…"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:747
|
||||
msgid "Undo"
|
||||
msgstr "Annuler"
|
||||
@@ -1194,11 +1240,11 @@ msgstr "Pourquoi ?"
|
||||
msgid "Write a reply…"
|
||||
msgstr "Écrire une réponse…"
|
||||
|
||||
#: src/pages/Notifications.tsx:394
|
||||
#: src/pages/Notifications.tsx:399
|
||||
msgid "Yesterday"
|
||||
msgstr "Hier"
|
||||
|
||||
#: src/pages/Notifications.tsx:379
|
||||
#: src/pages/Notifications.tsx:384
|
||||
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."
|
||||
|
||||
|
||||
73
src/model.ts
73
src/model.ts
@@ -216,6 +216,30 @@ export function deserializeComment(raw: RawComment): Comment {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat
|
||||
*/
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
userId: string;
|
||||
body: string;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
authorUsername: string;
|
||||
authorAvatarMime?: string;
|
||||
}
|
||||
|
||||
export type RawChatMessage = WithStringDate<ChatMessage>;
|
||||
|
||||
export function deserializeChatMessage(raw: RawChatMessage): ChatMessage {
|
||||
return {
|
||||
...raw,
|
||||
createdAt: new Date(raw.createdAt),
|
||||
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Playlists
|
||||
*/
|
||||
@@ -328,7 +352,7 @@ export interface DumpUpvotedData {
|
||||
export interface UserMentionedData {
|
||||
mentionerId: string;
|
||||
mentionerUsername: string;
|
||||
contextType: "comment" | "dump" | "playlist";
|
||||
contextType: "comment" | "dump" | "playlist" | "chat";
|
||||
contextId: string;
|
||||
contextTitle: string;
|
||||
dumpId?: string;
|
||||
@@ -498,6 +522,21 @@ export interface WSForceLogoutMessage {
|
||||
type: "force_logout";
|
||||
}
|
||||
|
||||
export interface WSChatMessageMessage {
|
||||
type: "chat_message";
|
||||
message: RawChatMessage;
|
||||
}
|
||||
|
||||
export interface WSChatMessageUpdatedMessage {
|
||||
type: "chat_message_updated";
|
||||
message: RawChatMessage;
|
||||
}
|
||||
|
||||
export interface WSChatMessageDeletedMessage {
|
||||
type: "chat_message_deleted";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type IncomingWSMessage =
|
||||
| WSPingMessage
|
||||
| WSWelcomeMessage
|
||||
@@ -519,7 +558,10 @@ export type IncomingWSMessage =
|
||||
| WSCommentDeletedMessage
|
||||
| WSNotificationCreatedMessage
|
||||
| WSErrorMessage
|
||||
| WSForceLogoutMessage;
|
||||
| WSForceLogoutMessage
|
||||
| WSChatMessageMessage
|
||||
| WSChatMessageUpdatedMessage
|
||||
| WSChatMessageDeletedMessage;
|
||||
|
||||
/**
|
||||
* WebSocket messages — client → server (outgoing)
|
||||
@@ -545,12 +587,37 @@ export interface WSCommentLikeRemoveMessage {
|
||||
commentId: string;
|
||||
}
|
||||
|
||||
export interface WSChatSendMessage {
|
||||
type: "chat_send";
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface WSChatFocusMessage {
|
||||
type: "chat_focus";
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export interface WSChatEditMessage {
|
||||
type: "chat_edit";
|
||||
id: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface WSChatDeleteMessage {
|
||||
type: "chat_delete";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type OutgoingWSMessage =
|
||||
| WSPongMessage
|
||||
| WSVoteCastMessage
|
||||
| WSVoteRemoveMessage
|
||||
| WSCommentLikeCastMessage
|
||||
| WSCommentLikeRemoveMessage;
|
||||
| WSCommentLikeRemoveMessage
|
||||
| WSChatSendMessage
|
||||
| WSChatFocusMessage
|
||||
| WSChatEditMessage
|
||||
| WSChatDeleteMessage;
|
||||
|
||||
/**
|
||||
* Follows
|
||||
|
||||
@@ -132,6 +132,7 @@ function notificationLink(n: Notification): string {
|
||||
return `/dumps/${d.dumpId}#comment-${d.contextId}`;
|
||||
}
|
||||
if (d.contextType === "dump") return `/dumps/${d.contextId}`;
|
||||
if (d.contextType === "chat") return `/?chat=open`;
|
||||
return `/playlists/${d.contextId}`;
|
||||
}
|
||||
}
|
||||
@@ -212,7 +213,11 @@ function notificationContent(n: Notification): React.ReactNode {
|
||||
case "user_mentioned": {
|
||||
const d = data as UserMentionedData;
|
||||
const where = d.contextTitle ||
|
||||
(d.contextType === "comment" ? t`a comment` : t`a post`);
|
||||
(d.contextType === "comment"
|
||||
? t`a comment`
|
||||
: d.contextType === "chat"
|
||||
? t`the chat`
|
||||
: t`a post`);
|
||||
return (
|
||||
<Trans>
|
||||
<strong>{d.mentionerUsername}</strong>
|
||||
|
||||
@@ -151,11 +151,12 @@
|
||||
/* ── Buttons — square corners, modest hard shadow ────────────────── */
|
||||
/* `.dump-fab` is exempt from the generic button chrome — it keeps its own
|
||||
shape, shadow and animations — but this hard-edged theme squares it off. */
|
||||
[data-style="brutalist"] .dump-fab {
|
||||
[data-style="brutalist"] .dump-fab,
|
||||
[data-style="brutalist"] .chat-fab {
|
||||
--fab-radius: 0;
|
||||
}
|
||||
|
||||
[data-style="brutalist"] button:not(.dump-fab) {
|
||||
[data-style="brutalist"] button:not(.dump-fab, .chat-fab) {
|
||||
border-radius: 0;
|
||||
border: 2px solid var(--color-border);
|
||||
font-weight: 700;
|
||||
@@ -163,7 +164,7 @@
|
||||
transition: box-shadow 0.08s ease, transform 0.08s ease, border-color 0.1s;
|
||||
}
|
||||
|
||||
[data-style="brutalist"] button:not(.dump-fab):hover:not(:disabled) {
|
||||
[data-style="brutalist"] button:not(.dump-fab, .chat-fab):hover:not(:disabled) {
|
||||
border-color: var(--color-border);
|
||||
box-shadow: none;
|
||||
transform: translate(2px, 2px);
|
||||
|
||||
@@ -254,11 +254,12 @@
|
||||
/* ── Buttons — chunky Win95 outset bevel, press = inset ──────────── */
|
||||
/* `.dump-fab` is exempt from the generic button chrome — it keeps its own
|
||||
shape, glow and animations — but this squared-off retro theme squares it. */
|
||||
[data-style="geocities"] .dump-fab {
|
||||
[data-style="geocities"] .dump-fab,
|
||||
[data-style="geocities"] .chat-fab {
|
||||
--fab-radius: 0;
|
||||
}
|
||||
|
||||
[data-style="geocities"] button:not(.dump-fab) {
|
||||
[data-style="geocities"] button:not(.dump-fab, .chat-fab) {
|
||||
border-radius: 0;
|
||||
border: 3px outset var(--color-border);
|
||||
box-shadow: none;
|
||||
@@ -325,7 +326,10 @@
|
||||
|
||||
[data-style="geocities"] .visibility-toggle button {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
/* Zero the width (not just the style): the generic `button:active` rule flips
|
||||
border-style to `inset`, and a `none` border keeps its `medium` width — so
|
||||
on press a 3px border would reappear inside the toggle's own frame. */
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ── Cards & panels — ridged "table border" with a soft neon halo ─── */
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
|
||||
/* Sharp, rectilinear corners throughout — newsprint has no rounding. */
|
||||
[data-style="nyt"] button:not(.dump-fab),
|
||||
[data-style="nyt"] button:not(.dump-fab, .chat-fab),
|
||||
[data-style="nyt"] .dump-card,
|
||||
[data-style="nyt"] .playlist-card,
|
||||
[data-style="nyt"] .journal-card,
|
||||
@@ -184,7 +184,8 @@
|
||||
|
||||
/* This theme squares everything off — opt the FAB into a square too (it keeps
|
||||
its animations; only the corners change). */
|
||||
[data-style="nyt"] .dump-fab {
|
||||
[data-style="nyt"] .dump-fab,
|
||||
[data-style="nyt"] .chat-fab {
|
||||
--fab-radius: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export type Permission =
|
||||
| "dump:moderate"
|
||||
| "comment:moderate"
|
||||
| "playlist:moderate"
|
||||
| "chat:moderate"
|
||||
| "user:manage"
|
||||
| "category:manage";
|
||||
|
||||
@@ -12,13 +13,19 @@ const ALL_PERMISSIONS: readonly Permission[] = [
|
||||
"dump:moderate",
|
||||
"comment:moderate",
|
||||
"playlist:moderate",
|
||||
"chat:moderate",
|
||||
"user:manage",
|
||||
"category:manage",
|
||||
];
|
||||
|
||||
const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
|
||||
admin: ALL_PERMISSIONS,
|
||||
moderator: ["dump:moderate", "comment:moderate", "playlist:moderate"],
|
||||
moderator: [
|
||||
"dump:moderate",
|
||||
"comment:moderate",
|
||||
"playlist:moderate",
|
||||
"chat:moderate",
|
||||
],
|
||||
user: [],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user