feat(mobile): add MobileDmsScreen with activity row and DM list

This commit is contained in:
Jannis Braun
2026-03-17 15:39:19 +01:00
parent 0222946f04
commit 49e4402802
2 changed files with 201 additions and 1 deletions
@@ -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" />;