Files
Lauri Ojansivu a896f3ed8c A visitor's Scrum board view opens as chosen, not as Swimlanes.
Without an account the browser keeps the board view, and client/lib/utils.js
had two hand-written copies of the view list for it: setBoardView stored
any of them, but storedBoardView's copy lacked Product Backlog, Sprints,
Sprint Report and Velocity, so those opened as Swimlanes after the reload.
Both now ask isKnownBoardView, i.e. BOARD_VIEWS, which removes the two
lists (about a hundred lines) and the drift with them.

The guards that counted view names in utils.js check the isKnownBoardView
calls instead, and boardViewMenu fails if a hand-written list returns.

Thanks to xet7 !
2026-10-17 00:20:32 +03:00

1296 lines
50 KiB
JavaScript

import { ReactiveCache } from '/imports/reactiveCache';
import { currentUserWith } from '/client/lib/currentUserWith';
import { headerPathVar } from '/client/lib/headerPathVar';
const { pageDocumentTitle } = require('/models/lib/starredPages');
const { resolveBoardView, isKnownBoardView, DEFAULT_BOARD_VIEW } = require('/models/lib/boardViewSettings');
import { allowBoardView } from '/client/lib/instanceFeatures';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { Tracker } from 'meteor/tracker';
import { ReactiveVar } from 'meteor/reactive-var';
import { findWhere, where, uniqBy, groupBy, indexBy, debounce, once } from '/imports/lib/collectionHelpers';
import Settings from '/models/settings';
import Users from '/models/users';
import { computeBoardBackground } from '/models/lib/boardBackground';
import { buildCardAttachmentMeta } from '/models/lib/attachmentMeta';
import { resolveShowDragHandles, readDragHandlesPreference } from '/models/lib/dragHandles';
const { memberCan } = require('/models/lib/boardRoleCapabilities');
const { sourceRoleBlocksDelegation } = require('/models/lib/linkedWritePolicy');
const { listCan } = require('/models/lib/listPermissions');
// One key for both pages: they draw one menu. docs/Features/All-Boards/Left-Menu.md
const LEFT_MENU_COLLAPSED_KEY = 'leftMenuCollapsed';
const LEFT_MENU_WIDTH_KEY = 'leftMenuWidth';
const pendingBoardView = new ReactiveVar(null);
const { canDrag } = require('/models/lib/boardDragging');
export const Utils = {
canDragBoardObject(kind, boardId = Session.get('currentBoard')) {
return canDrag(ReactiveCache.getBoard(boardId), kind);
},
async setBackgroundImage(url) {
const currentBoard = Utils.getCurrentBoard();
const bg = computeBoardBackground(currentBoard);
const swimlaneSelector =
".swimlane,.swimlane .list,.swimlane .list .list-body,.swimlane .list:first-child .list-body";
if (bg.type === "image") {
$(".board-wrapper").css({ "background": "url(" + bg.url + ")", "background-size": "cover" });
$(swimlaneSelector).css({ "background-color": "transparent" });
$(".minicard").css({ "opacity": "0.9" });
} else {
// #4978: clear any inline background left over from a previously shown
// board, so switching image -> color or image -> plain (e.g. via the
// favorites bar) actually removes the old image. The board's color, when
// any, is applied reactively via the `.board-wrapper` colorClass.
$(".board-wrapper").css({ "background": "", "background-size": "" });
$(swimlaneSelector).css({ "background-color": "" });
$(".minicard").css({ "opacity": "" });
}
},
/** returns the current board id
* <li> returns the current board id or the board id of the popup card if set
*/
getCurrentBoardId() {
let popupCardBoardId = Session.get('popupCardBoardId');
let currentBoard = Session.get('currentBoard');
let ret = currentBoard;
if (popupCardBoardId) {
ret = popupCardBoardId;
}
return ret;
},
getCurrentCardId(ignorePopupCard) {
let ret = Session.get('currentCard');
if (!ret && !ignorePopupCard) {
ret = Utils.getPopupCardId();
}
return ret;
},
getPopupCardId() {
const ret = Session.get('popupCardId');
return ret;
},
getCurrentListId() {
const ret = Session.get('currentList');
return ret;
},
/** returns the current board
* <li> returns the current board or the board of the popup card if set
*/
getCurrentBoard() {
const boardId = Utils.getCurrentBoardId();
const ret = ReactiveCache.getBoard(boardId);
return ret;
},
getCurrentCard(ignorePopupCard) {
const cardId = Utils.getCurrentCardId(ignorePopupCard);
if (!cardId) {
return null;
}
const ret = ReactiveCache.getCard(cardId);
return ret;
},
// Mobile mode utilities
getMobileMode() {
// Check localStorage first - user's explicit preference takes priority
const stored = localStorage.getItem('wekan-mobile-mode');
if (stored !== null) {
return stored === 'true';
}
// Then check user profile
// #6745: isMiniScreen() runs per list and per minicard - one field only.
const user = currentUserWith(['profile.mobileMode']);
if (user && user.profile && user.profile.mobileMode !== undefined) {
return user.profile.mobileMode;
}
// No explicit preference: detect from the VIEWPORT, not the user-agent.
// UA string parsing is unreliable across the many mobile browsers (Firefox,
// Chrome, Safari, postmarketOS, Ubuntu Touch Morph, ...) and is exactly the
// kind of fragile detection that bites projects — cf. Meteor #12421, where
// Mobile Safari version parsing was wrong. matchMedia width/pointer queries
// are well-supported and, importantly, match the CSS `@media (max-width:
// 800px)` breakpoint, so the body.mobile-mode class and the responsive CSS
// agree. A narrow screen, or a coarse (touch) pointer on a not-wide screen,
// gets mobile mode; wide desktop windows (fine pointer) stay desktop.
// NOTE: requires the width=device-width viewport meta (see
// server/lib/customHeadRender.js); without it window.innerWidth reports ~980
// on phones and this would never trigger.
const mq = q =>
typeof window !== 'undefined' && window.matchMedia && window.matchMedia(q).matches;
const narrow = mq('(max-width: 800px)');
const coarse = mq('(pointer: coarse)');
return Boolean(narrow || (coarse && window.innerWidth <= 1024));
},
// The user's EXPLICIT mobile-mode choice, or null if they have never made one.
// getMobileMode() cannot answer this: it falls back to viewport detection and so
// always returns a boolean, which is why the "has the user chosen?" test inside
// isMiniScreen() was always true.
getExplicitMobileMode() {
const stored = localStorage.getItem('wekan-mobile-mode');
if (stored !== null) {
return stored === 'true';
}
// #6745: isMiniScreen() runs per list and per minicard - one field only.
const user = currentUserWith(['profile.mobileMode']);
if (user && user.profile && user.profile.mobileMode !== undefined) {
return user.profile.mobileMode;
}
return null;
},
setMobileMode(enabled) {
const user = ReactiveCache.getCurrentUser();
if (user) {
// Update user profile
user.setMobileMode(enabled);
}
// Always store in localStorage for persistence across sessions
localStorage.setItem('wekan-mobile-mode', enabled.toString());
Utils.applyMobileMode(enabled);
// Trigger reactive updates for UI components
Session.set('wekan-mobile-mode', enabled);
},
getCardZoom() {
const user = ReactiveCache.getCurrentUser();
if (user && user.profile && user.profile.cardZoom !== undefined) {
return user.profile.cardZoom;
}
const stored = localStorage.getItem('wekan-card-zoom');
return stored ? parseFloat(stored) : 1.0;
},
setCardZoom(level) {
const user = ReactiveCache.getCurrentUser();
if (user) {
user.setCardZoom(level);
}
localStorage.setItem('wekan-card-zoom', level.toString());
Utils.applyCardZoom(level);
Session.set('wekan-card-zoom', level);
},
applyCardZoom(level) {
const cardDetails = document.querySelector('.card-details');
if (cardDetails) {
cardDetails.style.fontSize = `${level}em`;
}
},
applyMobileMode(enabled) {
const body = document.body;
if (enabled) {
body.classList.add('mobile-mode');
body.classList.remove('desktop-mode');
} else {
body.classList.add('desktop-mode');
body.classList.remove('mobile-mode');
}
},
initializeUserSettings() {
// Apply saved settings on page load
const mobileMode = Utils.getMobileMode();
Utils.applyMobileMode(mobileMode);
// #6419: keep mobile-mode in sync with the viewport (resize / orientation /
// responsive testing) when the user has NOT explicitly chosen a mode. Bound
// once. An explicit toggle (localStorage or profile) must stick, so we skip
// auto-switching in that case.
if (
typeof window !== 'undefined' &&
window.matchMedia &&
!Utils._mobileModeAutoBound
) {
Utils._mobileModeAutoBound = true;
const reapply = () => {
const stored = localStorage.getItem('wekan-mobile-mode');
const user = ReactiveCache.getCurrentUser();
const explicit =
stored !== null ||
(user && user.profile && user.profile.mobileMode !== undefined);
if (!explicit) {
Utils.applyMobileMode(Utils.getMobileMode());
}
};
window.matchMedia('(max-width: 800px)').addEventListener('change', reapply);
window.matchMedia('(pointer: coarse)').addEventListener('change', reapply);
}
},
getCurrentList() {
const listId = this.getCurrentListId();
let ret = null;
if (listId) {
ret = ReactiveCache.getList(listId);
}
return ret;
},
getPopupCard() {
const cardId = Utils.getPopupCardId();
const ret = ReactiveCache.getCard(cardId);
return ret;
},
// What the UI OFFERS, from the same capability table the server decides with
// (models/lib/boardRoleCapabilities.js).
//
// These three used to be their own lists of flags, and each one disagreed with
// the server rule in a different place - `canModifyCard` did not exclude
// `isNoComments` while the server did, `canModifyBoard` excluded neither
// `isNoComments` nor `isWorker`, and none of them knew about
// `isCommentAssignedOnly`. Every disagreement was a button offered to somebody
// whose write the server then refused, which reads as a bug to the person
// clicking it. Reading the one table is what keeps them honest.
currentUserCan(capability, board = Utils.getCurrentBoard()) {
const userId = Meteor.userId();
return !!(board && memberCan(board, userId, capability));
},
canModifyCard(card = Utils.getCurrentCard()) {
if (card && card.type === 'cardType-linkedCard') {
const source = ReactiveCache.getCard(card.linkedId);
const sourceBoard = source && ReactiveCache.getBoard(source.boardId);
if (!sourceBoard || sourceRoleBlocksDelegation(Meteor.userId(), sourceBoard)) return false;
}
const board = card && typeof card.board === 'function'
? card.board()
: Utils.getCurrentBoard();
return Utils.currentUserCanInList('write', card, board);
},
// #4527: what the card's list allows this user's role, over the board's own
// rule - the same decision as server/lib/listPermissionGate.js.
currentUserCanInList(capability, card = Utils.getCurrentCard(), board = Utils.getCurrentBoard()) {
const list = card && card.listId ? ReactiveCache.getList(card.listId) : null;
if (!list || !list.roleExceptions) return Utils.currentUserCan(capability, board);
return !!(board && listCan(board, list, Meteor.userId(), capability));
},
// A move is a card update on the server, but it is NOT the same capability: a
// Worker may move a card and assign themselves to it while writing nothing else
// (#3189, models/lib/workerCardWrite.js enforces exactly that field by field).
// Every role that can `write` can also move, so this only ever ADDS the Worker.
canMoveCard() {
return Utils.currentUserCan('moveCard');
},
// #3307: checking/unchecking a checklist item is not the same capability as
// editing or deleting one either - a Worker (write: false) may still tick a
// box, field by field, exactly like the move/self-assign carve-out above
// (models/lib/workerChecklistItemToggle.js enforces it server-side). Every
// role that can `write` can also check an item, so this only ever ADDS the
// Worker on top of whoever canModifyCard already allows.
canCheckChecklistItem(card = Utils.getCurrentCard()) {
if (Utils.canModifyCard(card)) return true;
// #6745: isWorker() only needs the id; per checklist item on a minicard.
const user = currentUserWith(['_id']);
return !!(user && user.isWorker && user.isWorker());
},
canModifyBoard() {
return Utils.currentUserCan('write');
},
reload() {
// we move all window.location.reload calls into this function
// so we can disable it when running tests.
// This is because we are not allowed to override location.reload but
// we can override Utils.reload to prevent reload during tests.
window.location.reload();
},
setBoardView(view) {
const currentUser = ReactiveCache.getCurrentUser();
if (currentUser) {
// #4906 / #4256: on a board, the choice is that board's own. The
// browser's stored view is the shared fallback, so only a choice made
// off any board updates it.
const boardId = (Utils.getCurrentBoardId && Utils.getCurrentBoardId()) || null;
if (!boardId) window.localStorage.setItem('boardView', view);
pendingBoardView.set({ view, boardId });
// Update user profile via Meteor method
Meteor.call('setBoardView', view, boardId, (error) => {
if (error) {
console.error('[setBoardView] Update failed:', error);
pendingBoardView.set(null);
}
});
} else {
// Not logged in: the browser keeps the choice. Any view of the Board View
// menu (BOARD_VIEWS), anything else the default.
window.localStorage.setItem('boardView', isKnownBoardView(view) ? view : DEFAULT_BOARD_VIEW);
Utils.reload();
}
},
unsetBoardView() {
window.localStorage.removeItem('boardView');
window.localStorage.removeItem('collapseSwimlane');
},
// The view to RENDER. The stored choice (storedBoardView below: the
// profile for a logged-in user, localStorage for a visitor) is used when
// the current board offers it; when there is none, or the board's admin
// hid it for this board's visibility (Board Settings / Board View,
// models/lib/boardViewSettings.js), the board's default for public or
// private is rendered instead - nobody is left on a view the menu no
// longer lists. Off a board (no current board) the stored choice is
// returned as it is. A view the instance disabled (#6736, Admin Panel /
// Settings / Visibility / Features) is not offered either.
boardView() {
const stored = Utils.storedBoardView();
const board = Utils.getCurrentBoard();
return board ? resolveBoardView(board, stored, allowBoardView, Meteor.userId()) : stored;
},
storedBoardView() {
const pendingChoice = pendingBoardView.get();
// #6745: only the view fields. boardView() is read by every list's card
// loop (listBody's idOrNull/containerSwimlaneId); reading the whole user here
// re-ran every list on each user write - a card open is one - and Blaze then
// re-evaluated every minicard (~37,000 computations on a 40-card board).
const currentUser = currentUserWith(['boardViewPreference', 'boardViewPreferences', 'profile.boardView', 'profile.boardViews']);
// #4906 / #4256: the view chosen on THIS board wins; a board without its
// own choice falls back to the one every board shared before.
const boardId = (Utils.getCurrentBoardId && Utils.getCurrentBoardId()) || null;
const perBoard = boardId
? ((currentUser?.boardViewPreferences || (currentUser?.profile || {}).boardViews || {})[boardId]) : undefined;
const publishedView = currentUser?.boardViewPreference;
const sharedView = isKnownBoardView(publishedView)
? publishedView : (currentUser?.profile || {}).boardView;
const profileView = isKnownBoardView(perBoard) ? perBoard : sharedView;
// A pending choice applies to the board it was made on (or, made off any
// board, to the shared view).
const pending = pendingChoice && pendingChoice.boardId === boardId ? pendingChoice.view : null;
if (pending) {
// #6659: a successful method callback can run before the reactive user
// document carries the persisted profile value. Keep rendering the
// chosen view until that document catches up instead of briefly exposing
// the old value and snapping back to it.
if (currentUser && profileView === pending) {
pendingBoardView.set(null);
}
return pending;
}
// #6691: impersonation and subscriptions still loading can expose a partial
// profile. Fall back to the browser preference, then the normal default.
if (isKnownBoardView(profileView)) {
return profileView;
}
// The same list setBoardView accepts. A hand-written copy of it here once
// lacked the four Scrum views, so a visitor's Product Backlog, Sprints,
// Sprint Report or Velocity opened as Swimlanes.
const browserView = window.localStorage.getItem('boardView');
return isKnownBoardView(browserView) ? browserView : DEFAULT_BOARD_VIEW;
},
// `swimlaneId` scopes a BOARD-WIDE list's collapse state to the one
// swimlane row it was toggled in. A list with no swimlaneId of its own
// (shared/pre-migration) renders once per swimlane in Swimlanes view - the
// SAME list document, one row per swimlane - so without this, collapsing
// it in swimlane 1 collapsed every other swimlane's row of it too, since
// they all shared the one `collapsedList-<listId>` key. Callers resolve
// swimlaneId via the containerSwimlaneId pattern already used for scoping
// that list's CARDS per swimlane (client/components/lists/listHeader.js,
// listBody.js) - undefined outside Swimlanes view, where there is only
// ever one row for the list and the old unscoped key is exactly right.
getListCollapseState(list, swimlaneId) {
if (!list) return false;
const storageId = swimlaneId ? `${list._id}:${swimlaneId}` : list._id;
const key = `collapsedList-${storageId}`;
const sessionVal = Session.get(key);
if (typeof sessionVal === 'boolean') {
return sessionVal;
}
const user = ReactiveCache.getCurrentUser();
let stored = null;
if (user && user.getCollapsedListFromStorage) {
stored = user.getCollapsedListFromStorage(list.boardId, storageId);
} else if (Users.getPublicCollapsedList) {
stored = Users.getPublicCollapsedList(list.boardId, storageId);
}
if (typeof stored === 'boolean') {
return stored;
}
const fallback = typeof list.collapsed === 'boolean' ? list.collapsed : false;
return fallback;
},
setListCollapseState(list, collapsed, swimlaneId) {
if (!list) return;
const storageId = swimlaneId ? `${list._id}:${swimlaneId}` : list._id;
const key = `collapsedList-${storageId}`;
Session.set(key, !!collapsed);
const user = ReactiveCache.getCurrentUser();
if (user) {
Meteor.call('setListCollapsedState', list.boardId, storageId, !!collapsed);
} else if (Users.setPublicCollapsedList) {
Users.setPublicCollapsedList(list.boardId, storageId, !!collapsed);
}
},
// #1591: the same shape as a list's collapse state above, for the whole
// minicard. There is deliberately no `Users.getPublicCollapsedCard` /
// `setPublicCollapsedCard` cookie fallback: a public board can have very
// many cards (unlike its handful of lists), and a per-card cookie entry per
// anonymous visitor would grow the cookie without bound. An anonymous
// visitor's fold still works for the session (the Session key above), it
// just does not survive a reload - the same tradeoff already accepted for
// an anonymous visitor's per-checklist folds (getCollapsedCardSection has
// no public/cookie fallback either).
getCardCollapseState(card) {
if (!card) return false;
const key = `collapsedCard-${card._id}`;
const sessionVal = Session.get(key);
if (typeof sessionVal === 'boolean') {
return sessionVal;
}
// #6745: once per minicard - read only this card's fold.
const user = card.boardId
? currentUserWith([`profile.collapsedCards.${card.boardId}.${card._id}`])
: null;
let stored = null;
if (user && user.getCollapsedCardFromStorage) {
stored = user.getCollapsedCardFromStorage(card.boardId, card._id);
}
if (typeof stored === 'boolean') {
Session.setDefault(key, stored);
return stored;
}
Session.setDefault(key, false);
return false;
},
setCardCollapseState(card, collapsed) {
if (!card) return;
const key = `collapsedCard-${card._id}`;
Session.set(key, !!collapsed);
const user = ReactiveCache.getCurrentUser();
if (user) {
Meteor.call('setCardCollapsedState', card.boardId, card._id, !!collapsed);
}
},
// The left menu's collapse state, the same shape as a list's above: a Session
// value so the fold is instant and survives a re-render, and the user
// document behind it so it survives a reload and follows the reader to their
// other browser.
//
// ONE state for both pages. All Boards and the Admin Panel draw one menu, and
// a reader who folds it away on one of them has said what they want on the
// other. docs/Features/All-Boards/Left-Menu.md
getLeftMenuCollapseState() {
const sessionVal = Session.get(LEFT_MENU_COLLAPSED_KEY);
if (typeof sessionVal === 'boolean') return sessionVal;
const user = ReactiveCache.getCurrentUser();
const stored = user && user.isLeftMenuCollapsed
? user.isLeftMenuCollapsed()
// Signed out - a public board has this menu too - so it is kept in a
// cookie, the same way the public list and swimlane collapse states are
// (models/users.js). `null` means it has never been set.
: Users.getPublicLeftMenuCollapsed
? Users.getPublicLeftMenuCollapsed() === true
: false;
Session.setDefault(LEFT_MENU_COLLAPSED_KEY, stored);
return stored;
},
setLeftMenuCollapseState(collapsed) {
Session.set(LEFT_MENU_COLLAPSED_KEY, !!collapsed);
if (ReactiveCache.getCurrentUser()) {
Meteor.call('setLeftMenuCollapsed', !!collapsed);
return;
}
if (Users.setPublicLeftMenuCollapsed) {
Users.setPublicLeftMenuCollapsed(!!collapsed);
}
},
// The width that menu was dragged to, kept in the same three layers as its
// fold - Session first, then the user's profile, then a cookie for a reader
// who is not signed in.
//
// `undefined` when nobody has ever dragged it, and that is deliberate: the
// DEFAULT width is a number in the stylesheet, and returning one here too
// would be a second copy of it to keep in step.
// docs/Features/All-Boards/Left-Menu.md
getLeftMenuWidth() {
const sessionVal = Session.get(LEFT_MENU_WIDTH_KEY);
if (typeof sessionVal === 'number') return sessionVal;
const user = ReactiveCache.getCurrentUser();
const stored = user && user.getLeftMenuWidth
? user.getLeftMenuWidth()
: Users.getPublicLeftMenuWidth
? Users.getPublicLeftMenuWidth()
: undefined;
// Only a real width is cached in the Session. `setDefault(key, undefined)`
// would make the key look SET on the next read, and the profile arriving a
// moment later - the user document is not there on the first render - would
// then never be picked up.
if (typeof stored === 'number') {
Session.setDefault(LEFT_MENU_WIDTH_KEY, stored);
return stored;
}
return undefined;
},
setLeftMenuWidth(width) {
const rounded = Math.round(width);
Session.set(LEFT_MENU_WIDTH_KEY, rounded);
if (ReactiveCache.getCurrentUser()) {
Meteor.call('setLeftMenuWidth', rounded);
return;
}
if (Users.setPublicLeftMenuWidth) {
Users.setPublicLeftMenuWidth(rounded);
}
},
// Is this workspace of the All Boards left menu folded? The same three layers
// the left menu's own fold uses: a Session value so the caret answers at once,
// the user's profile behind it, and a cookie for a reader who is not signed
// in. OPEN is the default - a tree that opened folded would hide the
// workspaces a reader has never touched.
// docs/Features/All-Boards/Workspaces.md
getWorkspaceCollapseState(workspaceId) {
if (!workspaceId) return false;
const key = `collapsedWorkspace-${workspaceId}`;
const sessionVal = Session.get(key);
if (typeof sessionVal === 'boolean') return sessionVal;
const user = ReactiveCache.getCurrentUser();
const stored = user && user.isWorkspaceCollapsed
? user.isWorkspaceCollapsed(workspaceId)
: Users.getPublicCollapsedWorkspaces
? Users.getPublicCollapsedWorkspaces()[workspaceId] === true
: false;
Session.setDefault(key, stored);
return stored;
},
setWorkspaceCollapseState(workspaceId, collapsed) {
if (!workspaceId) return;
Session.set(`collapsedWorkspace-${workspaceId}`, !!collapsed);
if (ReactiveCache.getCurrentUser()) {
Meteor.call('setWorkspaceCollapsed', workspaceId, !!collapsed);
return;
}
if (Users.setPublicCollapsedWorkspace) {
Users.setPublicCollapsedWorkspace(workspaceId, !!collapsed);
}
},
getSwimlaneCollapseState(swimlane) {
if (!swimlane) return false;
const key = `collapsedSwimlane-${swimlane._id}`;
const sessionVal = Session.get(key);
if (typeof sessionVal === 'boolean') {
return sessionVal;
}
const user = ReactiveCache.getCurrentUser();
let stored = null;
if (user && user.getCollapsedSwimlaneFromStorage) {
stored = user.getCollapsedSwimlaneFromStorage(
swimlane.boardId,
swimlane._id,
);
} else if (Users.getPublicCollapsedSwimlane) {
stored = Users.getPublicCollapsedSwimlane(swimlane.boardId, swimlane._id);
}
if (typeof stored === 'boolean') {
Session.setDefault(key, stored);
return stored;
}
const fallback = typeof swimlane.collapsed === 'boolean' ? swimlane.collapsed : false;
Session.setDefault(key, fallback);
return fallback;
},
setSwimlaneCollapseState(swimlane, collapsed) {
if (!swimlane) return;
const key = `collapsedSwimlane-${swimlane._id}`;
Session.set(key, !!collapsed);
const user = ReactiveCache.getCurrentUser();
if (user) {
Meteor.call('setSwimlaneCollapsedState', swimlane.boardId, swimlane._id, !!collapsed);
} else if (Users.setPublicCollapsedSwimlane) {
Users.setPublicCollapsedSwimlane(swimlane.boardId, swimlane._id, !!collapsed);
}
},
myCardsSort() {
let sort = window.localStorage.getItem('myCardsSort');
if (!sort || !['board', 'dueAt'].includes(sort)) {
sort = 'board';
}
return sort;
},
myCardsSortToggle() {
if (this.myCardsSort() === 'board') {
this.setMyCardsSort('dueAt');
} else {
this.setMyCardsSort('board');
}
},
setMyCardsSort(sort) {
window.localStorage.setItem('myCardsSort', sort);
Utils.reload();
},
archivedBoardIds() {
const ret = ReactiveCache.getBoards({ archived: false }).map(board => board._id);
return ret;
},
dueCardsView() {
let view = window.localStorage.getItem('dueCardsView');
if (!view || !['me', 'all'].includes(view)) {
view = 'me';
}
return view;
},
setDueCardsView(view) {
window.localStorage.setItem('dueCardsView', view);
Utils.reload();
},
// #global search "my cards / all cards" toggle. Reactive (Session) so the
// search re-runs immediately without a full page reload; persisted in
// localStorage. Defaults to 'all' (search everything).
globalSearchView() {
const view =
Session.get('globalSearchView') ||
window.localStorage.getItem('globalSearchView');
return view && ['me', 'all'].includes(view) ? view : 'all';
},
setGlobalSearchView(view) {
const next = ['me', 'all'].includes(view) ? view : 'all';
window.localStorage.setItem('globalSearchView', next);
Session.set('globalSearchView', next);
},
myCardsView() {
let view = window.localStorage.getItem('myCardsView');
if (!view || !['boards', 'table'].includes(view)) {
view = 'boards';
}
return view;
},
setMyCardsView(view) {
window.localStorage.setItem('myCardsView', view);
Utils.reload();
},
// XXX We should remove these two methods
goBoardId(_id) {
const board = ReactiveCache.getBoard(_id);
return (
board &&
FlowRouter.go('board', {
id: board._id,
slug: board.slug,
})
);
},
goCardId(_id) {
const card = ReactiveCache.getCard(_id);
const board = ReactiveCache.getBoard(card.boardId);
return (
board &&
FlowRouter.go('card', {
cardId: card._id,
boardId: board._id,
slug: board.slug,
swimlaneId: card.swimlaneId,
listId: card.listId,
})
);
},
// Shared by the card Attachments popup and clipboard upload. The same
// meta.cardId keeps existing attachments referenced in comments visible
// in the card gallery, including linked cards (#3843).
getCommonAttachmentMetaFrom(card) {
return buildCardAttachmentMeta(card, id => ReactiveCache.getCard(id));
},
// Collection helpers (replacing underscore.js)
findWhere,
where,
uniqBy,
groupBy,
indexBy,
debounce,
once,
MAX_IMAGE_PIXEL: Meteor.settings.public.MAX_IMAGE_PIXEL,
COMPRESS_RATIO: Meteor.settings.public.IMAGE_COMPRESS_RATIO,
shrinkImage(options) {
// shrink image to certain size
const dataurl = options.dataurl,
callback = options.callback,
toBlob = options.toBlob;
let canvas = document.createElement('canvas'),
image = document.createElement('img');
const maxSize = options.maxSize || 1024;
const ratio = options.ratio || 1.0;
const next = function (result) {
image = null;
canvas = null;
if (typeof callback === 'function') {
callback(result);
}
};
image.onload = function () {
let width = this.width,
height = this.height;
let changed = false;
if (width > height) {
if (width > maxSize) {
height *= maxSize / width;
width = maxSize;
changed = true;
}
} else if (height > maxSize) {
width *= maxSize / height;
height = maxSize;
changed = true;
}
canvas.width = width;
canvas.height = height;
canvas.getContext('2d').drawImage(this, 0, 0, width, height);
if (changed === true) {
const type = 'image/jpeg';
if (toBlob) {
canvas.toBlob(next, type, ratio);
} else {
next(canvas.toDataURL(type, ratio));
}
} else {
next(changed);
}
};
image.onerror = function () {
next(false);
};
image.src = dataurl;
},
capitalize(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
},
windowResizeDep: new Tracker.Dependency(),
// in fact, what we really care is screen size
// large mobile device like iPad or android Pad has a big screen, it should also behave like a desktop
// in a small window (even on desktop), Wekan run in compact mode.
// we can easily debug with a small window of desktop browser. :-)
isMiniScreen() {
this.windowResizeDep.depend();
// Also depend on mobile mode changes to make this reactive
Session.get('wekan-mobile-mode');
// Show mobile view when:
// 1. Screen width is 800px or less (matches CSS media queries)
// 2. Mobile phones in portrait mode
// 3. iPad in very small screens (≤ 600px)
// 4. All iPhone models by default (including largest models), but respect user preference
// An explicit choice wins on EVERY device, not just on an iPhone. Turning mobile
// mode on in desktop Firefox set body.mobile-mode but left this false, because the
// branches below only look at screen width and user agent. The two disagreed, and
// the parts of the mobile layout driven from here - the `mobile-view` class on
// each list, swimlane and minicard - never appeared, so lists and cards stayed
// their desktop width while the rest of the UI was in mobile mode.
const explicitMobileMode = this.getExplicitMobileMode();
if (explicitMobileMode !== null) {
return explicitMobileMode;
}
const isSmallScreen = window.innerWidth <= 800;
const isVerySmallScreen = window.innerWidth <= 600;
const isPortrait = window.innerWidth < window.innerHeight || window.matchMedia("(orientation: portrait)").matches;
const isMobilePhone = /Mobile|Android|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) && !/iPad/i.test(navigator.userAgent);
const isIPhone = /iPhone|iPod/i.test(navigator.userAgent);
const isIPad = /iPad/i.test(navigator.userAgent);
const isUbuntuTouch = /Ubuntu/i.test(navigator.userAgent);
// Below here the user has made no explicit choice (that is handled above), so
// these are the per-device DEFAULTS.
// iPhone: mobile view by default, on every model including the largest.
if (isIPhone) {
return true;
} else if (isMobilePhone) {
return isPortrait; // Other mobile phones: portrait = mobile, landscape = desktop
} else if (isIPad) {
return isVerySmallScreen; // iPad: only very small screens get mobile view
} else if (isUbuntuTouch) {
// Ubuntu Touch: smartphones (≤ 600px) behave like mobile phones, tablets (> 600px) like iPad
if (isVerySmallScreen) {
return isPortrait; // Ubuntu Touch smartphone: portrait = mobile, landscape = desktop
} else {
return isVerySmallScreen; // Ubuntu Touch tablet: only very small screens get mobile view
}
} else {
return isSmallScreen; // Desktop: based on 800px screen width
}
},
isTouchScreen() {
// NEW TOUCH DEVICE DETECTION:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent
var hasTouchScreen = false;
if ("maxTouchPoints" in navigator) {
hasTouchScreen = navigator.maxTouchPoints > 0;
} else if ("msMaxTouchPoints" in navigator) {
hasTouchScreen = navigator.msMaxTouchPoints > 0;
} else {
var mQ = window.matchMedia && matchMedia("(pointer:coarse)");
if (mQ && mQ.media === "(pointer:coarse)") {
hasTouchScreen = !!mQ.matches;
} else if ('orientation' in window) {
hasTouchScreen = true; // deprecated, but good fallback
} else {
// Only as a last resort, fall back to user agent sniffing
var UA = navigator.userAgent;
hasTouchScreen = (
/\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA) ||
/\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA)
);
}
}
return hasTouchScreen;
},
// The user's EXPLICIT drag-handle choice: true, false, or null when they have
// never chosen. The three states matter — see showDragHandles() below.
dragHandlesPreference() {
// #6745: read by every minicard - one field, not the whole user document.
const currentUser = currentUserWith(['profile.showDesktopDragHandles']);
const stored = currentUser
? (currentUser.profile || {}).showDesktopDragHandles
// Not logged in: the same three states, kept in localStorage. A MISSING
// key is "never chosen"; the string 'false' is a deliberate "off".
: window.localStorage.getItem('showDesktopDragHandles');
return readDragHandlesPreference(stored);
},
// Should drag handles be shown? An explicit choice always wins — including
// turning them OFF on a touch screen. Only when the user has never chosen does
// the device decide, and then a touch screen gets handles by default (a finger
// needs a handle to grab; a mouse does not).
//
// This used to be `isTouchScreen() || preference`, which meant the toggle did
// NOTHING on a touch screen: the OR was already true, so "Show desktop drag
// handles" could never hide them there.
showDragHandles() {
return resolveShowDragHandles(
Utils.dragHandlesPreference(),
Utils.isTouchScreen(),
);
},
// returns if desktop drag handles are enabled (the EFFECTIVE state, so the
// menu checkmark matches what is actually on screen)
isShowDesktopDragHandles() {
return Utils.showDragHandles();
},
// Legacy name kept because it is used across the board templates; it now means
// exactly showDragHandles().
isTouchScreenOrShowDesktopDragHandles() {
return Utils.showDragHandles();
},
calculateIndexData(prevData, nextData, nItems = 1) {
let base, increment;
// If we drop the card to an empty column
if (!prevData && !nextData) {
base = 0;
increment = 1;
// If we drop the card in the first position
} else if (!prevData) {
const nextSortIndex = nextData.sort;
const ceil = Math.ceil(nextSortIndex - 1);
if (ceil < nextSortIndex) {
increment = nextSortIndex - ceil;
base = nextSortIndex - increment;
} else {
base = nextData.sort - 1;
increment = -1;
}
// If we drop the card in the last position
} else if (!nextData) {
const prevSortIndex = prevData.sort;
const floor = Math.floor(prevSortIndex + 1);
if (floor > prevSortIndex) {
increment = prevSortIndex - floor;
base = prevSortIndex - increment;
} else {
base = prevData.sort + 1;
increment = 1;
}
}
// In the general case take the average of the previous and next element
// sort indexes.
else {
const prevSortIndex = prevData.sort;
const nextSortIndex = nextData.sort;
if (nItems == 1 ) {
if (prevSortIndex < 0 ) {
const ceil = Math.ceil(nextSortIndex - 1);
if (ceil < nextSortIndex && ceil > prevSortIndex) {
increment = ceil - prevSortIndex;
}
} else {
const floor = Math.floor(nextSortIndex - 1);
if (floor < nextSortIndex && floor > prevSortIndex) {
increment = floor - prevSortIndex;
}
}
}
if (!increment) {
increment = (nextSortIndex - prevSortIndex) / (nItems + 1);
}
if (!base) {
base = prevSortIndex + increment;
}
}
// XXX Return a generator that yield values instead of a base with a
// increment number.
return {
base,
increment,
};
},
// Determine the new sort index
calculateIndex(prevCardDomElement, nextCardDomElement, nCards = 1) {
let prevData = null;
let nextData = null;
if (prevCardDomElement) {
prevData = Blaze.getData(prevCardDomElement)
}
if (nextCardDomElement) {
nextData = Blaze.getData(nextCardDomElement);
}
const ret = Utils.calculateIndexData(prevData, nextData, nCards);
return ret;
},
manageCustomUI() {
// Subscribe to custom UI settings (published from server)
Meteor.subscribe('customUI');
// Reactive helper will be called when Settings data changes
Tracker.autorun(() => {
const settings = Settings.findOne({});
if (settings) {
Utils.setCustomUI(settings);
}
});
},
setCustomUI(data) {
const productName = (data && data.productName) ? data.productName : 'Wekan';
const currentBoard = Utils.getCurrentBoard();
if (currentBoard) {
document.title = `${currentBoard.title} - ${productName}`;
return;
}
// Every other page: the product name and then WHERE YOU ARE -
// "Product name - All Boards / Remaining". The tab used to say only the
// product name, so ten open tabs of one WeKan were ten identical tabs, and
// a bookmark of any of them was named after the whole app rather than after
// the page. The path is the one the header bar draws, published by it.
// docs/Features/Pages/Starred.md
document.title = pageDocumentTitle(productName, headerPathVar.get());
},
setMatomo(data) {
window._paq = window._paq || [];
window._paq.push(['setDoNotTrack', data.doNotTrack]);
if (data.withUserName) {
window._paq.push(['setUserId', ReactiveCache.getCurrentUser().username]);
}
window._paq.push(['trackPageView']);
window._paq.push(['enableLinkTracking']);
(function () {
window._paq.push(['setTrackerUrl', `${data.address}piwik.php`]);
window._paq.push(['setSiteId', data.siteId]);
const script = document.createElement('script');
Object.assign(script, {
id: 'scriptMatomo',
type: 'text/javascript',
async: 'true',
defer: 'true',
src: `${data.address}piwik.js`,
});
const s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(script, s);
})();
Session.set('matomo', true);
},
manageMatomo() {
// Subscribe to Matomo configuration (published from server)
Meteor.subscribe('matomoConfig');
// Reactive helper will be called when Settings data changes
Tracker.autorun(() => {
const matomo = Session.get('matomo');
if (matomo === undefined) {
const settings = Settings.findOne({});
if (settings && settings.matomoURL && settings.matomoSiteId) {
const matomoConfig = {
address: settings.matomoURL,
siteId: settings.matomoSiteId,
doNotTrack: settings.matomoDoNotTrack || false,
withUserName: settings.matomoWithUserName || false
};
Utils.setMatomo(matomoConfig);
} else {
Session.set('matomo', false);
}
} else if (matomo) {
window._paq = window._paq || [];
window._paq.push(['trackPageView']);
}
});
},
getTriggerActionDesc(event, tempInstance) {
const jqueryEl = tempInstance.$(event.currentTarget.parentNode);
const triggerEls = jqueryEl.find('.trigger-content').children();
const parts = [];
for (let i = 0; i < triggerEls.length; i++) {
const element = tempInstance.$(triggerEls[i]);
let part = '';
if (element.hasClass('trigger-text')) {
part = element.text().toLowerCase();
} else if (element.hasClass('user-details')) {
let username = element.find('input').val();
if (username === undefined || username === '') {
username = '*';
}
part = `${element
.find('.trigger-text')
.text()
.toLowerCase()} ${username}`;
} else if (element.find('.js-calendar-toggle').length > 0) {
part = element.find('.js-calendar-toggle').text().trim();
} else if (element.find('.js-calendar-native-time').length > 0) {
part = element.find('.js-calendar-native-time').val();
} else if (element.find('select').length > 0) {
part = element
.find('select option:selected')
.text()
.toLowerCase();
} else if (element.find('input[type=checkbox]').length > 0) {
if (element.find('input[type=checkbox]').is(':checked')) part = element.text().trim();
} else if (element.find('input, textarea').length > 0) {
let inputvalue = element.find('input, textarea').val();
if (inputvalue === undefined || inputvalue === '') {
inputvalue = '*';
}
part = inputvalue;
}
// Buttons and omitted copulas contribute no text or separator.
if (part !== '') parts.push(part);
}
return parts.join(' ');
},
fallbackCopyTextToClipboard(text) {
var textArea = document.createElement("textarea");
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
return Promise.resolve(true);
} catch (e) {
return Promise.reject(false);
} finally {
document.body.removeChild(textArea);
}
},
/** copy the text to the clipboard
* @see https://stackoverflow.com/questions/400212/how-do-i-copy-to-the-clipboard-in-javascript/30810322#30810322
* @param string copy this text to the clipboard
* @return Promise
*/
copyTextToClipboard(text) {
let ret;
if (navigator.clipboard) {
ret = navigator.clipboard.writeText(text).then(function () {
}, function (err) {
console.error('Async: Could not copy text: ', err);
});
} else {
ret = Utils.fallbackCopyTextToClipboard(text);
}
return ret;
},
/** show the "copied!" message
* @param promise the promise of Utils.copyTextToClipboard
* @param $tooltip jQuery tooltip element
*/
showCopied(promise, $tooltip) {
if (promise) {
promise.then(() => {
$tooltip.show(100);
setTimeout(() => $tooltip.hide(100), 1000);
}, (err) => {
console.error("error: ", err);
});
}
},
};
// A simple tracker dependency that we invalidate every time the window is
// resized. This is used to reactively re-calculate the popup position in case
// of a window resize. This is the equivalent of a "Signal" in some other
// programming environments (eg, elm).
$(window).on('resize', () => Utils.windowResizeDep.changed());
// #697: Sandstorm keeps a grain iframe alive while the user visits another
// page. If WeKan finishes its first render while that iframe is hidden, the
// browser can report a zero/narrow viewport and responsive helpers cache the
// compact layout. Returning to the grain does not consistently produce a
// native resize event, so the sidebar and other viewport-bound elements could
// remain laid out for the hidden frame and fall outside the visible iframe.
//
// Treat every way a preserved document resumes as a deferred resize. The next
// animation frame is important: it lets the embedding page restore the iframe's
// dimensions before Blaze, popups and header measurements read them. Coalescing
// avoids doing the same work twice when visibilitychange, pageshow and focus
// arrive together. This is safe outside Sandstorm too (for background tabs and
// bfcache restores), and reuses the ordinary resize path rather than maintaining
// a second list of responsive consumers.
function refreshViewportAfterResume() {
if (document.hidden || window.__wekanViewportResumePending) return;
// First leave the lifecycle-event task so pageshow/focus/visibilitychange can
// all collect, then leave one animation frame so the iframe has its restored
// dimensions. Chromium may run an animation frame inside a synthetic
// pageshow dispatch, so rAF alone is not a sufficient coalescing boundary.
window.__wekanViewportResumePending = true;
window.setTimeout(() => {
const schedule = window.requestAnimationFrame || (callback => window.setTimeout(callback, 0));
schedule(() => {
window.__wekanViewportResumePending = false;
window.dispatchEvent(new CustomEvent('resize', {
detail: { source: 'wekan-viewport-resume' },
}));
});
}, 0);
}
// Meteor hot-module replacement can evaluate this module again without
// recreating the document. Replace the previous instance instead of stacking
// another set of listeners (and another resize) on every hot update.
const previousViewportResume = window.__wekanViewportResumeHandler;
if (previousViewportResume) {
document.removeEventListener('visibilitychange', previousViewportResume);
window.removeEventListener('pageshow', previousViewportResume);
window.removeEventListener('focus', previousViewportResume);
}
window.__wekanViewportResumeHandler = refreshViewportAfterResume;
document.addEventListener('visibilitychange', refreshViewportAfterResume);
window.addEventListener('pageshow', refreshViewportAfterResume);
window.addEventListener('focus', refreshViewportAfterResume);
// --wekan-header-height: how tall the two header bars actually are, right now.
//
// Anything laid out against the VIEWPORT rather than against the page flow -
// the right sidebar is `position: fixed` on a phone, so that it spans the screen
// instead of the wider board behind it - has to start below the header, and the
// header is not one height: the quick-access bar plus a board bar whose buttons
// wrap to one, two or three rows, in whichever language and window width. Every
// fixed number for it has been wrong for some of those.
//
// So the header measures itself into a custom property and the CSS uses it. A
// ResizeObserver catches the rows re-wrapping (a window resize does not fire for
// that on its own), and the initial call covers the first paint.
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
// BOTH bars, not just the second one. This measured `#header` alone - the
// second bar - back when every page had one. Most pages have none now (their
// title is in the first bar and their controls are in a sidebar), so on those
// the variable was 0 and everything laid out against it started at the top of
// the WINDOW, underneath the first bar: the All Boards sidebar covered it.
//
// Measured as the BOTTOM of the lowest bar rather than a sum of heights, so
// any margin between them is included and a bar that is absent contributes
// nothing without needing a special case.
const HEADER_IDS = ['header-quick-access', 'header'];
const publishHeaderHeight = () => {
let bottom = 0;
for (const id of HEADER_IDS) {
const el = document.getElementById(id);
if (el) bottom = Math.max(bottom, el.getBoundingClientRect().bottom);
}
document.documentElement.style.setProperty(
'--wekan-header-height', `${Math.round(bottom)}px`);
};
const watchHeader = () => {
publishHeaderHeight();
if (!window.ResizeObserver) return;
for (const id of HEADER_IDS) {
const el = document.getElementById(id);
// The first bar WRAPS to a second row when its buttons run out of room,
// which is a resize of that element and of nothing else - so it is
// observed in its own right, not inferred from the window.
if (!el || el.__wekanHeightObserved) continue;
el.__wekanHeightObserved = true;
new ResizeObserver(publishHeaderHeight).observe(el);
}
};
// A login or route change can render the bars long after startup timers ran.
// The header template calls this after Blaze has flushed those changes.
Utils.watchHeaderHeight = watchHeader;
$(window).on('resize orientationchange', publishHeaderHeight);
// The header is rendered by Blaze, so it may not exist yet at import time.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', watchHeader);
} else {
watchHeader();
}
// ...and once more after the first render, for the same reason.
setTimeout(watchHeader, 0);
setTimeout(watchHeader, 500);
}