Room list: assign room to custom section (#33238)
* feat(sc): add new toast type for room list * feat(sc): add section entries in room list item menu * feat(rls): expose util functions * feat: allows to tag room with custom sections * feat(vm): add new Chat moved toast to room list vm * feat(vm): add section selection to room list item vm * feat(e2e): add tests for adding room in a custom section * test(e2e): update existing screenshots * chore: fix lint after merge * chore: remove outline in test
This commit is contained in:
@@ -62,6 +62,8 @@ export enum RoomListStoreV3Event {
|
||||
ListsLoaded = "lists_loaded",
|
||||
/** Fired when a new section is created in the room list. */
|
||||
SectionCreated = "section_created",
|
||||
/** Fired when a room's tags change. */
|
||||
RoomTagged = "room_tagged",
|
||||
}
|
||||
|
||||
// The result object for returning rooms from the store
|
||||
@@ -93,6 +95,7 @@ export const CHATS_TAG = "chats";
|
||||
export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
|
||||
export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
|
||||
export const SECTION_CREATED_EVENT = RoomListStoreV3Event.SectionCreated;
|
||||
export const ROOM_TAGGED_EVENT = RoomListStoreV3Event.RoomTagged;
|
||||
|
||||
/**
|
||||
* This store allows for fast retrieval of the room list in a sorted and filtered manner.
|
||||
@@ -243,6 +246,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
||||
case "MatrixActions.Room.tags": {
|
||||
const room = payload.room;
|
||||
this.addRoomAndEmit(room);
|
||||
this.emit(ROOM_TAGGED_EVENT);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -493,6 +497,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
||||
this.emit(SECTION_CREATED_EVENT, tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ordered section tags.
|
||||
*/
|
||||
public get orderedSectionTags(): string[] {
|
||||
return this.sortedTags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the custom sections from the settings store and update the sorted tags.
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,20 @@ import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectio
|
||||
|
||||
type Tag = string;
|
||||
|
||||
/**
|
||||
* Prefix for custom section tags.
|
||||
*/
|
||||
export const CUSTOM_SECTION_TAG_PREFIX = "element.io.section.";
|
||||
|
||||
/**
|
||||
* Checks if a given tag is a custom section tag.
|
||||
* @param tag - The tag to check.
|
||||
* @returns True if the tag is a custom section tag, false otherwise.
|
||||
*/
|
||||
export function isCustomSectionTag(tag: string): boolean {
|
||||
return tag.startsWith(CUSTOM_SECTION_TAG_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure of the custom section stored in the settings. The tag is used as a unique identifier for the section, and the name is given by the user.
|
||||
*/
|
||||
@@ -41,7 +55,7 @@ export async function createSection(): Promise<string | undefined> {
|
||||
const [shouldCreateSection, sectionName] = await modal.finished;
|
||||
if (!shouldCreateSection || !sectionName) return undefined;
|
||||
|
||||
const tag = `element.io.section.${window.crypto.randomUUID()}`;
|
||||
const tag = `${CUSTOM_SECTION_TAG_PREFIX}${window.crypto.randomUUID()}`;
|
||||
const newSection: CustomSection = { tag, name: sectionName };
|
||||
|
||||
// Save the new section data
|
||||
|
||||
@@ -13,20 +13,29 @@ import { DefaultTagID, type TagID } from "../../stores/room-list-v3/skip-list/ta
|
||||
import RoomListActions from "../../actions/RoomListActions";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { getTagsForRoom } from "./getTagsForRoom";
|
||||
import { isCustomSectionTag } from "../../stores/room-list-v3/section";
|
||||
|
||||
/**
|
||||
* Toggle tag for a given room
|
||||
* Toggle tag for a given room.
|
||||
* A room can only be in one section: either a custom section, Favourite, or LowPriority.
|
||||
* Applying any of these will atomically replace the current section tag.
|
||||
* @param room The room to tag
|
||||
* @param tagId The tag to invert
|
||||
*/
|
||||
export function tagRoom(room: Room, tagId: TagID): void {
|
||||
if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) {
|
||||
const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite;
|
||||
const isApplied = getTagsForRoom(room).includes(tagId);
|
||||
const removeTag = isApplied ? tagId : inverseTag;
|
||||
const addTag = isApplied ? null : tagId;
|
||||
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
|
||||
} else {
|
||||
if (tagId !== DefaultTagID.Favourite && tagId !== DefaultTagID.LowPriority && !isCustomSectionTag(tagId)) {
|
||||
logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the section tag currently applied (Fav, LowPriority, or custom) — at most one exists
|
||||
const currentSectionTag =
|
||||
getTagsForRoom(room).find(
|
||||
(t) => t === DefaultTagID.Favourite || t === DefaultTagID.LowPriority || isCustomSectionTag(t),
|
||||
) ?? null;
|
||||
|
||||
const isApplied = currentSectionTag === tagId;
|
||||
const removeTag = currentSectionTag;
|
||||
const addTag = isApplied ? null : tagId;
|
||||
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RoomNotifState,
|
||||
type RoomListItemViewSnapshot,
|
||||
type RoomListItemViewActions,
|
||||
type Section,
|
||||
} from "@element-hq/web-shared-components";
|
||||
import { RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
@@ -37,7 +38,8 @@ import { Action } from "../../dispatcher/actions";
|
||||
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
import { type Call, CallEvent } from "../../models/Call";
|
||||
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
|
||||
import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3";
|
||||
import { _t } from "../../languageHandler";
|
||||
|
||||
interface RoomItemProps {
|
||||
room: Room;
|
||||
@@ -96,6 +98,13 @@ export class RoomListItemViewModel
|
||||
this.disposables.trackListener(props.room, RoomEvent.Name, this.onRoomChanged);
|
||||
this.disposables.trackListener(props.room, RoomEvent.Tags, this.onRoomChanged);
|
||||
|
||||
const orderSectionsRef = SettingsStore.watchSetting("RoomList.OrderedCustomSections", null, () =>
|
||||
this.onOrderedCustomSectionsChange(),
|
||||
);
|
||||
this.disposables.track(() => {
|
||||
SettingsStore.unwatchSetting(orderSectionsRef);
|
||||
});
|
||||
|
||||
// Load message preview asynchronously (sync data is already complete)
|
||||
void this.loadAndSetMessagePreview();
|
||||
}
|
||||
@@ -181,6 +190,7 @@ export class RoomListItemViewModel
|
||||
this.snapshot.merge({
|
||||
...newItem,
|
||||
notification: keepIfSame(this.snapshot.current.notification, newItem.notification),
|
||||
sections: keepIfSame(this.snapshot.current.sections, newItem.sections),
|
||||
// Preserve message preview - it's managed separately by loadAndSetMessagePreview
|
||||
messagePreview: this.snapshot.current.messagePreview,
|
||||
});
|
||||
@@ -279,6 +289,9 @@ export class RoomListItemViewModel
|
||||
|
||||
const canMoveToSection = SettingsStore.getValue("feature_room_list_sections");
|
||||
|
||||
// Build sections list for the "Move to section" submenu
|
||||
const sections: Section[] = canMoveToSection ? RoomListItemViewModel.buildSections(roomTags) : [];
|
||||
|
||||
return {
|
||||
id: room.roomId,
|
||||
room,
|
||||
@@ -307,6 +320,7 @@ export class RoomListItemViewModel
|
||||
canMarkAsUnread,
|
||||
roomNotifState,
|
||||
canMoveToSection,
|
||||
sections,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -389,4 +403,42 @@ export class RoomListItemViewModel
|
||||
public onCreateSection = (): void => {
|
||||
RoomListStoreV3.instance.createSection();
|
||||
};
|
||||
|
||||
public onToggleSection = (tag: string): void => {
|
||||
tagRoom(this.props.room, tag);
|
||||
};
|
||||
|
||||
private onOrderedCustomSectionsChange = (): void => {
|
||||
// Rebuild sections list to reflect new order
|
||||
const sections = RoomListItemViewModel.buildSections(this.props.room.tags);
|
||||
this.snapshot.merge({ sections: keepIfSame(this.snapshot.current.sections, sections) });
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the list of available sections for the "Move to section" submenu.
|
||||
* Order follows the canonical section order from RoomListStoreV3.
|
||||
*/
|
||||
private static buildSections(roomTags: Room["tags"]): Section[] {
|
||||
const customSectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
|
||||
|
||||
return (
|
||||
RoomListStoreV3.instance.orderedSectionTags
|
||||
// Exclude the Chats section because the user toggle the other sections to move rooms in and out of the Chats section.
|
||||
.filter((tag) => tag !== CHATS_TAG)
|
||||
.map((tag) => ({
|
||||
tag,
|
||||
name: RoomListItemViewModel.getSectionName(tag, customSectionData),
|
||||
isSelected: Boolean(roomTags[tag]),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for a section based on its tag.
|
||||
*/
|
||||
private static getSectionName(tag: string, customSectionData: Record<string, { name: string }>): string {
|
||||
if (tag === DefaultTagID.Favourite) return _t("room_list|section|favourites");
|
||||
if (tag === DefaultTagID.LowPriority) return _t("room_list|section|low_priority");
|
||||
return customSectionData[tag]?.name || tag;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type RoomListViewState,
|
||||
type RoomListSection,
|
||||
_t,
|
||||
type ToastType,
|
||||
} from "@element-hq/web-shared-components";
|
||||
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
@@ -156,6 +157,13 @@ export class RoomListViewModel
|
||||
this.onSectionCreated as (...args: unknown[]) => void,
|
||||
);
|
||||
|
||||
// Subscribe to room tagging
|
||||
this.disposables.trackListener(
|
||||
RoomListStoreV3.instance,
|
||||
RoomListStoreV3Event.RoomTagged as any,
|
||||
this.onRoomTagged,
|
||||
);
|
||||
|
||||
// Subscribe to active room changes to update selected room
|
||||
const dispatcherRef = dispatcher.register(this.onDispatch);
|
||||
this.disposables.track(() => {
|
||||
@@ -595,15 +603,11 @@ export class RoomListViewModel
|
||||
|
||||
public onSectionCreated = (tag: string): void => {
|
||||
this.updateRoomListData(false, null, tag);
|
||||
this.showToast("section_created");
|
||||
};
|
||||
|
||||
clearTimeout(this.toastRef);
|
||||
this.snapshot.merge({
|
||||
toast: "section_created",
|
||||
});
|
||||
// Automatically close the toast after 15 seconds
|
||||
this.toastRef = setTimeout(() => {
|
||||
this.closeToast();
|
||||
}, 15 * 1000);
|
||||
public onRoomTagged = (): void => {
|
||||
this.showToast("chat_moved");
|
||||
};
|
||||
|
||||
public closeToast: () => void = () => {
|
||||
@@ -612,6 +616,15 @@ export class RoomListViewModel
|
||||
toast: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
private showToast(toast: ToastType): void {
|
||||
clearTimeout(this.toastRef);
|
||||
this.snapshot.merge({ toast });
|
||||
// Automatically close the toast after 15 seconds
|
||||
this.toastRef = setTimeout(() => {
|
||||
this.closeToast();
|
||||
}, 15 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user