Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
- #3939: Don't show invitations to groupchats in which the user is already present
- #3941: add adhoc completed command result and text-multi as merged lines of text
- Don't render unfurls for retracted messages.
- #3949: Allow pinning bookmarked conversations to the top (XEP-0469)

## 12.0.0 (2025-08-28)

Expand Down
48 changes: 48 additions & 0 deletions src/headless/plugins/bookmarks/collection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -251,6 +252,7 @@ class Bookmarks extends Collection {
const { chatboxes } = _converse.state;
const groupchat = chatboxes.get(bookmark.get('jid'));
groupchat?.save('bookmarked', true);
groupchat?.save('pinned', bookmark.pinned);
}

/**
Expand Down Expand Up @@ -334,6 +336,52 @@ 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'), '<pinned xmlns="urn:xmpp:bookmarks-pinning:0"/>'];

const { chatboxes } = _converse.state;
const groupchat = chatboxes.get(bookmark.get('jid'));
groupchat?.save('pinned', true);

try {
api.bookmarks.set({
jid: bookmark.get('jid'),
extensions,
});
} catch (error) {
groupchat?.save('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('<pinned')));

const { chatboxes } = _converse.state;
const groupchat = chatboxes.get(bookmark.get('jid'));
groupchat?.save('pinned', false);

try {
api.bookmarks.set({
jid: bookmark.get('jid'),
extensions,
});
} catch (error) {
groupchat?.save('pinned', true);
log.error('Error while trying to unpin bookmark');
log.error(error);
}
}
}

export default Bookmarks;
7 changes: 7 additions & 0 deletions src/headless/plugins/bookmarks/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ class Bookmark extends Model {
return 'jid';
}

/**
* @returns {boolean}
*/
get pinned() {
return this.get('extensions')?.some(/** @param {String} e */ e => e.includes('<pinned') && e.includes('urn:xmpp:bookmarks-pinning:0'));
}

