Favourites at the top, low priority at the bottom
The rail's bands now read the account's own filing beside this window's (#216). A favourite sits with the pins because it is the same intent said in a different place - the pin was made here, the favourite on the account, very likely in another client - and a pin made here still beats a low priority set elsewhere, because an explicit local gesture should. The menu entries sit under Pin for the same reason, and only on Matrix, where there is somewhere to keep them. The two are exclusive: being at the top and at the bottom is not a state, so starring a low-priority room takes the low priority off - as a tidy-up after the write the server already accepted, never as something that can undo it. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+1
-1
Submodule nobilis updated: 7d2a04a740...b7d42d8601
@@ -192,10 +192,19 @@ export function BufferList(): JSX.Element {
|
||||
? visible.filter(isDirectMessage)
|
||||
: visible.filter((b) => b.groupId === activeGroup.id)
|
||||
|
||||
// Server buffer above everything; then pinned, DMs, channels.
|
||||
// Server buffer above everything; then favourites and pins, DMs,
|
||||
// channels, and last whatever the account has pushed down.
|
||||
//
|
||||
// A favourite sits with the pins because it is the same intent said in a
|
||||
// different place: the pin was made in this window, the favourite on the
|
||||
// account - very likely in another client - and somebody who starred four
|
||||
// rooms in Element expects to find them at the top here too. Low priority
|
||||
// is the same idea pointing the other way, and it loses to a pin made
|
||||
// here: an explicit local gesture beats a filing decision made elsewhere.
|
||||
const band = (b: BufferEntry): number => {
|
||||
if (b.kind === 'server') return 0
|
||||
if (!isPinnedPage && pinned.includes(b.id)) return 1
|
||||
if (!isPinnedPage && (pinned.includes(b.id) || b.favourite)) return 1
|
||||
if (b.lowPriority) return 4
|
||||
if (b.kind === 'dm') return 2
|
||||
return 3
|
||||
}
|
||||
@@ -314,6 +323,7 @@ export function BufferList(): JSX.Element {
|
||||
onPopOut={() => store.popOut(b.id)}
|
||||
onDock={() => store.dock(b.id)}
|
||||
onMarkUnread={() => void store.markUnread(b.id)}
|
||||
onTag={(tag, on) => void store.setRoomTag(b.id, tag, on)}
|
||||
// Only where there is somewhere to drop it. A list with no headings of
|
||||
// your own has nothing a channel could be filed under, and a drag that
|
||||
// can only ever be refused is worse than one the row does not offer.
|
||||
@@ -722,6 +732,7 @@ interface BufferRowProps {
|
||||
onDock: () => void
|
||||
/** Matrix only: leave it unread on purpose. */
|
||||
onMarkUnread?: () => void
|
||||
onTag?: (tag: 'favourite' | 'lowPriority', on: boolean) => void
|
||||
draggable: boolean
|
||||
/** Being carried right now. */
|
||||
lifted: boolean
|
||||
@@ -761,6 +772,7 @@ function BufferRow({
|
||||
onPopOut,
|
||||
onDock,
|
||||
onMarkUnread,
|
||||
onTag,
|
||||
draggable,
|
||||
lifted,
|
||||
filed,
|
||||
@@ -835,6 +847,7 @@ function BufferRow({
|
||||
onPopOut,
|
||||
onDock,
|
||||
onMarkUnread: account?.service === 'matrix' ? onMarkUnread : undefined,
|
||||
onTag: account?.service === 'matrix' ? onTag : undefined,
|
||||
markedUnread: buffer.markedUnread
|
||||
})
|
||||
|
||||
|
||||
@@ -93,6 +93,10 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
|
||||
onPopOut: () => store.popOut(buffer.id),
|
||||
onDock: () => store.dock(buffer.id),
|
||||
onMarkUnread: account?.service === 'matrix' ? () => void store.markUnread(buffer.id) : undefined,
|
||||
onTag:
|
||||
account?.service === 'matrix'
|
||||
? (tag, on) => void store.setRoomTag(buffer.id, tag, on)
|
||||
: undefined,
|
||||
markedUnread: buffer.markedUnread
|
||||
}),
|
||||
{ separator: true },
|
||||
|
||||
@@ -32,6 +32,12 @@ export interface BufferMenuInput {
|
||||
spaces?: BufferGroup[]
|
||||
onTogglePin: () => void
|
||||
onToggleMute: () => void
|
||||
/**
|
||||
* Filing the conversation on the account rather than in this window -
|
||||
* Matrix's room tags. Absent for every service that has no such idea, which
|
||||
* is how the entries stay off menus where they would do nothing.
|
||||
*/
|
||||
onTag?: (tag: 'favourite' | 'lowPriority', on: boolean) => void
|
||||
onToggleAutojoin?: () => void
|
||||
onSpace?: (spaceId: string, child: boolean) => void
|
||||
onWatch?: () => void
|
||||
@@ -78,6 +84,7 @@ export function bufferMenuEntries({
|
||||
spaces,
|
||||
onTogglePin,
|
||||
onToggleMute,
|
||||
onTag,
|
||||
onToggleAutojoin,
|
||||
onSpace,
|
||||
onWatch,
|
||||
@@ -169,6 +176,28 @@ export function bufferMenuEntries({
|
||||
? ([{ label: 'Mark as unread', icon: 'mark_chat_unread', onClick: onMarkUnread }] as MenuEntry[])
|
||||
: []),
|
||||
{ label: pinned ? 'Unpin' : 'Pin', icon: 'push_pin', onClick: onTogglePin },
|
||||
// Beneath the pin, and deliberately beside it: they are the same intent
|
||||
// said in two places. A pin is this window's, a favourite is the
|
||||
// account's - it was quite possibly set in another client, and it is what
|
||||
// somebody who organised their rooms there expects to find here.
|
||||
//
|
||||
// The two are exclusive, because being at the top and at the bottom is
|
||||
// not a state, and because Element treats them that way - so starring a
|
||||
// room that was low priority takes the low priority off.
|
||||
...(onTag
|
||||
? ([
|
||||
{
|
||||
label: buffer.favourite ? 'Remove from favourites' : 'Favourite',
|
||||
icon: 'star',
|
||||
onClick: () => onTag('favourite', !buffer.favourite)
|
||||
},
|
||||
{
|
||||
label: buffer.lowPriority ? 'Restore priority' : 'Low priority',
|
||||
icon: 'low_priority',
|
||||
onClick: () => onTag('lowPriority', !buffer.lowPriority)
|
||||
}
|
||||
] as MenuEntry[])
|
||||
: []),
|
||||
// A mute made on the account itself is not this window's to undo, and an
|
||||
// "Unmute" that quietly did nothing would be worse than no entry at all -
|
||||
// so it says where the mute is, and where to go and take it off.
|
||||
|
||||
@@ -1521,6 +1521,49 @@ export class ChatStore {
|
||||
* through sync rather than as a reply - waiting for it would leave the row
|
||||
* looking unchanged for a whole sync cycle after somebody pressed the thing.
|
||||
*/
|
||||
/**
|
||||
* Files a conversation on the account itself: starred to the top of the
|
||||
* list, or pushed to the bottom.
|
||||
*
|
||||
* Matrix's room tags, which travel - so this is not the same as the
|
||||
* window's own pin, and undoing it here undoes it everywhere the account is
|
||||
* signed in.
|
||||
*
|
||||
* The two are exclusive. Being at the top and at the bottom is not a state,
|
||||
* and Element treats them that way, so starring a room that was low
|
||||
* priority takes the low priority off rather than leaving a room that
|
||||
* claims both.
|
||||
*/
|
||||
async setRoomTag(bufferId: string, tag: 'favourite' | 'lowPriority', on: boolean): Promise<void> {
|
||||
const other = tag === 'favourite' ? 'lowPriority' : 'favourite'
|
||||
const name = tag === 'favourite' ? 'm.favourite' : 'm.lowpriority'
|
||||
const otherName = tag === 'favourite' ? 'm.lowpriority' : 'm.favourite'
|
||||
const before = this.state.buffers
|
||||
// Moved now rather than when the server says so. The daemon says the same
|
||||
// thing back a moment later; this only means the row moves when the menu
|
||||
// item is clicked.
|
||||
this.set({
|
||||
buffers: before.map((b) =>
|
||||
b.id === bufferId ? { ...b, [tag]: on, ...(on ? { [other]: false } : {}) } : b
|
||||
)
|
||||
})
|
||||
const hadOther = !!before.find((b) => b.id === bufferId)?.[other]
|
||||
try {
|
||||
await window.moho.rpc('setMatrixRoomTag', { bufferId, tag: name, on })
|
||||
// Clearing the opposite tag is a tidy-up, not part of the request:
|
||||
// failing it would undo a change the server has already accepted. Only
|
||||
// where there is something to clear, so the ordinary case is one call.
|
||||
if (on && hadOther) {
|
||||
await window.moho
|
||||
.rpc('setMatrixRoomTag', { bufferId, tag: otherName, on: false })
|
||||
.catch(() => {})
|
||||
}
|
||||
} catch (e) {
|
||||
this.set({ buffers: before })
|
||||
this.toast('error', `Couldn't file that conversation: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async markUnread(bufferId: string): Promise<void> {
|
||||
this.set({
|
||||
buffers: this.state.buffers.map((b) => (b.id === bufferId ? { ...b, markedUnread: true } : b))
|
||||
|
||||
@@ -113,6 +113,20 @@ export interface Buffer {
|
||||
* than folded into it.
|
||||
*/
|
||||
serverMuted?: boolean
|
||||
/**
|
||||
* Starred on the account itself - Matrix's `m.favourite` tag today.
|
||||
*
|
||||
* Separate from this window's pin because it was very likely set in another
|
||||
* client, and finding it honoured here is the whole point of a tag that
|
||||
* travels. Both lift a conversation to the top of the list.
|
||||
*/
|
||||
favourite?: boolean
|
||||
/**
|
||||
* The other end of the same idea: `m.lowpriority`, a room the account has
|
||||
* pushed to the bottom. Not muted - it still says when somebody speaks, it
|
||||
* just stops competing for the top of the rail.
|
||||
*/
|
||||
lowPriority?: boolean
|
||||
/**
|
||||
* Set while the buffer exists but the service has not yet said what is in
|
||||
* it - a Matrix room between joining it and its first sync. The list draws
|
||||
|
||||
Reference in New Issue
Block a user