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';
|
import { MobileSpacesScreen } from './MobileSpacesScreen';
|
||||||
// These will be replaced with real imports as each task is completed:
|
// 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" />;
|
const MobileYouScreen = () => <PlaceholderScreen label="You" />;
|
||||||
import { MobileChatScreen } from './MobileChatScreen';
|
import { MobileChatScreen } from './MobileChatScreen';
|
||||||
const MobileSettingsScreen = ({ initialPanel: _p }: { initialPanel?: string }) => <PlaceholderScreen label="Settings" />;
|
const MobileSettingsScreen = ({ initialPanel: _p }: { initialPanel?: string }) => <PlaceholderScreen label="Settings" />;
|
||||||
|
|||||||
Reference in New Issue
Block a user