Improve messaging and dashboard reliability
This commit is contained in:
1 parent
345ec99348
commit
5a5a30a1d4
20 files changed
+406
-56
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
|
||||
import { ConversationDetail } from './conversation-detail.js';
|
||||
@@ -8,6 +8,7 @@ import { ProcessedUserLookup } from './processed-user-lookup.js';
|
||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||
import './conversations.css';
|
||||
import { ownedConversationId } from './conversation-identity.js';
|
||||
import { formatExactTime, formatShortRelativeTime } from './relative-time.js';
|
||||
|
||||
export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply' | 'received-images';
|
||||
|
||||
@@ -30,9 +31,10 @@ function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): bo
|
||||
return true;
|
||||
}
|
||||
|
||||
function ConversationListItem({ conversation, isSelected, onSelect }: {
|
||||
function ConversationListItem({ conversation, isSelected, now, onSelect }: {
|
||||
conversation: ActiveChat;
|
||||
isSelected: boolean;
|
||||
now: number;
|
||||
onSelect(): void;
|
||||
}) {
|
||||
const state = conversation.unread ? 'Unread' : conversation.isFromUs ? 'Awaiting reply' : 'Up to date';
|
||||
@@ -45,7 +47,15 @@ function ConversationListItem({ conversation, isSelected, onSelect }: {
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
/>
|
||||
<span className="conversation-list-heading"><strong><RedditUsernameLink username={conversation.username} /></strong><small>{state}</small></span>
|
||||
<span className="conversation-list-heading">
|
||||
<strong><RedditUsernameLink username={conversation.username} /></strong>
|
||||
<span className="conversation-list-meta">
|
||||
<small>{state}</small>
|
||||
<time dateTime={new Date(conversation.lastMessageTime).toISOString()} title={formatExactTime(conversation.lastMessageTime)}>
|
||||
{formatShortRelativeTime(conversation.lastMessageTime, now)}
|
||||
</time>
|
||||
</span>
|
||||
</span>
|
||||
<AccountOwnerBadge owner={conversation.owner} />
|
||||
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
|
||||
<span>{conversation.lastMessageSnippet || 'No message preview'}</span>
|
||||
@@ -58,9 +68,15 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
||||
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(null);
|
||||
const [localFilters, setLocalFilters] = useState<ConversationFilters>({ search: '', filter: 'all' });
|
||||
const [isStartingConversation, setIsStartingConversation] = useState(false);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const activeConversationId = selectedConversationId !== undefined ? selectedConversationId : internalSelectedId;
|
||||
const activeFilters = filters ?? localFilters;
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => setNow(Date.now()), 60_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
function updateFilters(next: ConversationFilters) {
|
||||
if (filters === undefined) setLocalFilters(next);
|
||||
onFiltersChange?.(next);
|
||||
@@ -68,12 +84,14 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
||||
|
||||
const visibleConversations = useMemo(() => {
|
||||
const normalizedSearch = activeFilters.search.trim().toLowerCase();
|
||||
return (conversationsQuery.data ?? []).filter((conversation) => {
|
||||
const matchesSearch = normalizedSearch.length === 0
|
||||
|| conversation.username.toLowerCase().includes(normalizedSearch)
|
||||
|| conversation.lastMessageSnippet.toLowerCase().includes(normalizedSearch);
|
||||
return matchesSearch && matchesFilter(conversation, activeFilters.filter);
|
||||
});
|
||||
return (conversationsQuery.data ?? [])
|
||||
.filter((conversation) => {
|
||||
const matchesSearch = normalizedSearch.length === 0
|
||||
|| conversation.username.toLowerCase().includes(normalizedSearch)
|
||||
|| conversation.lastMessageSnippet.toLowerCase().includes(normalizedSearch);
|
||||
return matchesSearch && matchesFilter(conversation, activeFilters.filter);
|
||||
})
|
||||
.sort((left, right) => right.lastMessageTime - left.lastMessageTime);
|
||||
}, [activeFilters, conversationsQuery.data]);
|
||||
|
||||
function selectConversation(id: string | null) {
|
||||
@@ -144,6 +162,7 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
||||
conversation={conversation}
|
||||
isSelected={ownedConversationId(conversation) === activeConversationId}
|
||||
key={ownedConversationId(conversation)}
|
||||
now={now}
|
||||
onSelect={() => selectConversation(ownedConversationId(conversation))}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
.conversation-list-item > span:last-child { overflow: hidden; color: var(--muted); font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.conversation-list-heading { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
|
||||
.conversation-list-heading small { color: var(--accent); }
|
||||
.conversation-list-meta { display: flex; align-items: center; gap: .45rem; }
|
||||
.conversation-list-meta time { color: var(--muted); font-size: .7rem; white-space: nowrap; }
|
||||
.conversation-image-marker { justify-self: start; padding: .18rem .45rem; color: #d8e8ff !important; background: #18324d; border: 1px solid #315d85; border-radius: 999px; font-size: .68rem !important; font-weight: 750; }
|
||||
.conversation-list-state, .conversation-detail-state { display: grid; place-items: center; align-content: center; gap: .5rem; min-height: 16rem; padding: 1.5rem; color: var(--muted); text-align: center; }
|
||||
.conversation-list-state strong, .conversation-detail-state strong { color: #e9f1ec; }
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
const MINUTE_MS = 60_000;
|
||||
const HOUR_MS = 60 * MINUTE_MS;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
const MONTH_MS = 30 * DAY_MS;
|
||||
const YEAR_MS = 365 * DAY_MS;
|
||||
|
||||
export function formatShortRelativeTime(timestamp: number, now: number): string {
|
||||
const elapsed = Math.max(0, now - timestamp);
|
||||
|
||||
if (elapsed < MINUTE_MS) return 'just now';
|
||||
if (elapsed < HOUR_MS) return `${Math.floor(elapsed / MINUTE_MS)}m ago`;
|
||||
if (elapsed < DAY_MS) return `${Math.floor(elapsed / HOUR_MS)}h ago`;
|
||||
if (elapsed < MONTH_MS) return `${Math.floor(elapsed / DAY_MS)}d ago`;
|
||||
if (elapsed < YEAR_MS) return `${Math.floor(elapsed / MONTH_MS)}mo ago`;
|
||||
return `${Math.floor(elapsed / YEAR_MS)}y ago`;
|
||||
}
|
||||
|
||||
export function formatExactTime(timestamp: number): string {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
|
||||
}
|
||||
@@ -23,11 +23,13 @@ export function filterMedia(items: MediaItem[], filters: MediaFilters, now: numb
|
||||
'30d': 30 * 86_400_000,
|
||||
}[filters.dateRange];
|
||||
|
||||
return items.filter((item) => {
|
||||
if (filters.conversation !== 'all' && mediaConversation(item) !== filters.conversation) return false;
|
||||
if (filters.sender !== 'all' && item.sender !== filters.sender) return false;
|
||||
if (filters.type !== 'all' && mediaType(item) !== filters.type) return false;
|
||||
if (rangeMs !== null && item.timestamp < now - rangeMs) return false;
|
||||
return true;
|
||||
});
|
||||
return items
|
||||
.filter((item) => {
|
||||
if (filters.conversation !== 'all' && mediaConversation(item) !== filters.conversation) return false;
|
||||
if (filters.sender !== 'all' && item.sender !== filters.sender) return false;
|
||||
if (filters.type !== 'all' && mediaType(item) !== filters.type) return false;
|
||||
if (rangeMs !== null && item.timestamp < now - rangeMs) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((left, right) => right.timestamp - left.timestamp);
|
||||
}
|
||||
Reference in new issue
Block a user