diff --git a/CHANGES.md b/CHANGES.md index ecf4333dbc..87ae4d6258 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -26,6 +26,7 @@ - #3941: add adhoc completed command result and text-multi as merged lines of text - #3989: properly display occupant hats in details dialog - Don't render unfurls for retracted messages. +- #3949: Allow pinning bookmarked conversations to the top (XEP-0469) ## 12.0.0 (2025-08-28) diff --git a/src/headless/plugins/bookmarks/collection.js b/src/headless/plugins/bookmarks/collection.js index 3c5f4e86f9..99da3dd36c 100644 --- a/src/headless/plugins/bookmarks/collection.js +++ b/src/headless/plugins/bookmarks/collection.js @@ -82,6 +82,7 @@ class Bookmarks extends Collection { const groupchat = await api.rooms.create(bookmark.get('jid'), { nick: bookmark.get('nick'), password: bookmark.get('password'), + pinned: bookmark.get('pinned'), }); groupchat.maybeShow(); } @@ -250,7 +251,7 @@ class Bookmarks extends Collection { markRoomAsBookmarked(bookmark) { const { chatboxes } = _converse.state; const groupchat = chatboxes.get(bookmark.get('jid')); - groupchat?.save('bookmarked', true); + groupchat?.setBookmark(bookmark); } /** @@ -334,6 +335,48 @@ class Bookmarks extends Collection { const { chatboxes } = _converse.state; return this.filter((b) => !chatboxes.get(b.get('jid'))); } + + /** + * + * @param {Bookmark} bookmark + */ + pinBookmark(bookmark) { + const extensions = [...bookmark.get('extensions'), ``]; + + bookmark.set('pinned', true); + + try { + api.bookmarks.set({ + jid: bookmark.get('jid'), + extensions, + }); + } catch (error) { + bookmark.set('pinned', false); + log.error('Error while trying to pin bookmark'); + log.error(error); + } + } + + /** + * + * @param {Bookmark} bookmark + */ + unpinBookmark(bookmark) { + const extensions = bookmark.get('extensions').filter(/** @param {String} e */ e => !(e.includes(' e.includes(' - IQ_stanzas.filter((s) => sizzle('iq publish[node="urn:xmpp:bookmarks:1"]', s).length).pop(), + IQ_stanzas.filter((s) => sizzle(`iq publish[node="${Strophe.NS.BOOKMARKS2}"]`, s).length).pop(), ); expect(sent_stanza).toEqualStanza( @@ -26,9 +26,9 @@ describe('A bookmark', function () { type="set" xmlns="jabber:client"> - + - + ${nick} ${settings.password} @@ -64,7 +64,7 @@ describe('A bookmark', function () { id="${sent_stanza.getAttribute('id')}"/>`; _converse.api.connection.get()._dataRecv(mock.createRequest(stanza)); - expect(muc.get('bookmarked')).toBeTruthy(); + expect(muc.bookmark).toBeTruthy(); }), ); @@ -81,7 +81,7 @@ describe('A bookmark', function () { const IQ_stanzas = _converse.api.connection.get().IQ_stanzas; let sent_stanza = await u.waitUntil(() => - IQ_stanzas.filter((s) => sizzle('iq publish[node="urn:xmpp:bookmarks:1"]', s).length).pop(), + IQ_stanzas.filter((s) => sizzle(`iq publish[node="${Strophe.NS.BOOKMARKS2}"]`, s).length).pop(), ); const stanza = stx` - IQ_stanzas.filter((s) => sizzle('iq publish[node="urn:xmpp:bookmarks:1"]', s).length).pop(), + IQ_stanzas.filter((s) => sizzle(`iq publish[node="${Strophe.NS.BOOKMARKS2}"]`, s).length).pop(), ); expect(sent_stanza).toEqualStanza( @@ -149,9 +149,9 @@ describe('A bookmark', function () { type="set" xmlns="jabber:client"> - + - + ${newnick} ${settings.password} @@ -245,7 +245,7 @@ describe('A bookmark', function () { const { bookmarks } = _converse.state; await u.waitUntil(() => bookmarks.length); - await u.waitUntil(() => muc.get('bookmarked')); + await u.waitUntil(() => muc.bookmark); spyOn(bookmarks, 'sendBookmarkStanza').and.callThrough(); const sent_IQs = _converse.api.connection.get().IQ_stanzas; @@ -267,9 +267,9 @@ describe('A bookmark', function () { type="set" xmlns="jabber:client"> - + - + ${nick} @@ -319,15 +319,15 @@ describe('A bookmark', function () { const IQ_stanzas = _converse.api.connection.get().IQ_stanzas; let sent_stanza = await u.waitUntil(() => - IQ_stanzas.filter((s) => sizzle('publish[node="urn:xmpp:bookmarks:1"]', s).length).pop(), + IQ_stanzas.filter((s) => sizzle(`publish[node="${Strophe.NS.BOOKMARKS2}"]`, s).length).pop(), ); expect(sent_stanza).toEqualStanza(stx` - + - + @@ -362,16 +362,16 @@ describe('A bookmark', function () { sent_stanza = await u.waitUntil(() => IQ_stanzas.filter( - (s) => sizzle('publish[node="urn:xmpp:bookmarks:1"] conference[name="Balcony"]', s).length, + (s) => sizzle(`publish[node="${Strophe.NS.BOOKMARKS2}"] conference[name="Balcony"]`, s).length, ).pop(), ); expect(sent_stanza).toEqualStanza(stx` - + - + romeo @@ -413,16 +413,16 @@ describe('A bookmark', function () { sent_stanza = await u.waitUntil(() => IQ_stanzas.filter( - (s) => sizzle('publish[node="urn:xmpp:bookmarks:1"] conference[name="Garden"]', s).length, + (s) => sizzle(`publish[node="${Strophe.NS.BOOKMARKS2}"] conference[name="Garden"]`, s).length, ).pop(), ); expect(sent_stanza).toEqualStanza(stx` - + - + r0meo secret @@ -469,7 +469,7 @@ describe('A bookmark', function () { const IQ_stanzas = _converse.api.connection.get().IQ_stanzas; const sent_stanza = await u.waitUntil(() => - IQ_stanzas.filter((s) => sizzle(`items[node="urn:xmpp:bookmarks:1"]`, s).length).pop(), + IQ_stanzas.filter((s) => sizzle(`items[node="${Strophe.NS.BOOKMARKS2}"]`, s).length).pop(), ); // Simulate server response with item-not-found error @@ -479,7 +479,7 @@ describe('A bookmark', function () { from="${sent_stanza.getAttribute('to')}" to="${sent_stanza.getAttribute('from')}"> - + @@ -521,7 +521,7 @@ describe('A bookmark', function () { const IQ_stanzas = _converse.api.connection.get().IQ_stanzas; let sent_stanza = await u.waitUntil(() => - IQ_stanzas.filter((s) => sizzle('publish[node="urn:xmpp:bookmarks:1"]', s).length).pop(), + IQ_stanzas.filter((s) => sizzle(`publish[node="${Strophe.NS.BOOKMARKS2}"]`, s).length).pop(), ); // Server acknowledges successful storage @@ -542,17 +542,169 @@ describe('A bookmark', function () { // Check that a retract stanza is sent as per XEP-0402 sent_stanza = await u.waitUntil(() => - IQ_stanzas.filter((s) => sizzle('retract[node="urn:xmpp:bookmarks:1"]', s).length).pop(), + IQ_stanzas.filter((s) => sizzle(`retract[node="${Strophe.NS.BOOKMARKS2}"]`, s).length).pop(), ); expect(sent_stanza).toEqualStanza(stx` - + `); }), ); + + it("can be pinned and sends out a stanza", mock.initConverse( + ['connected', 'chatBoxesFetched'], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 0); + await mock.waitUntilBookmarksReturned(_converse); + + const bare_jid = _converse.session.get('bare_jid'); + const muc_jid = 'theplay@conference.shakespeare.lit'; + const { api, state } = _converse; + + // First create a bookmark + state.bookmarks.create({ + jid: muc_jid, + autojoin: true, + name: 'The Play', + nick: 'romeo', + extensions: [], + }); + + await mock.waitForMUCDiscoInfo(_converse, muc_jid); + await u.waitUntil(() => state.chatboxes.length === 1); + + const IQ_stanzas = api.connection.get().IQ_stanzas; + + // Now pin the bookmark + const bookmark = state.bookmarks.findWhere({ jid: muc_jid }); + expect(bookmark).toBeTruthy(); + await state.bookmarks.pinBookmark(bookmark); + + + const sent_stanza = await u.waitUntil(() => + IQ_stanzas.filter((s) => + sizzle( + `publish[node="${Strophe.NS.BOOKMARKS2}"] conference[name="The Play"] extensions pinned`, + s, + ).length + ).pop() + ); + + expect(bookmark.get('pinned')).toBe(true); + + const chatbox = state.chatboxes.get(muc_jid); + expect(chatbox.bookmark.get('pinned')).toBe(true); + + expect(sent_stanza).toEqualStanza(stx` + + + + + + romeo + + + + + + + + + + http://jabber.org/protocol/pubsub#publish-options + + + true + + + max + + + never + + + whitelist + + + + + `); + }) + ); + + it("can be unpinned and sends out a stanza", mock.initConverse( + ['connected', 'chatBoxesFetched'], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 0); + await mock.waitUntilBookmarksReturned(_converse); + + const bare_jid = _converse.session.get('bare_jid'); + const muc_jid = 'theplay@conference.shakespeare.lit'; + const { api, state } = _converse; + + // First create a pinned bookmark + const bookmark = state.bookmarks.create({ + jid: muc_jid, + autojoin: true, + name: 'The Play', + nick: 'romeo', + extensions: [``], + }); + + await mock.waitForMUCDiscoInfo(_converse, muc_jid); + await u.waitUntil(() => state.chatboxes.length === 1); + + const IQ_stanzas = api.connection.get().IQ_stanzas; + + expect(bookmark.get('pinned')).toBe(true); + expect(state.chatboxes.get(muc_jid).bookmark.get('pinned')).toBe(true); + + // Now unpin the bookmark + await state.bookmarks.unpinBookmark(bookmark); + + const sent_stanza = await u.waitUntil(() => + IQ_stanzas.filter((s) => { + return sizzle(`publish[node="${Strophe.NS.BOOKMARKS2}"] conference[name="The Play"]`, s).length + }).pop() + ); + + expect(bookmark.get('pinned')).toBe(false); + expect(state.chatboxes.get(muc_jid).bookmark.get('pinned')).toBe(false); + + expect(sent_stanza).toEqualStanza(stx` + + + + + + romeo + + + + + + + http://jabber.org/protocol/pubsub#publish-options + + + true + + + max + + + never + + + whitelist + + + + + `); + }) + ); }); diff --git a/src/headless/plugins/bookmarks/tests/deprecated.js b/src/headless/plugins/bookmarks/tests/deprecated.js index da8846c48c..ef77c4c1fa 100644 --- a/src/headless/plugins/bookmarks/tests/deprecated.js +++ b/src/headless/plugins/bookmarks/tests/deprecated.js @@ -77,7 +77,7 @@ describe('A chat room', function () { id="${sent_stanza.getAttribute('id')}"/>`; _converse.api.connection.get()._dataRecv(mock.createRequest(stanza)); - expect(muc.get('bookmarked')).toBeTruthy(); + expect(muc.bookmark).toBeTruthy(); }), ); }); @@ -105,7 +105,7 @@ describe('A bookmark', function () { const { bookmarks } = _converse.state; await u.waitUntil(() => bookmarks.length); - await u.waitUntil(() => muc.get('bookmarked')); + await u.waitUntil(() => muc.bookmark); spyOn(bookmarks, 'sendBookmarkStanza').and.callThrough(); const sent_IQs = _converse.api.connection.get().IQ_stanzas; diff --git a/src/headless/plugins/chat/model.js b/src/headless/plugins/chat/model.js index 3630761756..c7c1367062 100644 --- a/src/headless/plugins/chat/model.js +++ b/src/headless/plugins/chat/model.js @@ -6,6 +6,7 @@ import converse from '../../shared/api/public.js'; import log from '@converse/log'; import { isUniView } from '../../utils/session.js'; import { sendChatState, sendMarker } from '../../shared/actions.js'; +import ModelWithBookmark from '../../shared/model-with-bookmark.js'; import ModelWithMessages from '../../shared/model-with-messages.js'; import ModelWithVCard from '../../shared/model-with-vcard'; import ModelWithContact from '../../shared/model-with-contact.js'; @@ -17,7 +18,9 @@ const { Strophe, u } = converse.env; /** * Represents a one-on-one chat conversation. */ -class ChatBox extends ModelWithVCard(ModelWithMessages(ModelWithContact(ColorAwareModel(ChatBoxBase)))) { +class ChatBox extends ModelWithBookmark( + ModelWithVCard(ModelWithMessages(ModelWithContact(ColorAwareModel(ChatBoxBase)))) +) { /** * @typedef {import('./message.js').default} Message * @typedef {import('../muc/muc.js').default} MUC @@ -27,7 +30,6 @@ class ChatBox extends ModelWithVCard(ModelWithMessages(ModelWithContact(ColorAwa defaults() { return { - bookmarked: false, hidden: isUniView() && !api.settings.get('singleton'), message_type: 'chat', num_unread: 0, diff --git a/src/headless/plugins/headlines/feed.js b/src/headless/plugins/headlines/feed.js index e940a9d252..7016bf0b77 100644 --- a/src/headless/plugins/headlines/feed.js +++ b/src/headless/plugins/headlines/feed.js @@ -9,7 +9,6 @@ import ChatBoxBase from '../../shared/chatbox.js'; export default class HeadlinesFeed extends ChatBoxBase { defaults() { return { - 'bookmarked': false, 'hidden': isUniView() && !api.settings.get('singleton'), 'message_type': 'headline', 'num_unread': 0, diff --git a/src/headless/plugins/muc/muc.js b/src/headless/plugins/muc/muc.js index 816922bf5a..02d6264a56 100644 --- a/src/headless/plugins/muc/muc.js +++ b/src/headless/plugins/muc/muc.js @@ -43,6 +43,7 @@ import { parseMUCMessage, parseMUCPresence } from './parsers.js'; import { sendMarker } from '../../shared/actions.js'; import ChatBoxBase from '../../shared/chatbox'; import ColorAwareModel from '../../shared/color'; +import ModelWithBookmark from '../../shared/model-with-bookmark'; import ModelWithMessages from '../../shared/model-with-messages'; import ModelWithVCard from '../../shared/model-with-vcard'; import { shouldCreateGroupchatMessage, isInfoVisible } from './utils.js'; @@ -55,7 +56,7 @@ const DISCO_INFO_TIMEOUT_ON_JOIN = 30000; /** * Represents a groupchat conversation. */ -class MUC extends ModelWithVCard(ModelWithMessages(ColorAwareModel(ChatBoxBase))) { +class MUC extends ModelWithBookmark(ModelWithVCard(ModelWithMessages(ColorAwareModel(ChatBoxBase)))) { /** * @typedef {import('../../shared/message.js').default} BaseMessage * @typedef {import('./message.js').default} MUCMessage @@ -72,7 +73,6 @@ class MUC extends ModelWithVCard(ModelWithMessages(ColorAwareModel(ChatBoxBase)) defaults() { /** @type {import('./types').DefaultMUCAttributes} */ return { - bookmarked: false, chat_state: undefined, closed: false, has_activity: false, // XEP-437 diff --git a/src/headless/shared/chatbox.js b/src/headless/shared/chatbox.js index a4f4a4a612..a87f5bfe82 100644 --- a/src/headless/shared/chatbox.js +++ b/src/headless/shared/chatbox.js @@ -5,13 +5,14 @@ import _converse from './_converse.js'; import converse from './api/public.js'; import log from '@converse/log'; import ModelWithMessages from './model-with-messages.js'; +import ModelWithBookmark from './model-with-bookmark.js'; const { u } = converse.env; /** * Base class for all chat boxes. Provides common methods. */ -export default class ChatBoxBase extends ModelWithMessages(Model) { +export default class ChatBoxBase extends ModelWithBookmark(ModelWithMessages(Model)) { async initialize() { await super.initialize(); const jid = this.get('jid'); diff --git a/src/headless/shared/model-with-bookmark.js b/src/headless/shared/model-with-bookmark.js new file mode 100644 index 0000000000..0c226e07a4 --- /dev/null +++ b/src/headless/shared/model-with-bookmark.js @@ -0,0 +1,19 @@ + +/** + * @template {import('./types').ModelExtender} T + * @param {T} BaseModel + */ +export default function ModelWithBookmark(BaseModel) { + return class ModelWithBookmark extends BaseModel { + initialize() { + super.initialize(); + this.bookmark = null; + } + + setBookmark(bookmark) { + this.bookmark = bookmark; + this.listenTo(this.bookmark, 'change', () => this.trigger('bookmark:change', bookmark)); + this.trigger('bookmark:change', bookmark); + } + }; +} diff --git a/src/headless/tests/mock.js b/src/headless/tests/mock.js index a7186d263f..09489f1d26 100644 --- a/src/headless/tests/mock.js +++ b/src/headless/tests/mock.js @@ -72,7 +72,7 @@ export const pend_names = ['Lord Capulet', 'Guard', 'Servant']; export const cur_names = Object.keys(current_contacts_map); export async function waitForRoster(_converse, type = 'current', length = -1, include_nick = true, grouped = true) { - const { Stanza, u, sizzle } = window.converse.env; + const { Stanza, Strophe, u, sizzle } = window.converse.env; const s = `iq[type="get"] query[xmlns="${Strophe.NS.ROSTER}"]`; const iq = await u.waitUntil(() => _converse.api.connection @@ -184,7 +184,7 @@ export async function waitUntilBookmarksReturned( ], node = 'urn:xmpp:bookmarks:1', ) { - const { u, sizzle } = window.converse.env; + const { Strophe, u, sizzle } = window.converse.env; await waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [{ 'category': 'pubsub', 'type': 'pep' }], features); const IQ_stanzas = _converse.api.connection.get().IQ_stanzas; const sent_stanza = await u.waitUntil(() => @@ -220,11 +220,11 @@ export async function waitUntilBookmarksReturned( id="${sent_stanza.getAttribute('id')}" xmlns="jabber:client"> - + ${bookmarks.map( (b) => stx` - ${b.nick ? stx`${b.nick}` : ''} diff --git a/src/headless/types/plugins/bookmarks/collection.d.ts b/src/headless/types/plugins/bookmarks/collection.d.ts index f4ec17cfeb..4ffdca2a06 100644 --- a/src/headless/types/plugins/bookmarks/collection.d.ts +++ b/src/headless/types/plugins/bookmarks/collection.d.ts @@ -78,6 +78,16 @@ declare class Bookmarks extends Collection { */ onBookmarksReceivedError(deferred: any, iq: Element): Promise; getUnopenedBookmarks(): Promise; + /** + * + * @param {Bookmark} bookmark + */ + pinBookmark(bookmark: Bookmark): void; + /** + * + * @param {Bookmark} bookmark + */ + unpinBookmark(bookmark: Bookmark): void; } import Bookmark from './model.js'; import { Collection } from '@converse/skeletor'; diff --git a/src/headless/types/plugins/bookmarks/model.d.ts b/src/headless/types/plugins/bookmarks/model.d.ts index 0ba37472ea..4b62200ecb 100644 --- a/src/headless/types/plugins/bookmarks/model.d.ts +++ b/src/headless/types/plugins/bookmarks/model.d.ts @@ -1,6 +1,7 @@ export default Bookmark; declare class Bookmark extends Model { constructor(attributes?: Partial, options?: import("@converse/skeletor").ModelOptions); + initialize(): void; getDisplayName(): any; } import { Model } from '@converse/skeletor'; diff --git a/src/headless/types/plugins/chat/model.d.ts b/src/headless/types/plugins/chat/model.d.ts index 6b7d641e23..b4f87066cc 100644 --- a/src/headless/types/plugins/chat/model.d.ts +++ b/src/headless/types/plugins/chat/model.d.ts @@ -1,5 +1,77 @@ export default ChatBox; declare const ChatBox_base: { + new (...args: any[]): { + [x: string]: any; + initialize(): void; + bookmark: any; + setBookmark(bookmark: any): void; + _browserStorage?: import("@converse/skeletor").BrowserStorage; + _changing: boolean; + _pending: boolean | import("@converse/skeletor").ModelOptions; + _previousAttributes?: import("@converse/skeletor").ModelAttributes; + _url: string; + _urlRoot: string; + attributes: import("@converse/skeletor").ModelAttributes; + changed: Partial; + cid: string; + collection?: import("@converse/skeletor").Collection; + id: string | number; + validationError: string | number | null; + browserStorage: import("@converse/skeletor").BrowserStorage; + readonly idAttribute: string; + readonly cidPrefix: string; + preinitialize(...args: any[]): void; + validate(attrs: import("@converse/skeletor").ObjectWithId | Partial, options?: import("@converse/skeletor").ModelOptions): string | number | null | void; + defaults(): Partial; + toJSON(): import("@converse/skeletor").ModelAttributes; + sync(method: import("@converse/skeletor").SyncOperation, model: import("@converse/skeletor").Model, options: import("@converse/skeletor").Options): any; + get(attr: K): import("@converse/skeletor").ModelAttributes[K]; + keys(): string[]; + values(): any[]; + pairs(): [string | number, any][]; + entries(): [string | number, any][]; + invert(): Record; + pick(...args: K[]): Pick; + omit(...args: K[]): Omit; + isEmpty(): boolean; + has(attr: string | number): boolean; + matches(attrs: Partial): boolean; + set(key: string | import("@converse/skeletor").ObjectWithId | Partial, val?: any, options?: import("@converse/skeletor").ModelOptions): any; + unset(attr: string | number, options?: import("@converse/skeletor").ModelOptions): any; + clear(options?: import("@converse/skeletor").ModelOptions): any; + hasChanged(attr?: string | number): boolean; + changedAttributes(diff?: Partial): false | Partial; + previous(attr: K): import("@converse/skeletor").ModelAttributes[K]; + previousAttributes(): import("@converse/skeletor").ModelAttributes; + fetch(options?: import("@converse/skeletor").Options): any; + save(key?: string | Partial, val?: any, options?: import("@converse/skeletor").ModelOptions): any; + destroy(options?: import("@converse/skeletor").ModelOptions): any; + urlRoot: string; + url: string; + parse(resp: any, options?: import("@converse/skeletor").ModelOptions): void | Partial; + isNew(): boolean; + isValid(options?: import("@converse/skeletor").ModelOptions): boolean; + _validate(attrs: import("@converse/skeletor").ObjectWithId | Partial, options?: import("@converse/skeletor").ModelOptions): boolean; + _events?: import("@converse/skeletor").EventHandlersMap; + _listeners?: import("@converse/skeletor").EventListenerMap; + _listeningTo?: import("@converse/skeletor").EventListenerMap; + _listenId?: string; + on(name: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback | import("@converse/skeletor").EventContext, context?: import("@converse/skeletor").EventContext): any; + listenTo(obj: import("@converse/skeletor").ObjectListenedTo, name: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback): any; + off(name?: string | import("@converse/skeletor").EventCallbackMap | null, callback?: import("@converse/skeletor").EventCallback | import("@converse/skeletor").EventContext | null, context?: import("@converse/skeletor").EventContext): any; + stopListening(obj?: any, name?: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback): any; + once(name: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback | import("@converse/skeletor").EventContext, context?: import("@converse/skeletor").EventContext): any; + listenToOnce(obj: any, name: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback): any; + trigger(name: string, ...args: any[]): any; + constructor: Function; + toString(): string; + toLocaleString(): string; + valueOf(): Object; + hasOwnProperty(v: PropertyKey): boolean; + isPrototypeOf(v: Object): boolean; + propertyIsEnumerable(v: PropertyKey): boolean; + }; +} & { new (...args: any[]): { [x: string]: any; _vcard: import("../vcard/vcard.js").default; @@ -376,7 +448,6 @@ declare class ChatBox extends ChatBox_base { * @typedef {import('../../shared/errors').StanzaParseError} StanzaParseError */ defaults(): { - bookmarked: boolean; hidden: boolean; message_type: string; num_unread: number; diff --git a/src/headless/types/plugins/headlines/feed.d.ts b/src/headless/types/plugins/headlines/feed.d.ts index c942a22c94..1e10e85321 100644 --- a/src/headless/types/plugins/headlines/feed.d.ts +++ b/src/headless/types/plugins/headlines/feed.d.ts @@ -4,7 +4,6 @@ export default class HeadlinesFeed extends ChatBoxBase { constructor(attrs: any, options: any); defaults(): { - bookmarked: boolean; hidden: boolean; message_type: string; num_unread: number; diff --git a/src/headless/types/plugins/muc/muc.d.ts b/src/headless/types/plugins/muc/muc.d.ts index 84b5919b00..397ec78df2 100644 --- a/src/headless/types/plugins/muc/muc.d.ts +++ b/src/headless/types/plugins/muc/muc.d.ts @@ -1,5 +1,77 @@ export default MUC; declare const MUC_base: { + new (...args: any[]): { + [x: string]: any; + initialize(): void; + bookmark: any; + setBookmark(bookmark: any): void; + _browserStorage?: import("@converse/skeletor").BrowserStorage; + _changing: boolean; + _pending: boolean | import("@converse/skeletor").ModelOptions; + _previousAttributes?: import("@converse/skeletor").ModelAttributes; + _url: string; + _urlRoot: string; + attributes: import("@converse/skeletor").ModelAttributes; + changed: Partial; + cid: string; + collection?: import("@converse/skeletor").Collection; + id: string | number; + validationError: string | number | null; + browserStorage: import("@converse/skeletor").BrowserStorage; + readonly idAttribute: string; + readonly cidPrefix: string; + preinitialize(...args: any[]): void; + validate(attrs: import("@converse/skeletor").ObjectWithId | Partial, options?: import("@converse/skeletor").ModelOptions): string | number | null | void; + defaults(): Partial; + toJSON(): import("@converse/skeletor").ModelAttributes; + sync(method: import("@converse/skeletor").SyncOperation, model: Model, options: import("@converse/skeletor").Options): any; + get(attr: K): import("@converse/skeletor").ModelAttributes[K]; + keys(): string[]; + values(): any[]; + pairs(): [string | number, any][]; + entries(): [string | number, any][]; + invert(): Record; + pick(...args: K[]): Pick; + omit(...args: K[]): Omit; + isEmpty(): boolean; + has(attr: string | number): boolean; + matches(attrs: Partial): boolean; + set(key: string | import("@converse/skeletor").ObjectWithId | Partial, val?: any, options?: import("@converse/skeletor").ModelOptions): any; + unset(attr: string | number, options?: import("@converse/skeletor").ModelOptions): any; + clear(options?: import("@converse/skeletor").ModelOptions): any; + hasChanged(attr?: string | number): boolean; + changedAttributes(diff?: Partial): false | Partial; + previous(attr: K): import("@converse/skeletor").ModelAttributes[K]; + previousAttributes(): import("@converse/skeletor").ModelAttributes; + fetch(options?: import("@converse/skeletor").Options): any; + save(key?: string | Partial, val?: any, options?: import("@converse/skeletor").ModelOptions): any; + destroy(options?: import("@converse/skeletor").ModelOptions): any; + urlRoot: string; + url: string; + parse(resp: any, options?: import("@converse/skeletor").ModelOptions): void | Partial; + isNew(): boolean; + isValid(options?: import("@converse/skeletor").ModelOptions): boolean; + _validate(attrs: import("@converse/skeletor").ObjectWithId | Partial, options?: import("@converse/skeletor").ModelOptions): boolean; + _events?: import("@converse/skeletor").EventHandlersMap; + _listeners?: import("@converse/skeletor").EventListenerMap; + _listeningTo?: import("@converse/skeletor").EventListenerMap; + _listenId?: string; + on(name: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback | import("@converse/skeletor").EventContext, context?: import("@converse/skeletor").EventContext): any; + listenTo(obj: import("@converse/skeletor").ObjectListenedTo, name: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback): any; + off(name?: string | import("@converse/skeletor").EventCallbackMap | null, callback?: import("@converse/skeletor").EventCallback | import("@converse/skeletor").EventContext | null, context?: import("@converse/skeletor").EventContext): any; + stopListening(obj?: any, name?: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback): any; + once(name: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback | import("@converse/skeletor").EventContext, context?: import("@converse/skeletor").EventContext): any; + listenToOnce(obj: any, name: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback): any; + trigger(name: string, ...args: any[]): any; + constructor: Function; + toString(): string; + toLocaleString(): string; + valueOf(): Object; + hasOwnProperty(v: PropertyKey): boolean; + isPrototypeOf(v: Object): boolean; + propertyIsEnumerable(v: PropertyKey): boolean; + }; +} & { new (...args: any[]): { [x: string]: any; _vcard: import("../vcard").VCard; @@ -301,7 +373,6 @@ declare class MUC extends MUC_base { * @typedef {import('../../shared/errors').StanzaParseError} StanzaParseError */ defaults(): { - bookmarked: boolean; chat_state: any; closed: boolean; has_activity: boolean; diff --git a/src/headless/types/shared/api/events.d.ts b/src/headless/types/shared/api/events.d.ts index bc724892a1..e14040dcd9 100644 --- a/src/headless/types/shared/api/events.d.ts +++ b/src/headless/types/shared/api/events.d.ts @@ -2,7 +2,6 @@ declare namespace _default { /** * Lets you trigger events, which can be listened to via * {@link _converse.api.listen.on} or {@link _converse.api.listen.once} - * (see [_converse.api.listen](http://localhost:8000/docs/html/api/-_converse.api.listen.html)). * * Some events also double as promises and can be waited on via {@link _converse.api.waitUntil}. * @@ -19,7 +18,7 @@ declare namespace _default { /** * Triggers a hook which can be intercepted by registered listeners via * {@link _converse.api.listen.on} or {@link _converse.api.listen.once}. - * (see [_converse.api.listen](http://localhost:8000/docs/html/api/-_converse.api.listen.html)). + * * A hook is a special kind of event which allows you to intercept a data * structure in order to modify it, before passing it back. * @async diff --git a/src/headless/types/shared/chatbox.d.ts b/src/headless/types/shared/chatbox.d.ts index 9b86213c80..7d80f4f385 100644 --- a/src/headless/types/shared/chatbox.d.ts +++ b/src/headless/types/shared/chatbox.d.ts @@ -1,4 +1,76 @@ declare const ChatBoxBase_base: { + new (...args: any[]): { + [x: string]: any; + initialize(): void; + bookmark: any; + setBookmark(bookmark: any): void; + _browserStorage?: import("@converse/skeletor").BrowserStorage; + _changing: boolean; + _pending: boolean | import("@converse/skeletor").ModelOptions; + _previousAttributes?: import("@converse/skeletor").ModelAttributes; + _url: string; + _urlRoot: string; + attributes: import("@converse/skeletor").ModelAttributes; + changed: Partial; + cid: string; + collection?: import("@converse/skeletor").Collection; + id: string | number; + validationError: string | number | null; + browserStorage: import("@converse/skeletor").BrowserStorage; + readonly idAttribute: string; + readonly cidPrefix: string; + preinitialize(...args: any[]): void; + validate(attrs: import("@converse/skeletor").ObjectWithId | Partial, options?: import("@converse/skeletor").ModelOptions): string | number | null | void; + defaults(): Partial; + toJSON(): import("@converse/skeletor").ModelAttributes; + sync(method: import("@converse/skeletor").SyncOperation, model: Model, options: import("@converse/skeletor").Options): any; + get(attr: K): import("@converse/skeletor").ModelAttributes[K]; + keys(): string[]; + values(): any[]; + pairs(): [string | number, any][]; + entries(): [string | number, any][]; + invert(): Record; + pick(...args: K[]): Pick; + omit(...args: K[]): Omit; + isEmpty(): boolean; + has(attr: string | number): boolean; + matches(attrs: Partial): boolean; + set(key: string | import("@converse/skeletor").ObjectWithId | Partial, val?: any, options?: import("@converse/skeletor").ModelOptions): any; + unset(attr: string | number, options?: import("@converse/skeletor").ModelOptions): any; + clear(options?: import("@converse/skeletor").ModelOptions): any; + hasChanged(attr?: string | number): boolean; + changedAttributes(diff?: Partial): false | Partial; + previous(attr: K): import("@converse/skeletor").ModelAttributes[K]; + previousAttributes(): import("@converse/skeletor").ModelAttributes; + fetch(options?: import("@converse/skeletor").Options): any; + save(key?: string | Partial, val?: any, options?: import("@converse/skeletor").ModelOptions): any; + destroy(options?: import("@converse/skeletor").ModelOptions): any; + urlRoot: string; + url: string; + parse(resp: any, options?: import("@converse/skeletor").ModelOptions): void | Partial; + isNew(): boolean; + isValid(options?: import("@converse/skeletor").ModelOptions): boolean; + _validate(attrs: import("@converse/skeletor").ObjectWithId | Partial, options?: import("@converse/skeletor").ModelOptions): boolean; + _events?: import("@converse/skeletor").EventHandlersMap; + _listeners?: import("@converse/skeletor").EventListenerMap; + _listeningTo?: import("@converse/skeletor").EventListenerMap; + _listenId?: string; + on(name: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback | import("@converse/skeletor").EventContext, context?: import("@converse/skeletor").EventContext): any; + listenTo(obj: import("@converse/skeletor").ObjectListenedTo, name: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback): any; + off(name?: string | import("@converse/skeletor").EventCallbackMap | null, callback?: import("@converse/skeletor").EventCallback | import("@converse/skeletor").EventContext | null, context?: import("@converse/skeletor").EventContext): any; + stopListening(obj?: any, name?: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback): any; + once(name: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback | import("@converse/skeletor").EventContext, context?: import("@converse/skeletor").EventContext): any; + listenToOnce(obj: any, name: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback): any; + trigger(name: string, ...args: any[]): any; + constructor: Function; + toString(): string; + toLocaleString(): string; + valueOf(): Object; + hasOwnProperty(v: PropertyKey): boolean; + isPrototypeOf(v: Object): boolean; + propertyIsEnumerable(v: PropertyKey): boolean; + }; +} & { new (...args: any[]): { [x: string]: any; disable_mam: boolean; diff --git a/src/headless/types/shared/model-with-bookmark.d.ts b/src/headless/types/shared/model-with-bookmark.d.ts new file mode 100644 index 0000000000..3b9baf723e --- /dev/null +++ b/src/headless/types/shared/model-with-bookmark.d.ts @@ -0,0 +1,78 @@ +/** + * @template {import('./types').ModelExtender} T + * @param {T} BaseModel + */ +export default function ModelWithBookmark(BaseModel: T): { + new (...args: any[]): { + [x: string]: any; + initialize(): void; + bookmark: any; + setBookmark(bookmark: any): void; + _browserStorage?: import("@converse/skeletor").BrowserStorage; + _changing: boolean; + _pending: boolean | import("@converse/skeletor").ModelOptions; + _previousAttributes?: import("@converse/skeletor").ModelAttributes; + _url: string; + _urlRoot: string; + attributes: import("@converse/skeletor").ModelAttributes; + changed: Partial; + cid: string; + collection?: import("@converse/skeletor").Collection; + id: string | number; + validationError: string | number | null; + browserStorage: import("@converse/skeletor").BrowserStorage; + readonly idAttribute: string; + readonly cidPrefix: string; + preinitialize(...args: any[]): void; + validate(attrs: import("@converse/skeletor").ObjectWithId | Partial, options?: import("@converse/skeletor").ModelOptions): string | number | null | void; + defaults(): Partial; + toJSON(): import("@converse/skeletor").ModelAttributes; + sync(method: import("@converse/skeletor").SyncOperation, model: import("@converse/skeletor").Model, options: import("@converse/skeletor").Options): any; + get(attr: K): import("@converse/skeletor").ModelAttributes[K]; + keys(): string[]; + values(): any[]; + pairs(): [string | number, any][]; + entries(): [string | number, any][]; + invert(): Record; + pick(...args: K[]): Pick; + omit(...args: K[]): Omit; + isEmpty(): boolean; + has(attr: string | number): boolean; + matches(attrs: Partial): boolean; + set(key: string | import("@converse/skeletor").ObjectWithId | Partial, val?: any, options?: import("@converse/skeletor").ModelOptions): any; + unset(attr: string | number, options?: import("@converse/skeletor").ModelOptions): any; + clear(options?: import("@converse/skeletor").ModelOptions): any; + hasChanged(attr?: string | number): boolean; + changedAttributes(diff?: Partial): false | Partial; + previous(attr: K): import("@converse/skeletor").ModelAttributes[K]; + previousAttributes(): import("@converse/skeletor").ModelAttributes; + fetch(options?: import("@converse/skeletor").Options): any; + save(key?: string | Partial, val?: any, options?: import("@converse/skeletor").ModelOptions): any; + destroy(options?: import("@converse/skeletor").ModelOptions): any; + urlRoot: string; + url: string; + parse(resp: any, options?: import("@converse/skeletor").ModelOptions): void | Partial; + isNew(): boolean; + isValid(options?: import("@converse/skeletor").ModelOptions): boolean; + _validate(attrs: import("@converse/skeletor").ObjectWithId | Partial, options?: import("@converse/skeletor").ModelOptions): boolean; + _events?: import("@converse/skeletor").EventHandlersMap; + _listeners?: import("@converse/skeletor").EventListenerMap; + _listeningTo?: import("@converse/skeletor").EventListenerMap; + _listenId?: string; + on(name: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback | import("@converse/skeletor").EventContext, context?: import("@converse/skeletor").EventContext): any; + listenTo(obj: import("@converse/skeletor").ObjectListenedTo, name: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback): any; + off(name?: string | import("@converse/skeletor").EventCallbackMap | null, callback?: import("@converse/skeletor").EventCallback | import("@converse/skeletor").EventContext | null, context?: import("@converse/skeletor").EventContext): any; + stopListening(obj?: any, name?: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback): any; + once(name: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback | import("@converse/skeletor").EventContext, context?: import("@converse/skeletor").EventContext): any; + listenToOnce(obj: any, name: string | import("@converse/skeletor").EventCallbackMap, callback?: import("@converse/skeletor").EventCallback): any; + trigger(name: string, ...args: any[]): any; + constructor: Function; + toString(): string; + toLocaleString(): string; + valueOf(): Object; + hasOwnProperty(v: PropertyKey): boolean; + isPrototypeOf(v: Object): boolean; + propertyIsEnumerable(v: PropertyKey): boolean; + }; +} & T; +//# sourceMappingURL=model-with-bookmark.d.ts.map \ No newline at end of file diff --git a/src/plugins/bookmark-views/components/bookmarks-pin-list.js b/src/plugins/bookmark-views/components/bookmarks-pin-list.js new file mode 100644 index 0000000000..6bb7d5608b --- /dev/null +++ b/src/plugins/bookmark-views/components/bookmarks-pin-list.js @@ -0,0 +1,37 @@ +import { _converse, api, constants, Model, u } from '@converse/headless'; +import tplBookmarksPinList from './templates/pin-list'; +import { RoomsList } from 'plugins/roomslist/view'; + +const { initStorage } = u; + +export class PinnedBookmarksView extends RoomsList { + model = null; + + initialize() { + const bare_jid = _converse.session.get('bare_jid'); + const id = `converse.bookmarks-pin-list-model-${bare_jid}`; + this.model = new Model({ toggle_state: constants.OPENED }); + _converse.state.bookmarks_pin_list = this.model; + + initStorage(this.model, id); + this.model.fetch(); + + this.addEventListeners(); + + this.requestUpdate(); + } + + /** @returns {import('@converse/headless').MUC[]} */ + getRoomsToShow() { + const { chatboxes } = _converse.state; + const rooms = chatboxes.filter((m) => m.bookmark?.get('pinned')); + rooms.sort((a, b) => (a.getDisplayName().toLowerCase() <= b.getDisplayName().toLowerCase() ? -1 : 1)); + return rooms; + } + + render() { + return tplBookmarksPinList(this); + } +} + +api.elements.define('converse-pinned-bookmarks', PinnedBookmarksView); diff --git a/src/plugins/bookmark-views/components/styles/pin-list.scss b/src/plugins/bookmark-views/components/styles/pin-list.scss new file mode 100644 index 0000000000..6abb76e75d --- /dev/null +++ b/src/plugins/bookmark-views/components/styles/pin-list.scss @@ -0,0 +1,19 @@ +.conversejs { + converse-pinned-bookmarks { + padding-bottom: 1rem; + .list-item { + .open-room { + display: flex; + flex-direction: row; + line-height: 1.5em; + height: 2.5em; + padding: 0.2em 0; + span { + overflow-x: hidden; + text-overflow: ellipsis; + padding-top: 0.25em; + } + } + } + } +} diff --git a/src/plugins/bookmark-views/components/templates/pin-list.js b/src/plugins/bookmark-views/components/templates/pin-list.js new file mode 100644 index 0000000000..63767dfe5c --- /dev/null +++ b/src/plugins/bookmark-views/components/templates/pin-list.js @@ -0,0 +1,38 @@ +import { constants } from "@converse/headless"; +import { __ } from "i18n"; +import { html } from "lit"; +import { tplRoomItem } from "shared/roomslist/templates/room-item"; +import '../styles/pin-list.scss'; + +/** + * @param {import('plugins/bookmark-views/components/bookmarks-pin-list').PinnedBookmarksView} el + */ +export default (el) => { + const rooms = el.getRoomsToShow(); + const is_closed = el.model.get('toggle_state') === constants.CLOSED; + + return html` + + +
+
    + ${ + rooms.map(/** @param {import('@converse/headless').MUC} room */(room) => tplRoomItem(el, room)) + } +
