feat(matrix-web): add online status indicators for DMs

- Add PresenceState and UserPresence types
- Track user presence via Matrix SDK events
- Display green dot indicator for online users in DM list
- Show gray dot for offline users
- Add tooltip with last active time

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-29 17:16:02 +01:00
parent c4483e2c0b
commit 84fca4036e
3 changed files with 128 additions and 12 deletions

View file

@ -27,6 +27,22 @@
.substring(0, 2)
.toUpperCase()
);
// Online status for DMs
let isOnline = $derived(room.isDirect && room.presence === 'online');
// Format last active time
let lastActiveText = $derived(() => {
if (!room.isDirect || !room.lastActiveAgo) return '';
if (room.presence === 'online') return 'Online';
const minutes = Math.floor(room.lastActiveAgo / 60000);
if (minutes < 1) return 'Gerade aktiv';
if (minutes < 60) return `Vor ${minutes} Min.`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `Vor ${hours} Std.`;
const days = Math.floor(hours / 24);
return `Vor ${days} Tag${days > 1 ? 'en' : ''}`;
});
</script>
<button
@ -36,17 +52,27 @@
: 'hover:bg-white/60 dark:hover:bg-white/5 hover:-translate-y-0.5'}"
{onclick}
>
<!-- Avatar -->
<div
class="w-11 h-11 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm
{selected
? 'bg-gradient-to-br from-blue-500 to-indigo-600 text-white'
: 'bg-gradient-to-br from-violet-500 to-purple-600 text-white'}"
>
{#if room.avatar}
<img src={room.avatar} alt={room.name} class="w-11 h-11 rounded-full object-cover" />
{:else}
<span class="text-sm font-semibold">{initials}</span>
<!-- Avatar with online indicator -->
<div class="relative flex-shrink-0">
<div
class="w-11 h-11 rounded-full flex items-center justify-center shadow-sm
{selected
? 'bg-gradient-to-br from-blue-500 to-indigo-600 text-white'
: 'bg-gradient-to-br from-violet-500 to-purple-600 text-white'}"
>
{#if room.avatar}
<img src={room.avatar} alt={room.name} class="w-11 h-11 rounded-full object-cover" />
{:else}
<span class="text-sm font-semibold">{initials}</span>
{/if}
</div>
<!-- Online indicator dot -->
{#if room.isDirect}
<div
class="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-white dark:border-zinc-900
{isOnline ? 'bg-green-500' : 'bg-zinc-400 dark:bg-zinc-600'}"
title={lastActiveText()}
></div>
{/if}
</div>

View file

@ -13,6 +13,8 @@ import type {
VerificationRequest,
CryptoCallbacks,
CrossSigningStatus,
PresenceState,
UserPresence,
} from './types';
const STORAGE_KEY = 'matrix_credentials';
@ -30,6 +32,7 @@ class MatrixStore {
private _currentRoomId = $state<string | null>(null);
private _timeline = $state<MatrixEvent[]>([]);
private _typingUsers = $state<Map<string, string[]>>(new Map());
private _userPresence = $state<Map<string, UserPresence>>(new Map());
private _error = $state<string | null>(null);
private _initialized = $state(false);
@ -77,6 +80,21 @@ class MatrixStore {
return this._crossSigningReady;
}
/**
* Get presence for a specific user
*/
getUserPresence(userId: string): UserPresence | undefined {
return this._userPresence.get(userId);
}
/**
* Check if a user is currently online
*/
isUserOnline(userId: string): boolean {
const presence = this._userPresence.get(userId);
return presence?.presence === 'online' || presence?.currentlyActive === true;
}
// ─────────────────────────────────────────────────────────
// Derived State
// ─────────────────────────────────────────────────────────
@ -260,6 +278,28 @@ class MatrixStore {
// Trigger reactivity for room updates
this._rooms = this._client!.getRooms();
});
// User presence changes
this._client.on(sdk.UserEvent.Presence, (event, user) => {
if (!user) return;
const userId = user.userId;
const presence: UserPresence = {
userId,
presence: (user.presence as PresenceState) || 'offline',
lastActiveAgo: user.lastActiveAgo,
statusMessage: user.presenceStatusMsg,
currentlyActive: user.currentlyActive,
};
// Trigger reactivity by creating new Map
const newMap = new Map(this._userPresence);
newMap.set(userId, presence);
this._userPresence = newMap;
// Also trigger room list update for DMs
this._rooms = this._client!.getRooms();
});
}
/**
@ -1199,6 +1239,33 @@ class MatrixStore {
}
}
// Get DM user presence info
const isDirect = this.isDirectRoom(room);
let dmUserId: string | undefined;
let presence: SimpleRoom['presence'];
let lastActiveAgo: number | undefined;
if (isDirect && myUserId) {
// Find the other user in the DM
const members = room.getJoinedMembers();
const otherMember = members.find((m) => m.userId !== myUserId);
if (otherMember) {
dmUserId = otherMember.userId;
const userPresence = this._userPresence.get(dmUserId);
if (userPresence) {
presence = userPresence.presence;
lastActiveAgo = userPresence.lastActiveAgo;
} else {
// Try to get from user object directly
const user = this._client?.getUser(dmUserId);
if (user) {
presence = (user.presence as SimpleRoom['presence']) || 'offline';
lastActiveAgo = user.lastActiveAgo;
}
}
}
}
return {
id: room.roomId,
name: room.name || 'Unnamed Room',
@ -1209,11 +1276,14 @@ class MatrixStore {
lastMessageTime: room.getLastActiveTimestamp() || undefined,
unreadCount: room.getUnreadNotificationCount('total' as any) || 0,
highlightCount: room.getUnreadNotificationCount('highlight' as any) || 0,
isDirect: this.isDirectRoom(room),
isDirect,
isEncrypted: room.hasEncryptionStateEvent(),
memberCount: room.getJoinedMemberCount(),
membership,
inviter,
dmUserId,
presence,
lastActiveAgo,
};
}

View file

@ -73,6 +73,22 @@ export type MessageType =
*/
export type RoomMembership = 'join' | 'invite' | 'leave' | 'ban' | 'knock';
/**
* User presence state
*/
export type PresenceState = 'online' | 'offline' | 'unavailable';
/**
* User presence info
*/
export interface UserPresence {
userId: string;
presence: PresenceState;
lastActiveAgo?: number; // milliseconds since last active
statusMessage?: string;
currentlyActive?: boolean;
}
/**
* Simplified room for UI rendering
*/
@ -91,6 +107,10 @@ export interface SimpleRoom {
memberCount: number;
membership: RoomMembership;
inviter?: string; // User who sent the invite
// Presence info for DMs
dmUserId?: string; // The other user's ID in a DM
presence?: PresenceState;
lastActiveAgo?: number;
}
/**