getDisplayName() {
return this.get('name') && Strophe.xmlunescape(this.get('name')) || this.get('jid');
}
Expand Down
145 changes: 145 additions & 0 deletions src/headless/plugins/bookmarks/tests/bookmarks.js
Original file line number Diff line number Diff line change
Expand Up @@ -557,4 +557,149 @@ describe('A bookmark', function () {
</iq>`);
}),
);

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="urn:xmpp:bookmarks:1"] conference[name="The Play"] extensions pinned', s).length).pop()
);

expect(bookmark.pinned).toBe(true);

const chatbox = state.chatboxes.get(muc_jid);
expect(chatbox.get('pinned')).toBe(true);

expect(sent_stanza).toEqualStanza(stx`
<iq from="${bare_jid}" to="${bare_jid}" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">
<pubsub xmlns="http://jabber.org/protocol/pubsub">
<publish node="urn:xmpp:bookmarks:1">
<item id="${muc_jid}">
<conference xmlns="urn:xmpp:bookmarks:1" autojoin="true" name="The Play">
<nick>romeo</nick>
<extensions>
<pinned xmlns="urn:xmpp:bookmarks-pinning:0"/>
</extensions>
</conference>
</item>
</publish>
<publish-options>
<x type="submit" xmlns="jabber:x:data">
<field type="hidden" var="FORM_TYPE">
<value>http://jabber.org/protocol/pubsub#publish-options</value>
</field>
<field var='pubsub#persist_items'>
<value>true</value>
</field>
<field var='pubsub#max_items'>
<value>max</value>
</field>
<field var='pubsub#send_last_published_item'>
<value>never</value>
</field>
<field var='pubsub#access_model'>
<value>whitelist</value>
</field>
</x>
</publish-options>
</pubsub>
</iq>`);
})
);

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: ['<pinned xmlns="urn:xmpp:bookmarks-pinning:0"/>'],
});

await mock.waitForMUCDiscoInfo(_converse, muc_jid);
await u.waitUntil(() => state.chatboxes.length === 1);

const IQ_stanzas = api.connection.get().IQ_stanzas;

expect(bookmark.pinned).toBe(true);
expect(state.chatboxes.get(muc_jid).get('pinned')).toBe(true);

// Now unpin the bookmark
await state.bookmarks.unpinBookmark(bookmark);

const sent_stanza = await u.waitUntil(() =>
IQ_stanzas.filter((s) => sizzle('publish[node="urn:xmpp:bookmarks:1"] conference[name="The Play"]', s).length).pop()
);

expect(bookmark.pinned).toBe(false);
expect(state.chatboxes.get(muc_jid).get('pinned')).toBe(false);

expect(sent_stanza).toEqualStanza(stx`
<iq from="${bare_jid}" to="${bare_jid}" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">
<pubsub xmlns="http://jabber.org/protocol/pubsub">
<publish node="urn:xmpp:bookmarks:1">
<item id="${muc_jid}">
<conference xmlns="urn:xmpp:bookmarks:1" autojoin="true" name="The Play">
<nick>romeo</nick>
</conference>
</item>
</publish>
<publish-options>
<x type="submit" xmlns="jabber:x:data">
<field type="hidden" var="FORM_TYPE">
<value>http://jabber.org/protocol/pubsub#publish-options</value>
</field>
<field var='pubsub#persist_items'>
<value>true</value>
</field>
<field var='pubsub#max_items'>
<value>max</value>
</field>
<field var='pubsub#send_last_published_item'>
<value>never</value>
</field>
<field var='pubsub#access_model'>
<value>whitelist</value>
</field>
</x>
</publish-options>
</pubsub>
</iq>`);
})
);
});
10 changes: 10 additions & 0 deletions src/headless/types/plugins/bookmarks/collection.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ declare class Bookmarks extends Collection<Bookmark> {
*/
onBookmarksReceivedError(deferred: any, iq: Element): Promise<void>;
getUnopenedBookmarks(): Promise<Bookmark[]>;
/**
*
* @param {Bookmark} bookmark
*/
pinBookmark(bookmark: Bookmark): void;
/**
*
* @param {Bookmark} bookmark
*/
unpinBookmark(bookmark: Bookmark): void;
}
import Bookmark from './model.js';
import { Collection } from '@converse/skeletor';
Expand Down
4 changes: 4 additions & 0 deletions src/headless/types/plugins/bookmarks/model.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
export default Bookmark;
declare class Bookmark extends Model<import("@converse/skeletor").ModelAttributes> {
constructor(attributes?: Partial<import("@converse/skeletor").ModelAttributes>, options?: import("@converse/skeletor").ModelOptions);
/**
* @returns {boolean}
*/
get pinned(): boolean;
getDisplayName(): any;
}
import { Model } from '@converse/skeletor';
Expand Down
38 changes: 38 additions & 0 deletions src/plugins/bookmark-views/components/bookmarks-pin-list.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { _converse, api, u } from '@converse/headless';
import tplBookmarksPinList from './templates/pin-list';
import BookmarksPinListModel from './model';
import { RoomsList } from 'plugins/roomslist/view';

const { initStorage } = u;

export class BookmarksPinView 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 BookmarksPinListModel({ id });
_converse.state.bookmarks_pin_list = this.model;

initStorage(this.model, id);
this.model.fetch();

this.handleEvents();

this.requestUpdate();
}

/** @returns {import('@converse/headless').MUC[]} */
getRoomsToShow() {
const { chatboxes } = _converse.state;
const rooms = chatboxes.filter((m) => m.get('pinned'));
rooms.sort((a, b) => (a.getDisplayName().toLowerCase() <= b.getDisplayName().toLowerCase() ? -1 : 1));
return rooms;
}

render() {
return tplBookmarksPinList(this);
}
}

api.elements.define('converse-bookmarks-pin', BookmarksPinView);
9 changes: 9 additions & 0 deletions src/plugins/bookmark-views/components/model.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { constants, Model } from "@converse/headless";

export default class BookmarksPinListModel extends Model {
defaults () {
return {
toggle_state: constants.OPENED,
}
}
}
22 changes: 22 additions & 0 deletions src/plugins/bookmark-views/components/styles/pin-list.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.conversejs {
#converse-bookmarks-pin {
padding-bottom: 1rem;

converse-bookmarks-pin {
.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;
}
}
}
}
}
}
43 changes: 43 additions & 0 deletions src/plugins/bookmark-views/components/templates/pin-list.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* @typedef {import('@converse/headless').MUC} MUC
* @typedef {import('plugins/bookmark-views/components/bookmarks-pin-list').BookmarksPinView} BookmarksPinView
*/

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 {BookmarksPinView} el
*/
export default (el) => {
const rooms = el.getRoomsToShow();
const is_closed = el.model.get('toggle_state') === constants.CLOSED;

return html`
<div class="d-flex controlbox-padded">
<span class="w-100 controlbox-heading controlbox-heading--groupchats">
<a class="list-toggle open-rooms-toggle" role="heading" aria-level="3"
title="${__('Click to toggle the list of pinned groupchats')}"
@click=${ev => el.toggleRoomsList(ev)}>

${__('Pinned groupchats')}

${rooms.length ? html`<converse-icon
class="fa ${ is_closed ? 'fa-caret-right' : 'fa-caret-down' }"
size="1em"
color="var(--muc-color)"></converse-icon>` : '' }
</a>
</span>
</div>

<div class="list-container list-container--openrooms ${ rooms.length ? '' : 'hidden' }">
<ul class="items-list rooms-list open-rooms-list ${ is_closed ? 'collapsed' : '' }">
${
rooms.map(/** @param {MUC} room */(room) => tplRoomItem(el, room))
}
</ul>
</div>`;
}
Loading
Loading