feat(mobile): add MobileDmsScreen with activity row and DM list
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { useSocialStore } from '../../stores/socialStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { resolveAssetUrl } from '../../utils/assetUrls';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export function MobileDmsScreen() {
|
||||
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
|
||||
const openModal = useUIStore((s) => s.openModal);
|
||||
|
||||
const dmChannels = useSpaceStore((s) => s.dmChannels);
|
||||
const readStates = useChatStore((s) => s.readStates);
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
const friends = useSocialStore((s) => s.friends);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Online friends for the activity row
|
||||
const onlineFriends = useMemo(() =>
|
||||
friends.filter(f => f.status === 'online' || f.status === 'idle' || f.status === 'dnd'),
|
||||
[friends]
|
||||
);
|
||||
|
||||
// Sort DMs by last message time (newest first)
|
||||
const sortedDms = useMemo(() =>
|
||||
[...dmChannels].sort((a, b) => {
|
||||
const aTime = a.lastMessage?.createdAt ?? a.createdAt;
|
||||
const bTime = b.lastMessage?.createdAt ?? b.createdAt;
|
||||
return bTime - aTime;
|
||||
}),
|
||||
[dmChannels]
|
||||
);
|
||||
|
||||
const handleDmTap = (dmId: string) => {
|
||||
navigate(`/channels/@me/${dmId}`);
|
||||
pushMobileScreen('channel-chat', { channelId: dmId, spaceId: '@me' });
|
||||
};
|
||||
|
||||
const formatTimestamp = (ts: number): string => {
|
||||
const now = Date.now();
|
||||
const diff = now - ts;
|
||||
if (diff < 60_000) return 'now';
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m`;
|
||||
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h`;
|
||||
if (diff < 604800_000) return `${Math.floor(diff / 86400_000)}d`;
|
||||
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-surface-base">
|
||||
{/* Header */}
|
||||
<header className="h-12 flex items-center justify-between px-4 border-b border-border-soft shrink-0">
|
||||
<h1 className="text-base font-semibold text-txt-primary">Messages</h1>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => pushMobileScreen('friends')}
|
||||
className="h-8 px-3 rounded-lg text-xs font-medium text-accent-primary hover:bg-interactive-hover transition-colors"
|
||||
>
|
||||
Friends
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Activity row — online friends */}
|
||||
{onlineFriends.length > 0 && (
|
||||
<div className="px-4 py-3 border-b border-border-soft">
|
||||
<div className="flex gap-3 overflow-x-auto no-scrollbar">
|
||||
{onlineFriends.map(friend => {
|
||||
const avatarUrl = friend.avatar
|
||||
? resolveAssetUrl(friend.avatar, friend._instanceOrigin) ?? `/api/uploads/${friend.avatar}`
|
||||
: null;
|
||||
return (
|
||||
<button
|
||||
key={friend.id}
|
||||
onClick={() => {
|
||||
// Find or create DM with this friend
|
||||
const existingDm = dmChannels.find(dm =>
|
||||
dm.members.length === 2 && dm.members.some(m => m.id === friend.id)
|
||||
);
|
||||
if (existingDm) {
|
||||
handleDmTap(existingDm.id);
|
||||
}
|
||||
}}
|
||||
className="flex flex-col items-center gap-1 shrink-0 w-14"
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={friend.displayName ?? friend.username}
|
||||
avatarColor={friend.avatarColor}
|
||||
size={40}
|
||||
/>
|
||||
<span className={`absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-surface-base ${
|
||||
friend.status === 'online' ? 'bg-status-online' :
|
||||
friend.status === 'idle' ? 'bg-status-idle' :
|
||||
'bg-status-dnd'
|
||||
}`} />
|
||||
</div>
|
||||
<span className="text-[10px] text-txt-secondary truncate w-full text-center">
|
||||
{friend.displayName ?? friend.username}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DM list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sortedDms.map(dm => {
|
||||
const otherMembers = dm.members.filter(m => m.id !== authUser?.id);
|
||||
const isGroup = dm.members.length > 2;
|
||||
const name = isGroup
|
||||
? otherMembers.map(m => m.displayName ?? m.username).join(', ')
|
||||
: otherMembers[0]?.displayName ?? otherMembers[0]?.username ?? 'Unknown';
|
||||
|
||||
const lastMsgId = dm.lastMessage?.id;
|
||||
const readState = readStates.get(dm.id);
|
||||
const isUnread = lastMsgId && (!readState || readState < lastMsgId);
|
||||
|
||||
const preview = dm.lastMessage?.content;
|
||||
const previewTime = dm.lastMessage?.createdAt;
|
||||
const previewSender = dm.lastMessage
|
||||
? dm.members.find(m => m.id === dm.lastMessage!.userId)
|
||||
: null;
|
||||
|
||||
const mainUser = otherMembers[0];
|
||||
const avatarUrl = mainUser?.avatar
|
||||
? `/api/uploads/${mainUser.avatar}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dm.id}
|
||||
onClick={() => handleDmTap(dm.id)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-interactive-hover text-left transition-colors"
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
{isGroup ? (
|
||||
<div className="w-10 h-10 rounded-full bg-surface-elevated flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-txt-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={name}
|
||||
avatarColor={mainUser?.avatarColor}
|
||||
size={40}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={`text-sm truncate ${isUnread ? 'font-semibold text-txt-primary' : 'text-txt-primary'}`}>
|
||||
{name}
|
||||
</span>
|
||||
{previewTime && (
|
||||
<span className="text-[11px] text-txt-tertiary shrink-0">
|
||||
{formatTimestamp(previewTime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{preview && (
|
||||
<p className={`text-xs truncate mt-0.5 ${isUnread ? 'text-txt-secondary font-medium' : 'text-txt-tertiary'}`}>
|
||||
{previewSender && previewSender.id !== authUser?.id
|
||||
? `${previewSender.displayName ?? previewSender.username}: ${preview}`
|
||||
: preview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isUnread && <span className="w-2.5 h-2.5 rounded-full bg-accent-primary shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{sortedDms.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-40 text-txt-tertiary text-sm">
|
||||
No conversations yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* FAB — New DM */}
|
||||
<button
|
||||
onClick={() => openModal('newDm')}
|
||||
className="fixed bottom-20 right-4 w-14 h-14 rounded-full bg-accent-primary text-white shadow-elevation-high flex items-center justify-center z-20 hover:bg-accent-primary-hover active:bg-accent-primary-active transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const PlaceholderScreen = ({ label }: { label: string }) => (
|
||||
|
||||
import { MobileSpacesScreen } from './MobileSpacesScreen';
|
||||
// These will be replaced with real imports as each task is completed:
|
||||
const MobileDmsScreen = () => <PlaceholderScreen label="DMs" />;
|
||||
import { MobileDmsScreen } from './MobileDmsScreen';
|
||||
const MobileYouScreen = () => <PlaceholderScreen label="You" />;
|
||||
import { MobileChatScreen } from './MobileChatScreen';
|
||||
const MobileSettingsScreen = ({ initialPanel: _p }: { initialPanel?: string }) => <PlaceholderScreen label="Settings" />;
|
||||
|
||||
Reference in New Issue
Block a user