+
`; +} diff --git a/src/plugins/bookmark-views/index.js b/src/plugins/bookmark-views/index.js index e927fff4ea..82f8efcb9d 100644 --- a/src/plugins/bookmark-views/index.js +++ b/src/plugins/bookmark-views/index.js @@ -9,6 +9,7 @@ import BookmarkForm from './components/bookmark-form.js'; import BookmarksView from './components/bookmarks-list.js'; import { BookmarkableChatRoomView } from './mixins.js'; import { removeBookmarkViaEvent } from './utils.js'; +import { PinnedBookmarksView } from './components/bookmarks-pin-list.js'; import './styles/bookmarks.scss'; @@ -36,6 +37,7 @@ converse.plugins.add('converse-bookmark-views', { removeBookmarkViaEvent, MUCBookmarkForm: BookmarkForm, BookmarksView, + PinnedBookmarksView, }; Object.assign(_converse, exports); // DEPRECATED diff --git a/src/plugins/bookmark-views/tests/bookmarks-pin-list.js b/src/plugins/bookmark-views/tests/bookmarks-pin-list.js new file mode 100644 index 0000000000..3bb601be6a --- /dev/null +++ b/src/plugins/bookmark-views/tests/bookmarks-pin-list.js @@ -0,0 +1,56 @@ +/* global mock, converse */ +const { Strophe, u } = converse.env; + +describe("The bookmarks pin list", function () { + it("shows a list of pinned bookmarks", mock.initConverse(['connected', 'chatboxesFetched'], {}, async function (_converse) { + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 0); + await mock.waitUntilBookmarksReturned(_converse); + await mock.openControlBox(_converse); + + const bookmarks_pin_list = document.querySelector('converse-pinned-bookmarks'); + const main_list = document.querySelector('converse-rooms-list'); + + let muc_jid = 'room@conference.shakespeare.lit'; + await api.bookmarks.set({ + jid: muc_jid, + name: 'Romeo\'s room', + autojoin: true, + nick: 'romeo', + extensions: [``], + }) + await mock.waitForMUCDiscoInfo(_converse, muc_jid); + + await u.waitUntil(() => bookmarks_pin_list.querySelectorAll(".open-room").length); + let room_els = bookmarks_pin_list.querySelectorAll(".open-room"); + expect(room_els.length).toBe(1); + expect(main_list.querySelectorAll(".open-room").length).toBe(0); + + muc_jid = 'lounge@montague.lit'; + await api.bookmarks.set({ + jid: muc_jid, + name: 'Lounge', + autojoin: true, + nick: 'romeo', + extensions: [``], + }); + await mock.waitForMUCDiscoInfo(_converse, muc_jid); + + await u.waitUntil(() => bookmarks_pin_list.querySelectorAll(".open-room").length > 1); + room_els = bookmarks_pin_list.querySelectorAll(".open-room"); + expect(room_els.length).toBe(2); + expect(main_list.querySelectorAll(".open-room").length).toBe(0); + + // Unpin a room + bookmarks_pin_list.querySelector('.unpin-room').click(); + await u.waitUntil(() => bookmarks_pin_list.querySelectorAll(".open-room").length === 1); + expect(bookmarks_pin_list.querySelectorAll(".open-room").length).toBe(1); + expect(main_list.querySelectorAll(".open-room").length).toBe(1); + + // pin it again + main_list.querySelector('.pin-room').click(); + await u.waitUntil(() => bookmarks_pin_list.querySelectorAll(".open-room").length === 2); + expect(bookmarks_pin_list.querySelectorAll(".open-room").length).toBe(2); + expect(main_list.querySelectorAll(".open-room").length).toBe(0); + })); +}); diff --git a/src/plugins/bookmark-views/tests/bookmarks.js b/src/plugins/bookmark-views/tests/bookmarks.js index dd5379ecec..da6ca8ce20 100644 --- a/src/plugins/bookmark-views/tests/bookmarks.js +++ b/src/plugins/bookmark-views/tests/bookmarks.js @@ -47,14 +47,14 @@ describe('Bookmarks', function () { - JC - JC @@ -78,19 +78,19 @@ describe('Bookmarks', function () { id="${u.getUniqueId()}" xmlns="jabber:client"> - + - + JC - + JC - + JC @@ -115,19 +115,19 @@ describe('Bookmarks', function () { id="${u.getUniqueId()}" xmlns="jabber:client"> - + - + JC - + JC - + JC @@ -162,13 +162,13 @@ describe('Bookmarks', function () { // Client requests all items const IQ_stanzas = _converse.api.connection.get().IQ_stanzas; const sent_stanza = await u.waitUntil(() => - IQ_stanzas.filter((s) => sizzle('items[node="urn:xmpp:bookmarks:1"]', s).length).pop(), + IQ_stanzas.filter((s) => sizzle(`items[node="${Strophe.NS.BOOKMARKS2}"]`, s).length).pop(), ); expect(sent_stanza).toEqualStanza( stx` - + `, ); @@ -184,16 +184,16 @@ describe('Bookmarks', function () { to="${_converse.jid}" id="${sent_stanza.getAttribute('id')}"> - + - JC - diff --git a/src/plugins/controlbox/model.js b/src/plugins/controlbox/model.js index c23d5fb1ec..9e8f59ebf5 100644 --- a/src/plugins/controlbox/model.js +++ b/src/plugins/controlbox/model.js @@ -13,7 +13,6 @@ const { CONTROLBOX_TYPE } = constants; class ControlBox extends Model { defaults() { return { - bookmarked: false, box_id: 'controlbox', chat_state: undefined, closed: !api.settings.get('show_controlbox_by_default'), diff --git a/src/plugins/controlbox/templates/controlbox.js b/src/plugins/controlbox/templates/controlbox.js index de632966d9..d6887326c2 100644 --- a/src/plugins/controlbox/templates/controlbox.js +++ b/src/plugins/controlbox/templates/controlbox.js @@ -47,6 +47,7 @@ export default (el) => { ? html`
+
${api.settings.get('authentication') === ANONYMOUS ? '' diff --git a/src/plugins/roomslist/templates/roomslist.js b/src/plugins/roomslist/templates/roomslist.js index b39fbb9054..b25f6827fb 100644 --- a/src/plugins/roomslist/templates/roomslist.js +++ b/src/plugins/roomslist/templates/roomslist.js @@ -3,72 +3,14 @@ * @typedef {import('@converse/headless').MUC} MUC */ import { html } from "lit"; -import { _converse, api, u, constants } from "@converse/headless"; +import { api, constants } from "@converse/headless"; import 'plugins/muc-views/modals/add-muc.js'; import 'plugins/muc-views/modals/muc-list.js'; import { __ } from 'i18n'; -import { getUnreadMsgsDisplay } from "shared/chat/utils"; - import '../styles/roomsgroups.scss'; +import { tplRoomItem } from "shared/roomslist/templates/room-item"; const { CLOSED } = constants; -const { isUniView } = u; - -/** @param {MUC} room */ -function isCurrentlyOpen (room) { - return isUniView() && !room.get('hidden'); -} - -/** @param {MUC} room */ -function tplUnreadIndicator (room) { - return html`${ getUnreadMsgsDisplay(room) }`; -} - -function tplActivityIndicator () { - return html``; -} - -/** - * @param {RoomsList} el - * @param {MUC} room - */ -function tplRoomItem (el, room) { - const i18n_leave_room = __('Leave this groupchat'); - const has_unread_msgs = room.get('num_unread_general') || room.get('has_activity'); - return html` -
  • - - el.openRoom(ev)}> - - ${ room.get('num_unread') ? - tplUnreadIndicator(room) : - (room.get('has_activity') ? tplActivityIndicator() : '') } - ${room.getDisplayName()} - - - el.closeRoom(ev)}> - - -
  • `; -} /** * @param {RoomsList} el diff --git a/src/plugins/roomslist/tests/grouplists.js b/src/plugins/roomslist/tests/grouplists.js index a2e4c1bd4f..da2781c98c 100644 --- a/src/plugins/roomslist/tests/grouplists.js +++ b/src/plugins/roomslist/tests/grouplists.js @@ -107,7 +107,7 @@ describe('A MUC domain group', function () { await mock.waitForRoster(_converse, 'current', 0); await mock.openControlBox(_converse); const controlbox = _converse.chatboxviews.get('controlbox'); - const list = controlbox.querySelector('.list-container--openrooms'); + const list = controlbox.querySelector('converse-rooms-list .list-container--openrooms'); const nick = 'JC'; const muc_jid = 'room@conference.shakespeare.lit'; _converse.api.rooms.open(muc_jid, { nick }); diff --git a/src/plugins/roomslist/view.js b/src/plugins/roomslist/view.js index 1b14e515c6..79ee593bce 100644 --- a/src/plugins/roomslist/view.js +++ b/src/plugins/roomslist/view.js @@ -19,6 +19,12 @@ export class RoomsList extends CustomElement { initStorage(this.model, id); this.model.fetch(); + this.addEventListeners(); + + this.requestUpdate(); + } + + addEventListeners() { const { chatboxes } = _converse.state; this.listenTo(chatboxes, 'add', this.renderIfChatRoom); this.listenTo(chatboxes, 'remove', this.renderIfChatRoom); @@ -26,9 +32,8 @@ export class RoomsList extends CustomElement { this.listenTo(chatboxes, 'change', this.renderIfRelevantChange); this.listenTo(chatboxes, 'vcard:add', () => this.requestUpdate()); this.listenTo(chatboxes, 'vcard:change', () => this.requestUpdate()); + this.listenTo(chatboxes, 'bookmark:change', () => this.requestUpdate()); this.listenTo(this.model, 'change', () => this.requestUpdate()); - - this.requestUpdate(); } render() { @@ -42,7 +47,7 @@ export class RoomsList extends CustomElement { /** @param {import('@converse/headless').Model} model */ renderIfRelevantChange(model) { - const attrs = ['bookmarked', 'hidden', 'name', 'num_unread', 'num_unread_general', 'has_activity']; + const attrs = ['hidden', 'name', 'num_unread', 'num_unread_general', 'has_activity']; const changed = model.changed || {}; if (u.muc.isChatRoom(model) && Object.keys(changed).filter((m) => attrs.includes(m)).length) { this.requestUpdate(); @@ -52,7 +57,7 @@ export class RoomsList extends CustomElement { /** @returns {import('@converse/headless').MUC[]} */ getRoomsToShow() { const { chatboxes } = _converse.state; - const rooms = chatboxes.filter((m) => m.get('type') === CHATROOMS_TYPE && !m.get('closed')); + const rooms = chatboxes.filter((m) => m.get('type') === CHATROOMS_TYPE && !m.get('closed') && !m.bookmark?.get('pinned')); rooms.sort((a, b) => (a.getDisplayName().toLowerCase() <= b.getDisplayName().toLowerCase() ? -1 : 1)); return rooms; } @@ -82,6 +87,29 @@ export class RoomsList extends CustomElement { } } + /** @param {Event} ev */ + pinRoom(ev) { + ev.preventDefault(); + const target = /** @type {HTMLElement} */ (ev.currentTarget); + const jid = target.getAttribute('data-room-jid'); + const { bookmarks } = _converse.state; + bookmarks + .where({ jid }) + .forEach(/** @param {import('@converse/headless').Bookmark} b */ (b) => + bookmarks.pinBookmark(b)); + } + + /** @param {Event} ev */ + unpinRoom(ev) { + ev.preventDefault(); + const target = /** @type {HTMLElement} */ (ev.currentTarget); + const jid = target.getAttribute('data-room-jid'); + const { bookmarks } = _converse.state; + bookmarks + .where({ jid }) + .forEach((b) => bookmarks.unpinBookmark(b)); + } + /** @param {Event} [ev] */ toggleRoomsList(ev) { ev?.preventDefault?.(); diff --git a/src/shared/roomslist/templates/room-item.js b/src/shared/roomslist/templates/room-item.js new file mode 100644 index 0000000000..e01dd544f1 --- /dev/null +++ b/src/shared/roomslist/templates/room-item.js @@ -0,0 +1,116 @@ +/** + * @typedef {import('plugins/roomslist/view').RoomsList} RoomsList + * @typedef {import('plugins/bookmark-views/components/bookmarks-pin-list').PinnedBookmarksView} PinnedBookmarksView + * @typedef {import('@converse/headless').MUC} MUC + */ +import { html } from "lit"; +import { api, u } from "@converse/headless"; +import 'plugins/muc-views/modals/add-muc.js'; +import 'plugins/muc-views/modals/muc-list.js'; +import { __ } from 'i18n'; +import { getUnreadMsgsDisplay } from "shared/chat/utils"; + +const { isUniView } = u; + +/** @param {MUC} room */ +function isCurrentlyOpen (room) { + return isUniView() && !room.get('hidden'); +} + +/** @param {MUC} room */ +function tplUnreadIndicator (room) { + return html`${ getUnreadMsgsDisplay(room) }`; +} + +function tplActivityIndicator () { + return html``; +} + +/** + * @param {RoomsList|PinnedBookmarksView} el + * @param {MUC} room + */ +export function tplRoomItem (el, room) { + const i18n_leave_room = __('Leave this groupchat'); + const has_unread_msgs = room.get('num_unread_general') || room.get('has_activity'); + + const buttons = [ + tplRoomMenuItem({ + room, + alt_text: i18n_leave_room, + text: __('Leave'), + icon_class: 'fa-sign-out-alt', + btn_class: 'close-room', + handler: (ev) => el.closeRoom(ev) + }), + ]; + + if (api.settings.get('allow_bookmarks')) { + if (!room.bookmark?.get('pinned')) { + buttons.push(tplRoomMenuItem({ + room, + alt_text: __('Pin this groupchat to the top of the list'), + text: __('Pin'), + icon_class: 'fa-bookmark', + btn_class: 'pin-room', + handler: (ev) => el.pinRoom(ev) + })) + } else { + buttons.push(tplRoomMenuItem({ + room, + alt_text: __('Unpin this groupchat from the top of the list'), + text: __('Unpin'), + icon_class: 'fa-bookmark-empty', + btn_class: 'unpin-room', + handler: (ev) => el.unpinRoom(ev) + })) + } + } + + return html` +
  • + + el.openRoom(ev)}> + + ${ room.get('num_unread') ? + tplUnreadIndicator(room) : + (room.get('has_activity') ? tplActivityIndicator() : '') } + ${room.getDisplayName()} + + + +
  • `; +} + +/** + * @param {Object} config + * @param {MUC} config.room + * @param {string} config.alt_text + * @param {string} config.text + * @param {function} config.handler + * @param {string} config.icon_class + * @param {string} config.btn_class + * @returns + */ +function tplRoomMenuItem (config) { + const { room, alt_text, text, handler, icon_class, btn_class } = config; + return html` + + ${text} + `; +} diff --git a/src/types/entry.d.ts b/src/types/entry.d.ts index 1f4abfd0bf..55b31d24b5 100644 --- a/src/types/entry.d.ts +++ b/src/types/entry.d.ts @@ -21,7 +21,7 @@ declare namespace converse { * Public API method which explicitly loads Converse and allows you the * possibility to pass in configuration settings which need to be defined * before loading. Currently this is only the - * [assets_path](https://conversejs.org/docs/html/configuration.html#assets_path) + * [assets_path](https://conversejs.org/docs/configuration/#assets_path) * setting. * * If not called explicitly, this method will be called implicitly once diff --git a/src/types/plugins/bookmark-views/components/bookmarks-pin-list.d.ts b/src/types/plugins/bookmark-views/components/bookmarks-pin-list.d.ts new file mode 100644 index 0000000000..a4e7c4b1cf --- /dev/null +++ b/src/types/plugins/bookmark-views/components/bookmarks-pin-list.d.ts @@ -0,0 +1,5 @@ +export class PinnedBookmarksView extends RoomsList { + model: any; +} +import { RoomsList } from 'plugins/roomslist/view'; +//# sourceMappingURL=bookmarks-pin-list.d.ts.map \ No newline at end of file diff --git a/src/types/plugins/bookmark-views/components/templates/pin-list.d.ts b/src/types/plugins/bookmark-views/components/templates/pin-list.d.ts new file mode 100644 index 0000000000..8a29663ba1 --- /dev/null +++ b/src/types/plugins/bookmark-views/components/templates/pin-list.d.ts @@ -0,0 +1,3 @@ +declare function _default(el: import("plugins/bookmark-views/components/bookmarks-pin-list").PinnedBookmarksView): import("lit-html").TemplateResult<1>; +export default _default; +//# sourceMappingURL=pin-list.d.ts.map \ No newline at end of file diff --git a/src/types/plugins/controlbox/model.d.ts b/src/types/plugins/controlbox/model.d.ts index 9dafc68b5d..50551599eb 100644 --- a/src/types/plugins/controlbox/model.d.ts +++ b/src/types/plugins/controlbox/model.d.ts @@ -9,7 +9,6 @@ export default ControlBox; declare class ControlBox extends Model { constructor(attributes?: Partial, options?: import("@converse/skeletor").ModelOptions); defaults(): { - bookmarked: boolean; box_id: string; chat_state: any; closed: boolean; diff --git a/src/types/plugins/roomslist/view.d.ts b/src/types/plugins/roomslist/view.d.ts index 0139f4dadd..3d71742438 100644 --- a/src/types/plugins/roomslist/view.d.ts +++ b/src/types/plugins/roomslist/view.d.ts @@ -1,6 +1,7 @@ export class RoomsList extends CustomElement { initialize(): void; model: RoomsListModel; + addEventListeners(): void; render(): import("lit-html").TemplateResult<1>; /** @param {import('@converse/headless').Model} model */ renderIfChatRoom(model: import("@converse/headless").Model): void; @@ -12,6 +13,10 @@ export class RoomsList extends CustomElement { openRoom(ev: Event): Promise; /** @param {Event} ev */ closeRoom(ev: Event): Promise; + /** @param {Event} ev */ + pinRoom(ev: Event): void; + /** @param {Event} ev */ + unpinRoom(ev: Event): void; /** @param {Event} [ev] */ toggleRoomsList(ev?: Event): void; /** diff --git a/src/types/shared/roomslist/templates/room-item.d.ts b/src/types/shared/roomslist/templates/room-item.d.ts new file mode 100644 index 0000000000..cacb282c56 --- /dev/null +++ b/src/types/shared/roomslist/templates/room-item.d.ts @@ -0,0 +1,9 @@ +/** + * @param {RoomsList|PinnedBookmarksView} el + * @param {MUC} room + */ +export function tplRoomItem(el: RoomsList | PinnedBookmarksView, room: MUC): import("lit-html").TemplateResult<1>; +export type RoomsList = import("plugins/roomslist/view").RoomsList; +export type PinnedBookmarksView = import("plugins/bookmark-views/components/bookmarks-pin-list").PinnedBookmarksView; +export type MUC = import("@converse/headless").MUC; +//# sourceMappingURL=room-item.d.ts.map \ No newline at end of file