mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
feat: let a new search look only in the open folder #1485
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { groupSearchScopeFolders } from '@/lib/search-scope-folders';
|
||||
import { defaultSearchScopeFor, groupSearchScopeFolders } from '@/lib/search-scope-folders';
|
||||
|
||||
// The search panel's Folder dropdown lists the shared/group folders under
|
||||
// their owner account instead of as bare names among the own ones (#1082),
|
||||
@@ -79,3 +79,32 @@ describe('groupSearchScopeFolders', () => {
|
||||
expect(shared).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultSearchScopeFor', () => {
|
||||
const folders = [
|
||||
mb({ id: 'inbox', role: 'inbox' }),
|
||||
mb({ id: 'projects' }),
|
||||
mb({ id: 'trash', role: 'trash' }),
|
||||
mb({ id: 'junk', role: 'junk' }),
|
||||
];
|
||||
|
||||
it('searches all folders but Spam and Trash from any other folder by default', () => {
|
||||
expect(defaultSearchScopeFor(folders, 'inbox', 'all')).toBe('');
|
||||
expect(defaultSearchScopeFor(folders, 'projects', 'all')).toBe('');
|
||||
expect(defaultSearchScopeFor(folders, 'trash', 'all')).toBe('trash');
|
||||
expect(defaultSearchScopeFor(folders, 'junk', 'all')).toBe('junk');
|
||||
});
|
||||
|
||||
// #1485
|
||||
it('searches the open folder when "Search in" is set to it', () => {
|
||||
expect(defaultSearchScopeFor(folders, 'inbox', 'folder')).toBe('inbox');
|
||||
expect(defaultSearchScopeFor(folders, 'projects', 'folder')).toBe('projects');
|
||||
expect(defaultSearchScopeFor(folders, 'trash', 'folder')).toBe('trash');
|
||||
});
|
||||
|
||||
it('searches all folders from a view that is not a folder', () => {
|
||||
expect(defaultSearchScopeFor(folders, '__unified_inbox', 'folder')).toBe('');
|
||||
expect(defaultSearchScopeFor(folders, '', 'folder')).toBe('');
|
||||
expect(defaultSearchScopeFor(folders, null, 'folder')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* The "Search in" setting (#1485): whether a new search looks in all folders
|
||||
* or only in the folder that is open. Its own module so the settings store
|
||||
* can check values without importing the folder helpers (they import the
|
||||
* store back through lib/utils).
|
||||
*/
|
||||
export type SearchDefaultScope = 'all' | 'folder';
|
||||
|
||||
export function isSearchDefaultScope(value: unknown): value is SearchDefaultScope {
|
||||
return value === 'all' || value === 'folder';
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode } from '@/lib/utils';
|
||||
import type { SearchDefaultScope } from '@/lib/search-default-scope';
|
||||
|
||||
/**
|
||||
* `searchMailboxId` of the "All folders" search scope: every folder of every
|
||||
@@ -18,13 +19,20 @@ export function isAllFoldersSearchScope(searchMailboxId: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* The search scope a folder starts with: Spam and Trash search themselves,
|
||||
* since that is what a search from inside them looks for; every other folder
|
||||
* searches all folders except Spam and Trash ("").
|
||||
* The search scope a folder starts with. With `defaultScope` 'folder' that
|
||||
* is the open folder. Otherwise Spam and Trash search themselves, since that
|
||||
* is what a search from inside them looks for, and every other folder
|
||||
* searches all folders except Spam and Trash (""). A view that is not a
|
||||
* folder of `mailboxes` (unified views, tags) always starts with "".
|
||||
*/
|
||||
export function defaultSearchScopeFor(mailboxes: Mailbox[], openMailboxId: string | null | undefined): string {
|
||||
export function defaultSearchScopeFor(
|
||||
mailboxes: Mailbox[],
|
||||
openMailboxId: string | null | undefined,
|
||||
defaultScope: SearchDefaultScope,
|
||||
): string {
|
||||
const open = openMailboxId ? mailboxes.find((mb) => mb.id === openMailboxId) : undefined;
|
||||
return open && (open.role === 'trash' || open.role === 'junk') ? open.id : '';
|
||||
if (!open) return '';
|
||||
return defaultScope === 'folder' || open.role === 'trash' || open.role === 'junk' ? open.id : '';
|
||||
}
|
||||
|
||||
export interface SearchScopeFolderGroup {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import { useAuthStore } from '../auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
@@ -193,6 +193,129 @@ describe('search folder scope (#788)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #1485: "Search in" set to the open folder makes a new search look only in
|
||||
// the folder that is open, without picking it in the filters first.
|
||||
describe('"Search in: the open folder" setting (#1485)', () => {
|
||||
let client: IJMAPClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = makeClient();
|
||||
useAuthStore.setState({
|
||||
activeAccountId: 'account-a',
|
||||
getClientForAccount: (id: string) => (id === 'account-a' ? client : undefined) as never,
|
||||
} as never);
|
||||
useSettingsStore.setState({ emailsPerPage: 50, searchDefaultScope: 'folder' } as never);
|
||||
useEmailStore.setState({
|
||||
isUnifiedView: false,
|
||||
unifiedRole: null,
|
||||
crossView: null,
|
||||
viewingAccountId: null,
|
||||
selectedKeyword: null,
|
||||
selectedMailbox: 'inbox',
|
||||
searchMailboxId: 'inbox',
|
||||
searchScopeFollowsFolder: false,
|
||||
searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
||||
searchAbortController: null,
|
||||
emails: [],
|
||||
mailboxes: [
|
||||
makeMailbox({ id: 'inbox', role: 'inbox' }),
|
||||
makeMailbox({ id: 'sent', name: 'Sent Items', role: 'sent' }),
|
||||
makeMailbox({ id: 'owner-x:x-inbox', originalId: 'x-inbox', name: 'Shared Inbox', role: 'inbox', isShared: true, accountId: 'owner-x' }),
|
||||
],
|
||||
accountMailboxes: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState({ searchDefaultScope: 'all' } as never);
|
||||
});
|
||||
|
||||
it('quick search looks only in the folder that is open', async () => {
|
||||
useEmailStore.getState().selectMailbox('sent');
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('sent');
|
||||
|
||||
await useEmailStore.getState().searchEmails(client, 'invoice');
|
||||
|
||||
expect(client.searchEmails).toHaveBeenCalledTimes(1);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('invoice', 'sent', undefined, 50, 0, { deferSnippets: true });
|
||||
});
|
||||
|
||||
it('searches a shared folder in its owner account', async () => {
|
||||
useEmailStore.getState().selectMailbox('owner-x:x-inbox');
|
||||
useEmailStore.setState({ searchFilters: { ...DEFAULT_SEARCH_FILTERS, from: 'bob@example.com' } });
|
||||
|
||||
await useEmailStore.getState().advancedSearch(client);
|
||||
|
||||
const calls = (client.advancedSearchEmails as ReturnType<typeof vi.fn>).mock.calls;
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0][0]).toEqual({ operator: 'AND', conditions: [{ from: 'bob@example.com' }, { inMailbox: 'x-inbox' }] });
|
||||
expect(calls[0][1]).toBe('owner-x');
|
||||
});
|
||||
|
||||
it('keeps a scope picked in the filters when another folder is opened', () => {
|
||||
useEmailStore.getState().setSearchMailboxId('');
|
||||
useEmailStore.getState().selectMailbox('sent');
|
||||
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('');
|
||||
});
|
||||
|
||||
it('clearing the filters goes back to the open folder', () => {
|
||||
useEmailStore.getState().setSearchMailboxId('*');
|
||||
useEmailStore.getState().clearSearchFilters();
|
||||
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('inbox');
|
||||
});
|
||||
|
||||
it('a view that is not a folder searches all folders', () => {
|
||||
useEmailStore.getState().selectMailbox('__unified_inbox');
|
||||
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('');
|
||||
});
|
||||
|
||||
it('changing the setting moves the open folder\'s scope with it', () => {
|
||||
useSettingsStore.setState({ searchDefaultScope: 'all' } as never);
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('');
|
||||
|
||||
useSettingsStore.setState({ searchDefaultScope: 'folder' } as never);
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('inbox');
|
||||
|
||||
// Then it follows the folders the user opens.
|
||||
useEmailStore.getState().selectMailbox('sent');
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('sent');
|
||||
});
|
||||
|
||||
it('changing the setting leaves a scope picked in the filters alone', () => {
|
||||
useEmailStore.getState().setSearchMailboxId('sent');
|
||||
useSettingsStore.setState({ searchDefaultScope: 'all' } as never);
|
||||
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('sent');
|
||||
});
|
||||
|
||||
it('scopes the inbox the app opens with once the folder list arrives', async () => {
|
||||
useEmailStore.setState({ mailboxes: [], selectedMailbox: '', searchMailboxId: '' });
|
||||
const folders = [makeMailbox({ id: 'inbox', role: 'inbox' }), makeMailbox({ id: 'sent', role: 'sent' })];
|
||||
const listClient = { ...makeClient(), getAllMailboxes: vi.fn().mockResolvedValue(folders) } as unknown as IJMAPClient;
|
||||
|
||||
await useEmailStore.getState().fetchMailboxes(listClient);
|
||||
|
||||
expect(useEmailStore.getState().selectedMailbox).toBe('inbox');
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('inbox');
|
||||
});
|
||||
|
||||
it('scopes a folder opened before the folder list arrived', async () => {
|
||||
useEmailStore.setState({ mailboxes: [], selectedMailbox: '', searchMailboxId: '' });
|
||||
useEmailStore.getState().selectMailbox('sent');
|
||||
const folders = [makeMailbox({ id: 'inbox', role: 'inbox' }), makeMailbox({ id: 'sent', role: 'sent' })];
|
||||
const listClient = { ...makeClient(), getAllMailboxes: vi.fn().mockResolvedValue(folders) } as unknown as IJMAPClient;
|
||||
|
||||
await useEmailStore.getState().fetchMailboxes(listClient);
|
||||
|
||||
expect(useEmailStore.getState().selectedMailbox).toBe('sent');
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('sent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unread badge scope', () => {
|
||||
let client: IJMAPClient;
|
||||
|
||||
|
||||
+42
-19
@@ -1406,6 +1406,11 @@ function mailboxesInView(state: Pick<EmailStore, 'viewingAccountId' | 'accountMa
|
||||
return state.mailboxes;
|
||||
}
|
||||
|
||||
/** The search scope `openMailboxId` starts with under the user's "Search in" setting (#1485). */
|
||||
function currentSearchScopeDefault(mailboxes: Mailbox[], openMailboxId: string | null | undefined): string {
|
||||
return defaultSearchScopeFor(mailboxes, openMailboxId, useSettingsStore.getState().searchDefaultScope);
|
||||
}
|
||||
|
||||
function resolveActionMailboxes(): Mailbox[] {
|
||||
return mailboxesInView(useEmailStore.getState());
|
||||
}
|
||||
@@ -3362,7 +3367,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// the open folder moves to the new one.
|
||||
searchMailboxId: state.searchScopeFollowsFolder
|
||||
? mailboxId
|
||||
: defaultSearchScopeFor(
|
||||
: currentSearchScopeDefault(
|
||||
accountId ? state.accountMailboxes[accountId] ?? [] : state.mailboxes,
|
||||
mailboxId,
|
||||
),
|
||||
@@ -3509,13 +3514,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// The folder's default search scope follows it (Spam and Trash search
|
||||
// themselves); a scope the user picked in the dropdown stays. The
|
||||
// unread badge's scope follows any real folder.
|
||||
const scopeIsDefault = state.searchMailboxId === defaultSearchScopeFor(mailboxes, state.selectedMailbox);
|
||||
const scopeIsDefault = state.searchMailboxId === currentSearchScopeDefault(mailboxes, state.selectedMailbox);
|
||||
const scopeFollows = state.searchScopeFollowsFolder && mailboxes.some(m => m.id === mailboxId);
|
||||
return {
|
||||
selectedMailbox: mailboxId,
|
||||
...(scopeFollows
|
||||
? { searchMailboxId: mailboxId }
|
||||
: scopeIsDefault ? { searchMailboxId: defaultSearchScopeFor(mailboxes, mailboxId) } : {}),
|
||||
: scopeIsDefault ? { searchMailboxId: currentSearchScopeDefault(mailboxes, mailboxId) } : {}),
|
||||
isLoadingMore: false,
|
||||
selectedEmail: null,
|
||||
selectedEmailKeys: new Set(),
|
||||
@@ -3683,22 +3688,27 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const loadingPatch = isInitialLoad && mailboxes.length === 0
|
||||
? { isLoading: false, mailboxSyncStates: fetched.states }
|
||||
: { mailboxSyncStates: fetched.states };
|
||||
// Find inbox from PRIMARY account (not shared accounts)
|
||||
const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared);
|
||||
const nextSelected = selectionValid ? currentSelectedMailbox : inboxMailbox?.id ?? '';
|
||||
// A default search scope moves to the default of the folder now open:
|
||||
// a folder opened before the list arrived, or the inbox picked here,
|
||||
// only gets its scope once it is in the list (#1485). A scope naming a
|
||||
// folder that is gone (e.g. the Spam/Trash the default scope followed)
|
||||
// falls back to the default as well.
|
||||
const { searchMailboxId: scope, searchScopeFollowsFolder } = get();
|
||||
const nextInView = mailboxesInView({ ...get(), mailboxes });
|
||||
const nextDefault = currentSearchScopeDefault(nextInView, nextSelected);
|
||||
const scopeGone = !isAllFoldersSearchScope(scope) && !nextInView.some(m => m.id === scope);
|
||||
const scopeWasDefault = !searchScopeFollowsFolder
|
||||
&& scope === currentSearchScopeDefault(mailboxesInView(get()), currentSelectedMailbox);
|
||||
const scopePatch = (scopeGone || scopeWasDefault) && scope !== nextDefault
|
||||
? { searchMailboxId: nextDefault }
|
||||
: {};
|
||||
if (!selectionValid) {
|
||||
// A search scope naming a folder that is gone (e.g. the Spam/Trash
|
||||
// the default scope followed) falls back to the default.
|
||||
const scope = get().searchMailboxId;
|
||||
const scopePatch = !isAllFoldersSearchScope(scope) && !mailboxes.some(m => m.id === scope)
|
||||
? { searchMailboxId: '' }
|
||||
: {};
|
||||
// Find inbox from PRIMARY account (not shared accounts)
|
||||
const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared);
|
||||
if (inboxMailbox) {
|
||||
set({ mailboxes, selectedMailbox: inboxMailbox.id, ...scopePatch, ...loadingPatch });
|
||||
} else {
|
||||
set({ mailboxes, selectedMailbox: '', ...scopePatch, ...loadingPatch });
|
||||
}
|
||||
set({ mailboxes, selectedMailbox: nextSelected, ...scopePatch, ...loadingPatch });
|
||||
} else {
|
||||
set({ mailboxes, ...loadingPatch });
|
||||
set({ mailboxes, ...scopePatch, ...loadingPatch });
|
||||
}
|
||||
} catch (error) {
|
||||
if (stale()) return;
|
||||
@@ -5541,7 +5551,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
clearSearchFilters: () => {
|
||||
set((state) => ({
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
||||
searchMailboxId: defaultSearchScopeFor(mailboxesInView(state), state.selectedMailbox),
|
||||
searchMailboxId: currentSearchScopeDefault(mailboxesInView(state), state.selectedMailbox),
|
||||
searchScopeFollowsFolder: false,
|
||||
}));
|
||||
},
|
||||
@@ -8492,6 +8502,19 @@ useEmailStore.subscribe((state, prev) => {
|
||||
state.holdListRow(listRowKey(email, state.isScheduledView));
|
||||
});
|
||||
|
||||
// Changing "Search in" moves the open folder's scope along with it, unless
|
||||
// the user picked a scope in the search panel (#1485). Otherwise the old
|
||||
// default would look like a picked scope and stick to every folder.
|
||||
useSettingsStore.subscribe((settings, prevSettings) => {
|
||||
if (settings.searchDefaultScope === prevSettings.searchDefaultScope) return;
|
||||
const state = useEmailStore.getState();
|
||||
if (state.searchScopeFollowsFolder) return;
|
||||
const mailboxes = mailboxesInView(state);
|
||||
const before = defaultSearchScopeFor(mailboxes, state.selectedMailbox, prevSettings.searchDefaultScope);
|
||||
const after = defaultSearchScopeFor(mailboxes, state.selectedMailbox, settings.searchDefaultScope);
|
||||
if (state.searchMailboxId === before && before !== after) useEmailStore.setState({ searchMailboxId: after });
|
||||
});
|
||||
|
||||
// A folder's remembered list follows what happens to it on screen - a message
|
||||
// read, archived or deleted here is shown that way when the folder is opened
|
||||
// again, rather than as it was when the folder was loaded.
|
||||
@@ -8575,7 +8598,7 @@ if (typeof window !== 'undefined') {
|
||||
useEmailStore.setState({
|
||||
mailboxes: snap.mailboxes,
|
||||
selectedMailbox,
|
||||
searchMailboxId: defaultSearchScopeFor(snap.mailboxes, selectedMailbox),
|
||||
searchMailboxId: currentSearchScopeDefault(snap.mailboxes, selectedMailbox),
|
||||
...(selectedMailbox === snapMailbox && Array.isArray(snap.emails) && snap.emails.length > 0
|
||||
? {
|
||||
emails: snap.emails,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { subscriptionOwnerFor } from '@/lib/calendar-subscription-sync';
|
||||
import type { FolderCountMode } from '@/lib/folder-counts';
|
||||
import { DEFAULT_FILES_SETTINGS, normalizeFilesSettings, type FilesSettings } from '@/lib/files-settings';
|
||||
import { orderForMailbox, sanitizeSortLevels, type MessageListOrderScope, type SortLevel } from '@/lib/message-list-order';
|
||||
import { isSearchDefaultScope, type SearchDefaultScope } from '@/lib/search-default-scope';
|
||||
import { EMPTY_SIGNATURE_DETAILS, sanitizeSignatureDetails, type SignatureDetails } from '@/lib/org-signature';
|
||||
import {
|
||||
ACCESSIBILITY_DEFAULTS,
|
||||
@@ -559,6 +560,7 @@ interface SettingsState extends AccessibilitySettings {
|
||||
confirmBulkDelete: boolean; // Ask before moving several selected emails to Trash (permanent deletes always ask)
|
||||
returnToListAfterAction: boolean; // After delete / archive / move / spam / mark-unread in an open message, return to the list instead of opening the next message
|
||||
clearSearchOnFolderChange: boolean; // Reset the search query + advanced filters when switching folders, instead of re-running the search in the newly selected folder (#553 keeps it applied when this is off)
|
||||
searchDefaultScope: SearchDefaultScope; // Where a new search looks: all folders, or only the open one (#1485)
|
||||
showPreview: boolean;
|
||||
showVerificationCodes: boolean; // Offer the one-time code of a sign-in mail as a copy chip in the list and the reader
|
||||
mailLayout: MailLayout;
|
||||
@@ -898,6 +900,7 @@ const DEFAULT_SETTINGS = {
|
||||
confirmBulkDelete: true,
|
||||
returnToListAfterAction: true,
|
||||
clearSearchOnFolderChange: false,
|
||||
searchDefaultScope: 'all' as SearchDefaultScope,
|
||||
showPreview: true,
|
||||
showVerificationCodes: true,
|
||||
mailLayout: 'split' as MailLayout,
|
||||
@@ -1133,6 +1136,7 @@ function isAllowedPolicyValue(key: string, value: unknown, restriction?: Setting
|
||||
if (key === 'sendDelaySeconds' && ![0, 10, 30, 60].includes(value as number)) return false;
|
||||
if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(value as string)) return false;
|
||||
if (key === 'messageListOrderScope' && value !== 'inbox' && value !== 'all') return false;
|
||||
if (key === 'searchDefaultScope' && !isSearchDefaultScope(value)) return false;
|
||||
if (key === 'calendarMonthTimedEvents' && !MONTH_TIMED_EVENT_STYLES.includes(value as MonthTimedEventStyle)) return false;
|
||||
if (!isAllowedAccessibilityValue(key, value)) return false;
|
||||
if (restriction && typeof restriction === 'object') {
|
||||
@@ -1331,6 +1335,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
returnToListAfterAction: state.returnToListAfterAction,
|
||||
confirmBulkDelete: state.confirmBulkDelete,
|
||||
clearSearchOnFolderChange: state.clearSearchOnFolderChange,
|
||||
searchDefaultScope: state.searchDefaultScope,
|
||||
showPreview: state.showPreview,
|
||||
showVerificationCodes: state.showVerificationCodes,
|
||||
mailLayout: state.mailLayout,
|
||||
@@ -1627,6 +1632,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
if (key === 'messageListOrderScope' && settings[key] !== 'inbox' && settings[key] !== 'all') {
|
||||
return;
|
||||
}
|
||||
if (key === 'searchDefaultScope' && !isSearchDefaultScope(settings[key])) {
|
||||
return;
|
||||
}
|
||||
if (DEVICE_LOCAL_SETTING_KEYS.has(key)) {
|
||||
return;
|
||||
}
|
||||
@@ -1986,6 +1994,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
if (state.messageListOrderScope !== 'inbox' && state.messageListOrderScope !== 'all') {
|
||||
state.messageListOrderScope = 'inbox';
|
||||
}
|
||||
if (!isSearchDefaultScope(state.searchDefaultScope)) {
|
||||
state.searchDefaultScope = 'all';
|
||||
}
|
||||
applyAccessibility(state);
|
||||
applyDensity(state.density, state.interfaceLayout);
|
||||
applyInterfaceLayout(state.interfaceLayout, state.density);
|
||||
|
||||
Reference in New Issue
Block a user