2026-02-05 21:05:14 +00:00
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
BaseViewModel ,
RoomNotifState ,
2026-03-17 15:20:52 +01:00
type RoomListItemViewSnapshot ,
type RoomListItemViewActions ,
2026-02-05 21:05:14 +00:00
} from "@element-hq/web-shared-components" ;
import { RoomEvent } from "matrix-js-sdk/src/matrix" ;
import { CallType } from "matrix-js-sdk/src/webrtc/call" ;
2026-03-02 18:16:38 +01:00
import type { Room , MatrixClient , RoomMember } from "matrix-js-sdk/src/matrix" ;
2026-02-05 21:05:14 +00:00
import type { RoomNotificationState } from "../../stores/notifications/RoomNotificationState" ;
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore" ;
import { NotificationStateEvents } from "../../stores/notifications/NotificationState" ;
2026-03-04 18:40:12 +01:00
import { MessagePreviewStore } from "../../stores/message-preview" ;
2026-03-03 20:25:20 +01:00
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag" ;
2026-02-05 21:05:14 +00:00
import DMRoomMap from "../../utils/DMRoomMap" ;
import SettingsStore from "../../settings/SettingsStore" ;
import { NotificationLevel } from "../../stores/notifications/NotificationLevel" ;
import { hasAccessToNotificationMenu , hasAccessToOptionsMenu } from "./utils" ;
import { EchoChamber } from "../../stores/local-echo/EchoChamber" ;
import { RoomNotifState as ElementRoomNotifState } from "../../RoomNotifs" ;
import { shouldShowComponent } from "../../customisations/helpers/UIComponents" ;
import { UIComponent } from "../../settings/UIFeature" ;
import { CallStore , CallStoreEvent } from "../../stores/CallStore" ;
import { clearRoomNotification , setMarkedUnreadState } from "../../utils/notifications" ;
import { tagRoom } from "../../utils/room/tagRoom" ;
2026-03-25 20:52:45 +01:00
import { keepIfSame } from "../../utils/keepIfSame" ;
2026-02-05 21:05:14 +00:00
import dispatcher from "../../dispatcher/dispatcher" ;
import { Action } from "../../dispatcher/actions" ;
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload" ;
import PosthogTrackers from "../../PosthogTrackers" ;
2026-03-02 18:16:38 +01:00
import { type Call , CallEvent } from "../../models/Call" ;
2026-02-05 21:05:14 +00:00
interface RoomItemProps {
room : Room ;
client : MatrixClient ;
}
/**
* View model for an individual room list item.
* Manages per-room subscriptions and updates only when this specific room's data changes.
2026-03-17 15:20:52 +01:00
* Implements RoomListItemViewActions to provide interaction callbacks.
2026-02-05 21:05:14 +00:00
*/
export class RoomListItemViewModel
2026-03-17 15:20:52 +01:00
extends BaseViewModel < RoomListItemViewSnapshot , RoomItemProps >
implements RoomListItemViewActions
2026-02-05 21:05:14 +00:00
{
private notifState : RoomNotificationState ;
2026-03-02 18:16:38 +01:00
/**
* Track the current call for this room to manager listeners
*/
private currentCall : Call | null = null ;
2026-02-05 21:05:14 +00:00
public constructor ( props : RoomItemProps ) {
// Get notification state first so we can generate a complete initial snapshot
const notifState = RoomNotificationStateStore . instance . getRoomState ( props . room );
const initialItem = RoomListItemViewModel . generateItemSync ( props . room , props . client , notifState );
super ( props , initialItem );
this . notifState = notifState ;
// Subscribe to notification state changes for this room
this . disposables . trackListener ( this . notifState , NotificationStateEvents . Update , this . onNotificationChanged );
2026-03-25 20:52:45 +01:00
// Subscribe to message preview changes for this specific room
this . disposables . trackListener (
MessagePreviewStore . instance ,
MessagePreviewStore . getPreviewChangedEventName ( props . room ),
this . onMessagePreviewChanged ,
);
2026-02-05 21:05:14 +00:00
// Subscribe to settings changes for message preview toggle
const settingsWatchRef = SettingsStore . watchSetting (
"RoomList.showMessagePreview" ,
null ,
this . onMessagePreviewSettingChanged ,
);
this . disposables . track (() => {
SettingsStore . unwatchSetting ( settingsWatchRef );
});
// Subscribe to call state changes
this . disposables . trackListener ( CallStore . instance , CallStoreEvent . ConnectedCalls , this . onCallStateChanged );
2026-03-02 18:16:38 +01:00
// If there is an active call for this room, listen to participant changes
this . listenToCallParticipants ();
2026-02-05 21:05:14 +00:00
// Subscribe to room-specific events
this . disposables . trackListener ( props . room , RoomEvent . Name , this . onRoomChanged );
this . disposables . trackListener ( props . room , RoomEvent . Tags , this . onRoomChanged );
// Load message preview asynchronously (sync data is already complete)
void this . loadAndSetMessagePreview ();
}
2026-03-02 18:16:38 +01:00
public dispose () : void {
super . dispose ();
this . currentCall ? . off ( CallEvent . Participants , this . onCallParticipantsChanged );
}
2026-02-05 21:05:14 +00:00
private onNotificationChanged = () : void => {
this . updateItem ();
};
private onMessagePreviewChanged = () : void => {
void this . loadAndSetMessagePreview ();
};
private onMessagePreviewSettingChanged = () : void => {
void this . loadAndSetMessagePreview ();
};
2026-03-02 18:16:38 +01:00
/**
* Handler for call participant changes. Only updates the item if the call moves between having participants and not having participants, to avoid unnecessary updates.
* @param participants The current call participants
*/
private onCallParticipantsChanged = ( participants : Map < RoomMember , Set < string >>) : void => {
const hasCall = Boolean ( this . snapshot . current . notification . callType );
// There is already an active call, we don't need to update the item
if ( hasCall && participants . size > 0 ) return ;
this . updateItem ();
};
/**
* Listen to participant changes for the current call in this room (if any) to trigger updates when participants join/leave the call.
*/
private listenToCallParticipants () : void {
const call = CallStore . instance . getCall ( this . props . room . roomId );
// Remove listener from previous call (if any) and add to new call to track participant changes
if ( call !== this . currentCall ) {
this . currentCall ? . off ( CallEvent . Participants , this . onCallParticipantsChanged );
call ? . on ( CallEvent . Participants , this . onCallParticipantsChanged );
}
this . currentCall = call ;
}
2026-02-05 21:05:14 +00:00
private onCallStateChanged = () : void => {
// Only update if call state for this room actually changed
const call = CallStore . instance . getCall ( this . props . room . roomId );
2026-03-02 18:16:38 +01:00
this . listenToCallParticipants ();
2026-02-05 21:05:14 +00:00
const currentCallType = this . snapshot . current . notification . callType ;
const newCallType =
call && call . participants . size > 0 ? ( call . callType === CallType . Voice ? "voice" : "video" ) : undefined ;
if ( currentCallType !== newCallType ) {
this . updateItem ();
}
};
private onRoomChanged = () : void => {
this . updateItem ();
};
/**
* Update the item snapshot with current sync data.
* Preserves the message preview which is managed separately.
*/
private updateItem () : void {
const newItem = RoomListItemViewModel . generateItemSync ( this . props . room , this . props . client , this . notifState );
2026-03-25 20:52:45 +01:00
this . snapshot . merge ({
... newItem ,
notification : keepIfSame ( this . snapshot . current . notification , newItem . notification ),
// Preserve message preview - it's managed separately by loadAndSetMessagePreview
messagePreview : this.snapshot.current.messagePreview ,
});
2026-02-05 21:05:14 +00:00
}
private getMessagePreviewTag () : string {
const isDm = Boolean ( DMRoomMap . shared (). getUserIdForRoomId ( this . props . room . roomId ));
return isDm ? DefaultTagID.DM : DefaultTagID.Untagged ;
}
/**
* Load the message preview for this room if enabled.
* Returns undefined if previews are disabled or couldn't be loaded.
*/
private async loadMessagePreview () : Promise < string | undefined > {
const shouldShowMessagePreview = SettingsStore . getValue ( "RoomList.showMessagePreview" );
if ( ! shouldShowMessagePreview ) {
return undefined ;
}
const messagePreviewTag = this . getMessagePreviewTag ();
const preview = await MessagePreviewStore . instance . getPreviewForRoom ( this . props . room , messagePreviewTag );
return preview ? . text ;
}
/**
* Load and set the message preview if it differs from current.
*/
private async loadAndSetMessagePreview () : Promise < void > {
const messagePreview = await this . loadMessagePreview ();
if ( messagePreview !== this . snapshot . current . messagePreview ) {
this . snapshot . merge ({ messagePreview });
}
}
/**
* Generate a complete RoomListItem with all synchronous data.
* Message preview is loaded separately to avoid blocking initial render.
*/
private static generateItemSync (
room : Room ,
client : MatrixClient ,
notifState : RoomNotificationState ,
2026-03-17 15:20:52 +01:00
) : RoomListItemViewSnapshot {
2026-02-05 21:05:14 +00:00
// Get room tags for menu state
const roomTags = room . tags ;
const isDm = Boolean ( DMRoomMap . shared (). getUserIdForRoomId ( room . roomId ));
// Message preview will be loaded asynchronously and updated separately
const messagePreview = undefined ;
const isFavourite = Boolean ( roomTags [ DefaultTagID . Favourite ]);
const isLowPriority = Boolean ( roomTags [ DefaultTagID . LowPriority ]);
const isArchived = Boolean ( roomTags [ DefaultTagID . Archived ]);
// More options menu state
const showMoreOptionsMenu = hasAccessToOptionsMenu ( room );
const showNotificationMenu = hasAccessToNotificationMenu ( room , client . isGuest (), isArchived );
// Notification levels
const canMarkAsRead = notifState . level > NotificationLevel . None ;
const canMarkAsUnread = ! canMarkAsRead && ! isArchived ;
const canInvite = room . canInvite ( client . getUserId () ! ) && ! isDm && shouldShowComponent ( UIComponent . InviteUsers );
const canCopyRoomLink = ! isDm ;
// Get the current room notification state from EchoChamber
const echoChamber = EchoChamber . forRoom ( room );
const elementRoomNotifState = echoChamber . notificationVolume ;
// Convert element-web RoomNotifState to shared-components RoomNotifState
let roomNotifState : RoomNotifState ;
switch ( elementRoomNotifState ) {
case ElementRoomNotifState.AllMessages :
roomNotifState = RoomNotifState . AllMessages ;
break ;
case ElementRoomNotifState.AllMessagesLoud :
roomNotifState = RoomNotifState . AllMessagesLoud ;
break ;
case ElementRoomNotifState.MentionsOnly :
roomNotifState = RoomNotifState . MentionsOnly ;
break ;
case ElementRoomNotifState.Mute :
roomNotifState = RoomNotifState . Mute ;
break ;
default :
roomNotifState = RoomNotifState . AllMessages ;
}
const isNotificationMute = elementRoomNotifState === ElementRoomNotifState . Mute ;
// Video room and call state tracking
const call = CallStore . instance . getCall ( room . roomId );
const participantCount = call ? . participants . size ?? 0 ;
const hasParticipantsInCall = participantCount > 0 ;
const callType =
call ? . callType === CallType . Voice ? "voice" : call ? . callType === CallType . Video ? "video" : undefined ;
return {
id : room.roomId ,
room ,
name : room.name ,
isBold : notifState.hasAnyNotificationOrActivity ,
messagePreview ,
notification : {
hasAnyNotificationOrActivity : notifState.hasAnyNotificationOrActivity || hasParticipantsInCall ,
isUnsentMessage : notifState.isUnsentMessage ,
invited : notifState.invited ,
isMention : notifState.isMention ,
isActivityNotification : notifState.isActivityNotification ,
isNotification : notifState.isNotification ,
hasUnreadCount : notifState.hasUnreadCount ,
count : notifState.count ,
muted : isNotificationMute ,
callType : hasParticipantsInCall ? callType : undefined ,
},
showMoreOptionsMenu ,
showNotificationMenu ,
isFavourite ,
isLowPriority ,
canInvite ,
canCopyRoomLink ,
canMarkAsRead ,
canMarkAsUnread ,
roomNotifState ,
};
}
public onOpenRoom = () : void => {
dispatcher . dispatch < ViewRoomPayload >({
action : Action.ViewRoom ,
room_id : this.props.room.roomId ,
metricsTrigger : "RoomList" ,
});
};
public onMarkAsRead = async () : Promise < void > => {
await clearRoomNotification ( this . props . room , this . props . client );
PosthogTrackers . trackInteraction ( "WebRoomListRoomTileContextMenuMarkRead" );
};
public onMarkAsUnread = async () : Promise < void > => {
await setMarkedUnreadState ( this . props . room , this . props . client , true );
PosthogTrackers . trackInteraction ( "WebRoomListRoomTileContextMenuMarkUnread" );
};
public onToggleFavorite = () : void => {
tagRoom ( this . props . room , DefaultTagID . Favourite );
PosthogTrackers . trackInteraction ( "WebRoomListRoomTileContextMenuFavouriteToggle" );
};
public onToggleLowPriority = () : void => {
tagRoom ( this . props . room , DefaultTagID . LowPriority );
};
public onInvite = () : void => {
dispatcher . dispatch ({
action : "view_invite" ,
roomId : this.props.room.roomId ,
});
PosthogTrackers . trackInteraction ( "WebRoomListRoomTileContextMenuInviteItem" );
};
public onCopyRoomLink = () : void => {
dispatcher . dispatch ({
action : "copy_room" ,
room_id : this.props.room.roomId ,
});
};
public onLeaveRoom = () : void => {
const isArchived = Boolean ( this . props . room . tags [ DefaultTagID . Archived ]);
dispatcher . dispatch ({
action : isArchived ? "forget_room" : "leave_room" ,
room_id : this.props.room.roomId ,
});
PosthogTrackers . trackInteraction ( "WebRoomListRoomTileContextMenuLeaveItem" );
};
public onSetRoomNotifState = ( notifState : RoomNotifState ) : void => {
// Convert shared-components RoomNotifState to element-web RoomNotifState
let elementNotifState : ElementRoomNotifState ;
switch ( notifState ) {
case "all_messages" :
elementNotifState = ElementRoomNotifState . AllMessages ;
break ;
case "all_messages_loud" :
elementNotifState = ElementRoomNotifState . AllMessagesLoud ;
break ;
case "mentions_only" :
elementNotifState = ElementRoomNotifState . MentionsOnly ;
break ;
case "mute" :
elementNotifState = ElementRoomNotifState . Mute ;
break ;
default :
elementNotifState = ElementRoomNotifState . AllMessages ;
}
// Set the notification state using EchoChamber
const echoChamber = EchoChamber . forRoom ( this . props . room );
echoChamber . notificationVolume = elementNotifState ;
};
}