1
0
mirror of https://github.com/flarum/core.git synced 2025-08-06 16:36:47 +02:00

Merge branch '2.x' into dk/info-v2

This commit is contained in:
Daniël Klabbers
2025-02-24 21:57:55 +01:00
135 changed files with 1343 additions and 400 deletions

View File

@@ -7,6 +7,7 @@ export type SettingDropdownOption = {
export interface ISettingDropdownAttrs extends ISelectDropdownAttrs {
setting?: string;
options: Array<SettingDropdownOption>;
default: any;
}
export default class SettingDropdown<CustomAttrs extends ISettingDropdownAttrs = ISettingDropdownAttrs> extends SelectDropdown<CustomAttrs> {
static initAttrs(attrs: ISettingDropdownAttrs): void;

View File

@@ -68,6 +68,7 @@ export default class UserListPage extends AdminPage {
* See `UserListPage.tsx` for examples.
*/
columns(): ItemList<ColumnData>;
userActionItems(user: User): ItemList<Mithril.Children>;
headerInfo(): {
className: string;
icon: string;

View File

@@ -5,6 +5,10 @@ export default class SearchManager<State extends SearchState = SearchState> {
* The minimum query length before sources are searched.
*/
static MIN_SEARCH_LEN: number;
/**
* Time to wait (in milliseconds) after the user stops typing before triggering a search.
*/
static SEARCH_DEBOUNCE_TIME_MS: number;
/**
* An object which stores previously searched queries and provides convenient
* tools for retrieving and managing search values.

View File

@@ -5,8 +5,10 @@ export interface IButtonAttrs extends ComponentAttrs {
* Class(es) of an optional icon to be rendered within the button.
*
* If provided, the button will gain a `has-icon` class.
*
* You may also provide a rendered icon element directly.
*/
icon?: string;
icon?: string | boolean | Mithril.Children;
/**
* Disables button from user input.
*
@@ -36,6 +38,12 @@ export interface IButtonAttrs extends ComponentAttrs {
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attr-type
*/
type?: string;
/**
* Helper text. Displayed under the button label.
*
* Default: `null`
*/
helperText?: Mithril.Children;
}
/**
* The `Button` component defines an element which, when clicked, performs an
@@ -54,4 +62,5 @@ export default class Button<CustomAttrs extends IButtonAttrs = IButtonAttrs> ext
* Get the template for the button's content.
*/
protected getButtonContent(children: Mithril.Children): Mithril.ChildArray;
protected getButtonSubContent(): Mithril.Children;
}

View File

@@ -13,6 +13,8 @@ export interface IDropdownAttrs extends ComponentAttrs {
caretIcon?: string;
/** The label of the dropdown toggle button. Defaults to 'Controls'. */
label: Mithril.Children;
/** The helper text to display under the button label. */
helperText: Mithril.Children;
/** The label used to describe the dropdown toggle button to assistive readers. Defaults to 'Toggle dropdown menu'. */
accessibleToggleLabel?: string;
/** An optional tooltip to show when hovering over the dropdown toggle button. */
@@ -42,5 +44,6 @@ export default class Dropdown<CustomAttrs extends IDropdownAttrs = IDropdownAttr
* Get the template for the button's content.
*/
getButtonContent(children: Mithril.ChildArray): Mithril.ChildArray;
protected getButtonSubContent(): Mithril.Children;
getMenu(items: Mithril.Vnode<any, any>[]): Mithril.Vnode<any, any>;
}

View File

@@ -0,0 +1,21 @@
import Component, { ComponentAttrs } from '../Component';
import ItemList from '../utils/ItemList';
import type Mithril from 'mithril';
export interface IIPAddressAttrs extends ComponentAttrs {
ip: string | undefined | null;
}
/**
* A component to wrap an IP address for display.
* Designed to be customizable for different use cases.
*
* @example
* <IPAddress ip="127.0.0.1" />
* @example
* <IPAddress ip={post.ipAddress()} />
*/
export default class IPAddress<CustomAttrs extends IIPAddressAttrs = IIPAddressAttrs> extends Component<CustomAttrs> {
ip: string;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>): void;
view(): JSX.Element;
viewItems(): ItemList<Mithril.Children>;
}

View File

@@ -11,6 +11,7 @@ export default class Post extends Model {
contentHtml(): string | null | undefined;
renderFailed(): boolean | undefined;
contentPlain(): string | null | undefined;
ipAddress(): string | null | undefined;
editedAt(): Date | null | undefined;
editedUser(): false | User | null;
isEdited(): boolean;

View File

@@ -1,5 +1,5 @@
import Model from '../Model';
import { ApiQueryParamsPlural, ApiResponsePlural } from '../Store';
import type Model from '../Model';
import type { ApiQueryParamsPlural, ApiResponsePlural } from '../Store';
import type Mithril from 'mithril';
export type SortMapItem = string | {
sort: string;
@@ -8,9 +8,9 @@ export type SortMapItem = string | {
export type SortMap = {
[key: string]: SortMapItem;
};
export interface Page<TModel> {
export interface Page<TModel extends Model> {
number: number;
items: TModel[];
items: ApiResponsePlural<TModel> | TModel[];
hasPrev?: boolean;
hasNext?: boolean;
}
@@ -51,6 +51,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
* Load a new page of results.
*/
protected loadPage(page?: number): Promise<ApiResponsePlural<T>>;
protected mutateRequestParams(params: ApiQueryParamsPlural, page: number): ApiQueryParamsPlural;
/**
* Get the parameters that should be passed in the API request.
* Do not include page offset unless subclass overrides loadPage.
@@ -110,4 +111,5 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
currentSort(): string | undefined;
changeSort(sort: string): void;
changeFilter(key: string, value: any): void;
remove(model: T): void;
}

View File

@@ -13,6 +13,8 @@ export default class IndexPage<CustomAttrs extends IIndexPageAttrs = IIndexPageA
lastDiscussion?: Discussion;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>): void;
view(): JSX.Element;
contentItems(): ItemList<Mithril.Children>;
toolbarItems(): ItemList<Mithril.Children>;
setTitle(): void;
oncreate(vnode: Mithril.VnodeDOM<CustomAttrs, this>): void;
onbeforeremove(vnode: Mithril.VnodeDOM<CustomAttrs, this>): void;

View File

@@ -1,11 +1,13 @@
/// <reference types="mithril" />
import Component, { type ComponentAttrs } from '../../common/Component';
import Post from '../../common/models/Post';
import type Model from '../../common/Model';
import type User from '../../common/models/User';
import ItemList from '../../common/utils/ItemList';
import type Mithril from 'mithril';
type ModelType = Post | (Model & {
user: () => User | null | false;
createdAt: () => Date;
ipAddress: undefined | (() => string | null | undefined);
});
export interface IPostMetaAttrs extends ComponentAttrs {
/** Can be a post or similar model like private message */
@@ -19,10 +21,16 @@ export interface IPostMetaAttrs extends ComponentAttrs {
*/
export default class PostMeta<CustomAttrs extends IPostMetaAttrs = IPostMetaAttrs> extends Component<CustomAttrs> {
view(): JSX.Element;
viewItems(): ItemList<Mithril.Children>;
metaItems(): ItemList<Mithril.Children>;
/**
* Get the permalink for the given post.
*/
getPermalink(post: ModelType): null | string;
/**
* Selects the permalink input when the dropdown is shown.
*/
selectPermalink(e: MouseEvent): void;
postIdentifier(post: ModelType): string | null;
}
export {};

View File

@@ -9,5 +9,13 @@
export default class PostPreview extends Component<import("../../common/Component").ComponentAttrs, undefined> {
constructor();
view(): JSX.Element;
/**
* @returns {string|undefined|null}
*/
content(): string | undefined | null;
/**
* @returns {string}
*/
excerpt(): string;
}
import Component from "../../common/Component";

View File

@@ -16,6 +16,10 @@ export default class PostStream extends Component<import("../../common/Component
stream: any;
scrollListener: ScrollListener | undefined;
view(): JSX.Element;
/**
* @returns {ItemList<import('mithril').Children>}
*/
afterFirstPostItems(): ItemList<import('mithril').Children>;
/**
* @returns {ItemList<import('mithril').Children>}
*/

View File

@@ -14,6 +14,9 @@ export default class PostStreamScrubber extends Component<import("../../common/C
handlers: {} | undefined;
scrollListener: ScrollListener | undefined;
view(): JSX.Element;
firstPostLabel(): string | any[];
unreadLabel(unreadCount: any): any[];
lastPostLabel(): string | any[];
onupdate(vnode: any): void;
oncreate(vnode: any): void;
dragging: boolean | undefined;

View File

@@ -10,6 +10,8 @@ export interface IWelcomeHeroAttrs {
export default class WelcomeHero extends Component<IWelcomeHeroAttrs> {
oninit(vnode: Mithril.Vnode<IWelcomeHeroAttrs, this>): void;
view(vnode: Mithril.Vnode<IWelcomeHeroAttrs, this>): JSX.Element | null;
viewItems(): ItemList<Mithril.Children>;
contentItems(): ItemList<Mithril.Children>;
/**
* Hide the welcome hero.
*/
@@ -20,5 +22,4 @@ export default class WelcomeHero extends Component<IWelcomeHeroAttrs> {
* @returns if the welcome hero is hidden.
*/
isHidden(): boolean;
welcomeItems(): ItemList<Mithril.Children>;
}

View File

@@ -9,6 +9,11 @@ declare namespace PostControls {
* @return {ItemList<import('mithril').Children>}')}
*/
function controls(post: import("../../common/models/Post").default, context: import("../../common/Component").default<any, any>): ItemList<import("mithril").Children>;
function sections(): {
user: (post: import("../../common/models/Post").default, context: import("../../common/Component").default<any, any>) => ItemList<import("mithril").Children>;
moderation: (post: import("../../common/models/Post").default, context: import("../../common/Component").default<any, any>) => ItemList<import("mithril").Children>;
destructive: (post: import("../../common/models/Post").default, context: import("../../common/Component").default<any, any>) => ItemList<import("mithril").Children>;
};
/**
* Get controls for a post pertaining to the current user (e.g. report).
*

2
framework/core/js/dist/admin.js generated vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
framework/core/js/dist/forum.js generated vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -93,7 +93,7 @@ export default class MailPage<CustomAttrs extends IPageAttrs = IPageAttrs> exten
mailSettingItems(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
const fields = this.driverFields![this.setting('mail_driver')()];
const fields = this.driverFields![this.setting('mail_driver')()] || {};
const fieldKeys = Object.keys(fields);
if (this.status!.sending) {

View File

@@ -59,7 +59,12 @@ export default class PermissionGrid<CustomAttrs extends IPermissionGridAttrs = I
<th>
{scope.label}{' '}
{!!scope.onremove && (
<Button icon="fas fa-times" className="Button Button--text PermissionGrid-removeScope" onclick={scope.onremove} />
<Button
icon="fas fa-times"
className="Button Button--text PermissionGrid-removeScope"
aria-label={app.translator.trans('core.admin.permissions.remove_scope_label', { scope: scope.label })}
onclick={scope.onremove}
/>
)}
</th>
))}

View File

@@ -28,7 +28,13 @@ export default class SessionDropdown<CustomAttrs extends ISessionDropdownAttrs =
getButtonContent() {
const user = app.session.user;
return [<Avatar user={user} />, ' ', <span className="Button-label">{username(user)}</span>];
return [
<Avatar user={user} />,
' ',
<span className="Button-label">
<span className="Button-labelText">{username(user)}</span>
</span>,
];
}
/**

View File

@@ -12,6 +12,7 @@ export type SettingDropdownOption = {
export interface ISettingDropdownAttrs extends ISelectDropdownAttrs {
setting?: string;
options: Array<SettingDropdownOption>;
default: any;
}
export default class SettingDropdown<CustomAttrs extends ISettingDropdownAttrs = ISettingDropdownAttrs> extends SelectDropdown<CustomAttrs> {
@@ -33,7 +34,7 @@ export default class SettingDropdown<CustomAttrs extends ISettingDropdownAttrs =
return super.view({
...vnode,
children: this.attrs.options.map(({ value, label }) => {
const active = app.data.settings[this.attrs.setting!] === value;
const active = (app.data.settings[this.attrs.setting!] ?? this.attrs.default) === value;
return (
<Button icon={active ? 'fas fa-check' : true} onclick={saveSettings.bind(this, { [this.attrs.setting!]: value })} active={active}>

View File

@@ -4,6 +4,7 @@ import app from '../../admin/app';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import Button from '../../common/components/Button';
import Dropdown from '../../common/components/Dropdown';
import listItems from '../../common/helpers/listItems';
@@ -363,17 +364,18 @@ export default class UserListPage extends AdminPage {
);
columns.add(
'editUser',
'userActions',
{
name: app.translator.trans('core.admin.users.grid.columns.edit_user.title'),
name: app.translator.trans('core.admin.users.grid.columns.user_actions.title'),
content: (user: User) => (
<Button
className="Button UserList-editModalBtn"
title={app.translator.trans('core.admin.users.grid.columns.edit_user.tooltip', { username: user.username() })}
onclick={() => app.modal.show(() => import('../../common/components/EditUserModal'), { user })}
<Dropdown
className="UserList-userActions"
buttonClassName="Button UserList-userActionsBtn Button--icon Button--flat"
menuClassName="Dropdown-menu--right"
icon="fas fa-ellipsis-h"
>
{app.translator.trans('core.admin.users.grid.columns.edit_user.button')}
</Button>
{this.userActionItems(user).toArray()}
</Dropdown>
),
},
-90
@@ -382,6 +384,23 @@ export default class UserListPage extends AdminPage {
return columns;
}
userActionItems(user: User): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
items.add(
'editUser',
<Button
icon="fas fa-pencil-alt"
title={app.translator.trans('core.admin.users.grid.columns.user_actions.edit_user.tooltip', { username: user.displayName() }, true)}
onclick={() => app.modal.show(() => import('../../common/components/EditUserModal'), { user })}
>
{app.translator.trans('core.admin.users.grid.columns.user_actions.edit_user.button')}
</Button>
);
return items;
}
headerInfo() {
return {
className: 'UserListPage',

View File

@@ -7,6 +7,11 @@ export default class SearchManager<State extends SearchState = SearchState> {
*/
public static MIN_SEARCH_LEN = 3;
/**
* Time to wait (in milliseconds) after the user stops typing before triggering a search.
*/
public static SEARCH_DEBOUNCE_TIME_MS = 250;
/**
* An object which stores previously searched queries and provides convenient
* tools for retrieving and managing search values.

View File

@@ -11,8 +11,10 @@ export interface IButtonAttrs extends ComponentAttrs {
* Class(es) of an optional icon to be rendered within the button.
*
* If provided, the button will gain a `has-icon` class.
*
* You may also provide a rendered icon element directly.
*/
icon?: string;
icon?: string | boolean | Mithril.Children;
/**
* Disables button from user input.
*
@@ -42,6 +44,12 @@ export interface IButtonAttrs extends ComponentAttrs {
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attr-type
*/
type?: string;
/**
* Helper text. Displayed under the button label.
*
* Default: `null`
*/
helperText?: Mithril.Children;
}
/**
@@ -56,7 +64,7 @@ export interface IButtonAttrs extends ComponentAttrs {
*/
export default class Button<CustomAttrs extends IButtonAttrs = IButtonAttrs> extends Component<CustomAttrs> {
view(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
let { type, 'aria-label': ariaLabel, icon: iconName, disabled, loading, className, class: _class, ...attrs } = this.attrs;
let { type, 'aria-label': ariaLabel, icon: iconName, disabled, loading, className, class: _class, helperText, ...attrs } = this.attrs;
// If no `type` attr provided, set to "button"
type ||= 'button';
@@ -74,6 +82,7 @@ export default class Button<CustomAttrs extends IButtonAttrs = IButtonAttrs> ext
hasIcon: iconName,
disabled: disabled || loading,
loading: loading,
hasSubContent: !!this.getButtonSubContent(),
});
const buttonAttrs = {
@@ -104,12 +113,21 @@ export default class Button<CustomAttrs extends IButtonAttrs = IButtonAttrs> ext
* Get the template for the button's content.
*/
protected getButtonContent(children: Mithril.Children): Mithril.ChildArray {
const iconName = this.attrs.icon;
const icon = this.attrs.icon;
return [
iconName && <Icon name={iconName} className="Button-icon" />,
children && <span className="Button-label">{children}</span>,
icon && (typeof icon === 'string' || icon === true ? <Icon name={icon} className="Button-icon" /> : icon),
children && (
<span className="Button-label">
<span className="Button-labelText">{children}</span>
{this.getButtonSubContent()}
</span>
),
this.attrs.loading && <LoadingIndicator size="small" display="inline" />,
];
}
protected getButtonSubContent(): Mithril.Children {
return this.attrs.helperText ? <span className="Button-helperText">{this.attrs.helperText}</span> : null;
}
}

View File

@@ -19,6 +19,8 @@ export interface IDropdownAttrs extends ComponentAttrs {
caretIcon?: string;
/** The label of the dropdown toggle button. Defaults to 'Controls'. */
label: Mithril.Children;
/** The helper text to display under the button label. */
helperText: Mithril.Children;
/** The label used to describe the dropdown toggle button to assistive readers. Defaults to 'Toggle dropdown menu'. */
accessibleToggleLabel?: string;
/** An optional tooltip to show when hovering over the dropdown toggle button. */
@@ -157,11 +159,18 @@ export default class Dropdown<CustomAttrs extends IDropdownAttrs = IDropdownAttr
getButtonContent(children: Mithril.ChildArray): Mithril.ChildArray {
return [
this.attrs.icon ? <Icon name={this.attrs.icon} className="Button-icon" /> : '',
<span className="Button-label">{this.attrs.label}</span>,
<span className="Button-label">
<span className="Button-labelText">{this.attrs.label}</span>
{this.getButtonSubContent()}
</span>,
this.attrs.caretIcon ? <Icon name={this.attrs.caretIcon} className="Button-caret" /> : '',
];
}
protected getButtonSubContent(): Mithril.Children {
return this.attrs.helperText ? <span className="Button-helperText">{this.attrs.helperText}</span> : null;
}
getMenu(items: Mithril.Vnode<any, any>[]): Mithril.Vnode<any, any> {
return <ul className={'Dropdown-menu dropdown-menu ' + this.attrs.menuClassName}>{items}</ul>;
}

View File

@@ -0,0 +1,38 @@
import Component, { ComponentAttrs } from '../Component';
import ItemList from '../utils/ItemList';
import type Mithril from 'mithril';
export interface IIPAddressAttrs extends ComponentAttrs {
ip: string | undefined | null;
}
/**
* A component to wrap an IP address for display.
* Designed to be customizable for different use cases.
*
* @example
* <IPAddress ip="127.0.0.1" />
* @example
* <IPAddress ip={post.ipAddress()} />
*/
export default class IPAddress<CustomAttrs extends IIPAddressAttrs = IIPAddressAttrs> extends Component<CustomAttrs> {
ip!: string;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);
this.ip = this.attrs.ip || '';
}
view() {
return <span className="IPAddress">{this.viewItems().toArray()}</span>;
}
viewItems(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
items.add('ip', <span className="IPAddress-value">{this.ip}</span>, 100);
return items;
}
}

View File

@@ -23,6 +23,7 @@ export default class Pagination<CustomAttrs extends IPaginationInterface = IPagi
<Button
disabled={currentPage === 1}
title={app.translator.trans('core.lib.pagination.first_button')}
aria-label={app.translator.trans('core.lib.pagination.first_button')}
onclick={() => onChange(1)}
icon="fas fa-step-backward"
className="Button Button--icon Pagination-first"
@@ -30,6 +31,7 @@ export default class Pagination<CustomAttrs extends IPaginationInterface = IPagi
<Button
disabled={currentPage === 1}
title={app.translator.trans('core.lib.pagination.back_button')}
aria-label={app.translator.trans('core.lib.pagination.back_button')}
onclick={() => onChange(currentPage - 1)}
icon="fas fa-chevron-left"
className="Button Button--icon Pagination-back"
@@ -77,6 +79,7 @@ export default class Pagination<CustomAttrs extends IPaginationInterface = IPagi
<Button
disabled={!moreData}
title={app.translator.trans('core.lib.pagination.next_button')}
aria-label={app.translator.trans('core.lib.pagination.next_button')}
onclick={() => onChange(currentPage + 1)}
icon="fas fa-chevron-right"
className="Button Button--icon Pagination-next"
@@ -84,6 +87,7 @@ export default class Pagination<CustomAttrs extends IPaginationInterface = IPagi
<Button
disabled={!moreData}
title={app.translator.trans('core.lib.pagination.last_button')}
aria-label={app.translator.trans('core.lib.pagination.last_button')}
onclick={() => onChange(totalPageCount)}
icon="fas fa-step-forward"
className="Button Button--icon Pagination-last"

View File

@@ -324,7 +324,7 @@ export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearch
this.searchState.cache(query);
m.redraw();
}, 250);
}, SearchManager.SEARCH_DEBOUNCE_TIME_MS);
}
/**

View File

@@ -50,7 +50,9 @@ export default class SelectDropdown<CustomAttrs extends ISelectDropdownAttrs = I
let label = (activeChild && typeof activeChild === 'object' && 'children' in activeChild && activeChild.children) || this.attrs.defaultLabel;
return [
<span className="Button-label">{label}</span>,
<span className="Button-label">
<span className="Button-labelText">{label}</span>
</span>,
this.attrs.caretIcon ? <Icon name={this.attrs.caretIcon} className="Button-caret" /> : null,
];
}

View File

@@ -41,6 +41,10 @@ export default class Post extends Model {
}).call(this);
}
ipAddress() {
return Model.attribute<string | null | undefined>('ipAddress').call(this);
}
editedAt() {
return Model.attribute('editedAt', Model.transformDate).call(this);
}

View File

@@ -1,8 +1,7 @@
import app from '../../common/app';
import Model from '../Model';
import { ApiQueryParamsPlural, ApiResponsePlural } from '../Store';
import type Model from '../Model';
import type { ApiQueryParamsPlural, ApiResponsePlural } from '../Store';
import type Mithril from 'mithril';
import setRouteWithForcedRefresh from '../utils/setRouteWithForcedRefresh';
export type SortMapItem =
| string
@@ -15,9 +14,9 @@ export type SortMap = {
[key: string]: SortMapItem;
};
export interface Page<TModel> {
export interface Page<TModel extends Model> {
number: number;
items: TModel[];
items: ApiResponsePlural<TModel> | TModel[];
hasPrev?: boolean;
hasNext?: boolean;
@@ -73,7 +72,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
}
public loadPrev(): Promise<void> {
if (this.loadingPrev || this.getLocation().page === 1) return Promise.resolve();
if (this.loadingPrev || !this.hasPrev()) return Promise.resolve();
this.loadingPrev = true;
@@ -140,7 +139,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
delete params.include;
}
return app.store.find<T[]>(this.type, params).then((results) => {
return app.store.find<T[]>(this.type, this.mutateRequestParams(params, page)).then((results) => {
const usedPerPage = results.payload?.meta?.perPage;
const usedTotal = results.payload?.meta?.page?.total;
@@ -160,6 +159,35 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
});
}
protected mutateRequestParams(params: ApiQueryParamsPlural, page: number): ApiQueryParamsPlural {
/*
* Support use of page[near]=
*/
if (params.page?.near && this.hasItems()) {
delete params.page?.near;
const nextPage = this.location.page < page;
const offsets = this.getPages().map((page) => {
if ('payload' in page.items) {
return page.items.payload.meta?.page?.offset || 0;
}
return 0;
});
const minOffset = Math.min(...offsets);
const maxOffset = Math.max(...offsets);
const limit = this.pageSize || PaginatedListState.DEFAULT_PAGE_SIZE;
params.page ||= {};
params.page.offset = nextPage ? maxOffset + limit : Math.max(minOffset - limit, 0);
}
return params;
}
/**
* Get the parameters that should be passed in the API request.
* Do not include page offset unless subclass overrides loadPage.
@@ -363,4 +391,12 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
1
);
}
remove(model: T): void {
const page = this.pages.find((pg) => pg.items.includes(model));
if (page) {
page.items = page.items.filter((item) => item !== model);
}
}
}

View File

@@ -1,6 +1,7 @@
import app from '../app';
import Component, { ComponentAttrs } from '../../common/Component';
import Button from '../../common/components/Button';
import IPAddress from '../../common/components/IPAddress';
import humanTime from '../../common/helpers/humanTime';
import ItemList from '../../common/utils/ItemList';
import LabelValue from '../../common/components/LabelValue';
@@ -105,7 +106,12 @@ export default class AccessTokensList<CustomAttrs extends IAccessTokensListAttrs
token.lastActivityAt() ? (
<>
{humanTime(token.lastActivityAt())}
{token.lastIpAddress() && `${token.lastIpAddress()}`}
{token.lastIpAddress() && (
<span>
{' '}
<IPAddress ip={token.lastIpAddress()} />
</span>
)}
{this.attrs.type === 'developer_token' && token.device() && (
<>
{' '}

View File

@@ -51,7 +51,7 @@ export default class DiscussionList extends Component {
<ul role="feed" aria-busy={isLoading} className="DiscussionList-discussions">
{state.getPages().map((pg, pageNum) => {
return pg.items.map((discussion, itemNum) => (
<li key={discussion.id()} data-id={discussion.id()} role="article" aria-setsize="-1" aria-posinset={pageNum * pageSize + itemNum}>
<li key={discussion.id()} data-id={discussion.id()} role="article" aria-setsize="-1" aria-posinset={pageNum * pageSize + itemNum + 1}>
<DiscussionListItem discussion={discussion} params={params} />
</li>
));

View File

@@ -44,7 +44,9 @@ export default abstract class HeaderDropdown<CustomAttrs extends IHeaderDropdown
return [
this.attrs.icon ? <Icon name={this.attrs.icon} className="Button-icon" /> : null,
unread !== 0 && <span className="Bubble HeaderDropdownBubble">{unread}</span>,
<span className="Button-label">{this.attrs.label}</span>,
<span className="Button-label">
<span className="Button-labelText">{this.attrs.label}</span>
</span>,
];
}

View File

@@ -63,15 +63,29 @@ export default class IndexPage<CustomAttrs extends IIndexPageAttrs = IIndexPageA
view() {
return (
<PageStructure className="IndexPage" hero={this.hero.bind(this)} sidebar={this.sidebar.bind(this)}>
<div className="IndexPage-toolbar">
<ul className="IndexPage-toolbar-view">{listItems(this.viewItems().toArray())}</ul>
<ul className="IndexPage-toolbar-action">{listItems(this.actionItems().toArray())}</ul>
</div>
<DiscussionList state={app.discussions} />
{this.contentItems().toArray()}
</PageStructure>
);
}
contentItems(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
items.add('toolbar', <div className="IndexPage-toolbar">{this.toolbarItems().toArray()}</div>, 100);
items.add('discussionList', <DiscussionList state={app.discussions} />, 90);
return items;
}
toolbarItems(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
items.add('view', <ul className="IndexPage-toolbar-view">{listItems(this.viewItems().toArray())}</ul>, 100);
items.add('action', <ul className="IndexPage-toolbar-action">{listItems(this.actionItems().toArray())}</ul>, 90);
return items;
}
setTitle() {
app.setTitle(extractText(app.translator.trans('core.forum.index.meta_title_text')));
app.setTitleCount(0);

View File

@@ -45,7 +45,7 @@ export default class PostList<CustomAttrs extends IPostListAttrs = IPostListAttr
<ul role="feed" aria-busy={isLoading} className="PostList-discussions">
{state.getPages().map((pg, pageNum) => {
return pg.items.map((post, itemNum) => (
<li key={post.id()} data-id={post.id()} role="article" aria-setsize="-1" aria-posinset={pageNum * pageSize + itemNum}>
<li key={post.id()} data-id={post.id()} role="article" aria-setsize="-1" aria-posinset={pageNum * pageSize + itemNum + 1}>
<PostListItem post={post} params={params} />
</li>
));

View File

@@ -2,12 +2,17 @@ import app from '../../forum/app';
import Component, { type ComponentAttrs } from '../../common/Component';
import humanTime from '../../common/helpers/humanTime';
import fullTime from '../../common/helpers/fullTime';
import IPAddress from '../../common/components/IPAddress';
import Post from '../../common/models/Post';
import type Model from '../../common/Model';
import type User from '../../common/models/User';
import classList from '../../common/utils/classList';
import ItemList from '../../common/utils/ItemList';
import type Mithril from 'mithril';
type ModelType = Post | (Model & { user: () => User | null | false; createdAt: () => Date });
type ModelType =
| Post
| (Model & { user: () => User | null | false; createdAt: () => Date; ipAddress: undefined | (() => string | null | undefined) });
export interface IPostMetaAttrs extends ComponentAttrs {
/** Can be a post or similar model like private message */
@@ -22,47 +27,69 @@ export interface IPostMetaAttrs extends ComponentAttrs {
*/
export default class PostMeta<CustomAttrs extends IPostMetaAttrs = IPostMetaAttrs> extends Component<CustomAttrs> {
view() {
return <div className="Dropdown PostMeta">{this.viewItems().toArray()}</div>;
}
viewItems(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
const post = this.attrs.post;
const permalink = this.getPermalink(post);
const time = post.createdAt();
items.add(
'time',
<button
className={classList({
'Button Button--text': true,
'Dropdown-toggle Button--link': !!permalink,
})}
onclick={permalink ? this.selectPermalink.bind(this) : undefined}
data-toggle="dropdown"
>
{humanTime(time)}
</button>,
100
);
items.add('meta-dropdown', !!permalink && <div className="Dropdown-menu dropdown-menu">{this.metaItems().toArray()}</div>, 90);
return items;
}
metaItems(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
const post = this.attrs.post;
const time = post.createdAt();
const permalink = this.getPermalink(post);
const touch = 'ontouchstart' in document.documentElement;
// When the dropdown menu is shown, select the contents of the permalink
// input so that the user can quickly copy the URL.
const selectPermalink = function (this: Element, e: MouseEvent) {
setTimeout(() => $(this).parent().find('.PostMeta-permalink').select());
items.add('post-number', <span className="PostMeta-number">{this.postIdentifier(post)}</span>, 100);
e.redraw = false;
};
items.add('post-time', <span className="PostMeta-time">{fullTime(time)}</span>, 90);
return (
<div className="Dropdown PostMeta">
<button
className={classList({
'Button Button--text': true,
'Dropdown-toggle Button--link': !!permalink,
})}
onclick={permalink ? selectPermalink : undefined}
data-toggle="dropdown"
>
{humanTime(time)}
</button>
{!!permalink && (
<div className="Dropdown-menu dropdown-menu">
<span className="PostMeta-number">{this.postIdentifier(post)}</span> <span className="PostMeta-time">{fullTime(time)}</span>{' '}
<span className="PostMeta-ip">{post.data.attributes!.ipAddress}</span>
{touch ? (
<a className="Button PostMeta-permalink" href={permalink}>
{permalink}
</a>
) : (
<input className="FormControl PostMeta-permalink" value={permalink} onclick={(e: MouseEvent) => e.stopPropagation()} />
)}
</div>
)}
</div>
items.add(
'post-ip',
<span className="PostMeta-ip">
<IPAddress ip={post.ipAddress?.()} />
</span>,
80
);
items.add(
'permalink',
touch ? (
<a className="Button PostMeta-permalink" href={permalink}>
{permalink}
</a>
) : (
<input className="FormControl PostMeta-permalink" value={permalink} onclick={(e: MouseEvent) => e.stopPropagation()} />
),
0
);
return items;
}
/**
@@ -76,6 +103,15 @@ export default class PostMeta<CustomAttrs extends IPostMetaAttrs = IPostMetaAttr
return this.attrs.permalink?.() || null;
}
/**
* Selects the permalink input when the dropdown is shown.
*/
selectPermalink(e: MouseEvent) {
const $button = $(e.currentTarget as HTMLElement);
setTimeout(() => $button.parent().find('.PostMeta-permalink').select());
e.redraw = false;
}
postIdentifier(post: ModelType): string | null {
if (post instanceof Post) {
return app.translator.trans('core.forum.post.number_tooltip', { number: post.number() }, true);

View File

@@ -17,16 +17,28 @@ export default class PostPreview extends Component {
view() {
const post = this.attrs.post;
const user = post.user();
const content = post.contentType() === 'comment' && post.contentPlain();
const excerpt = content ? highlight(content, this.attrs.highlight, 300) : '';
return (
<Link className="PostPreview" href={app.route.post(post)} onclick={this.attrs.onclick}>
<span className="PostPreview-content">
<Avatar user={user} />
{username(user)} <span className="PostPreview-excerpt">{excerpt}</span>
{username(user)} <span className="PostPreview-excerpt">{this.excerpt()}</span>
</span>
</Link>
);
}
/**
* @returns {string|undefined|null}
*/
content() {
return this.attrs.post.contentType() === 'comment' && this.attrs.post.contentPlain();
}
/**
* @returns {string}
*/
excerpt() {
return this.content() ? highlight(this.content(), this.attrs.highlight, 300) : '';
}
}

View File

@@ -78,11 +78,24 @@ export default class PostStream extends Component {
content = <LoadingPost />;
}
return (
const postStreamElement = (
<div className="PostStream-item" {...attrs}>
{content}
</div>
);
const afterPostItems = post && post.id() === this.discussion.data.relationships.firstPost?.data.id ? this.afterFirstPostItems().toArray() : [];
if (afterPostItems.length > 0) {
return m.fragment({ ...attrs }, [
postStreamElement,
<div className="PostStream-item PostStream-afterFirstPost" key="afterFirstPost">
{afterPostItems}
</div>,
]);
}
return postStreamElement;
});
if (!viewingEnd && posts[this.stream.visibleEnd - this.stream.visibleStart - 1]) {
@@ -117,6 +130,15 @@ export default class PostStream extends Component {
);
}
/**
* @returns {ItemList<import('mithril').Children>}
*/
afterFirstPostItems() {
const items = new ItemList();
return items;
}
/**
* @returns {ItemList<import('mithril').Children>}
*/

View File

@@ -42,7 +42,7 @@ export default class PostStreamScrubber extends Component {
const newStyle = {
top: 100 - unreadPercent * 100 + '%',
height: unreadPercent * 100 + '%',
opacity: unreadPercent ? 1 : 0,
opacity: unreadPercent > 0 ? 1 : 0,
};
if (vnode.state.oldStyle) {
@@ -65,7 +65,7 @@ export default class PostStreamScrubber extends Component {
<div className="Dropdown-menu dropdown-menu">
<div className="Scrubber">
<Button className="Scrubber-first Button Button--link" onclick={this.goToFirst.bind(this)} icon="fas fa-angle-double-up">
{app.translator.trans('core.forum.post_scrubber.original_post_link')}
{this.firstPostLabel()}
</Button>
<div className="Scrubber-scrollbar">
@@ -80,12 +80,12 @@ export default class PostStreamScrubber extends Component {
<div className="Scrubber-after" />
<div className="Scrubber-unread" oncreate={styleUnread} onupdate={styleUnread}>
{app.translator.trans('core.forum.post_scrubber.unread_text', { count: unreadCount })}
{this.unreadLabel(unreadCount)}
</div>
</div>
<Button className="Scrubber-last Button Button--link" onclick={this.goToLast.bind(this)} icon="fas fa-angle-double-down">
{app.translator.trans('core.forum.post_scrubber.now_link')}
{this.lastPostLabel()}
</Button>
</div>
</div>
@@ -93,6 +93,18 @@ export default class PostStreamScrubber extends Component {
);
}
firstPostLabel() {
return app.translator.trans('core.forum.post_scrubber.original_post_link');
}
unreadLabel(unreadCount) {
return app.translator.trans('core.forum.post_scrubber.unread_text', { count: unreadCount });
}
lastPostLabel() {
return app.translator.trans('core.forum.post_scrubber.now_link');
}
onupdate(vnode) {
super.onupdate(vnode);

View File

@@ -33,7 +33,13 @@ export default class SessionDropdown<CustomAttrs extends ISessionDropdownAttrs =
getButtonContent() {
const user = app.session.user;
return [<Avatar user={user} />, ' ', <span className="Button-label">{username(user)}</span>];
return [
<Avatar user={user} />,
' ',
<span className="Button-label">
<span className="Button-labelText">{username(user)}</span>
</span>,
];
}
/**

View File

@@ -26,20 +26,43 @@ export default class WelcomeHero extends Component<IWelcomeHeroAttrs> {
return (
<header className="Hero WelcomeHero">
<div className="container">
<Button
icon="fas fa-times"
onclick={slideUp}
className="Hero-close Button Button--icon Button--link"
aria-label={app.translator.trans('core.forum.welcome_hero.hide')}
/>
<div className="containerNarrow">{this.welcomeItems().toArray()}</div>
</div>
<div className="container">{this.viewItems().toArray()}</div>
</header>
);
}
viewItems(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
const slideUp = () => {
this.$().slideUp(this.hide.bind(this));
};
items.add(
'dismiss-button',
<Button
icon="fas fa-times"
onclick={slideUp}
className="Hero-close Button Button--icon Button--link"
aria-label={app.translator.trans('core.forum.welcome_hero.hide')}
/>,
100
);
items.add('content', <div className="containerNarrow">{this.contentItems().toArray()}</div>, 80);
return items;
}
contentItems(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
items.add('title', <h1 className="Hero-title">{app.forum.attribute('welcomeTitle')}</h1>, 100);
items.add('subtitle', <div className="Hero-subtitle">{m.trust(app.forum.attribute('welcomeMessage'))}</div>);
return items;
}
/**
* Hide the welcome hero.
*/
@@ -58,13 +81,4 @@ export default class WelcomeHero extends Component<IWelcomeHeroAttrs> {
return false;
}
welcomeItems(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
items.add('hero-title', <h1 className="Hero-title">{app.forum.attribute('welcomeTitle')}</h1>, 20);
items.add('hero-subtitle', <div className="Hero-subtitle">{m.trust(app.forum.attribute('welcomeMessage'))}</div>, 10);
return items;
}
}

View File

@@ -20,8 +20,9 @@ const PostControls = {
controls(post, context) {
const items = new ItemList();
['user', 'moderation', 'destructive'].forEach((section) => {
const controls = this[section + 'Controls'](post, context).toArray();
Object.entries(this.sections()).forEach(([section, method]) => {
const controls = method.call(this, post, context).toArray();
if (controls.length) {
controls.forEach((item) => items.add(item.itemName, item));
items.add(section + 'Separator', <Separator />);
@@ -31,6 +32,14 @@ const PostControls = {
return items;
},
sections() {
return {
user: this.userControls,
moderation: this.moderationControls,
destructive: this.destructiveControls,
};
},
/**
* Get controls for a post pertaining to the current user (e.g. report).
*

View File

@@ -1,5 +1,6 @@
@import "common/common";
@import "admin/AdminPage";
@import "admin/AdminHeader";
@import "admin/AdminNav";
@import "admin/AdvancedPage";

View File

@@ -0,0 +1,3 @@
.AdminPage {
padding-bottom: var(--page-bottom-padding);
}

View File

@@ -94,23 +94,19 @@
}
}
.Dropdown {
display: block;
.Dropdown-toggle {
width: 100%;
display: block;
text-align: left;
float: none;
margin: -2px 0;
}
.Dropdown-menu {
margin: 0;
margin: 6px 0 0;
}
}
.Button {
text-decoration: none;
.Badge {
margin: -3px 2px -3px 0;
margin: 0 2px 0 0;
}
}
td:not(:hover) .Select-caret,
@@ -126,12 +122,8 @@
margin: -1px 0;
}
.PermissionDropdown {
.Dropdown-toggle {
padding: 5px 0;
margin: -5px 0;
}
.Badge {
margin: -3px 3px -3px 0;
margin: 0 3px 0 0;
box-shadow: none;
}
}

View File

@@ -57,7 +57,7 @@
display: flex;
align-items: center;
&[data-column-name="editUser"] {
&[data-column-name="userActions"] {
padding: 0;
position: relative;
}
@@ -81,16 +81,20 @@
text-decoration-style: dotted;
}
&-editModalBtn {
&-userActions {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
&-userActionsBtn {
width: 100%;
height: 100%;
border-radius: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
&-email {

View File

@@ -1,7 +1,7 @@
.App {
position: relative !important;
padding-top: var(--header-height);
padding-bottom: 50px;
padding-bottom: var(--app-bottom-padding);
min-height: 100vh;
@media @phone {
@@ -211,7 +211,7 @@
}
.FormControl, .ButtonGroup, .Button {
width: 100%;
text-align: left;
justify-content: start;
}
.Dropdown-menu {
.ButtonGroup, .Button {
@@ -325,7 +325,6 @@
.App-content {
background: var(--body-bg);
width: 100%;
min-height: 100vh;
padding-bottom: 50px;
min-height: calc(~"100vh - var(--header-height-phone)");
}
}

View File

@@ -259,6 +259,12 @@
line-height: inherit;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
flex-direction: column;
}
.Button-helperText {
font-size: 0.73rem;
color: var(--muted-more-color);
}
.Button-icon {
line-height: inherit;

View File

@@ -27,8 +27,8 @@
> a, > button, > span {
padding: 8px 15px;
display: flex;
align-items: center;
gap: 9px;
align-items: center;
width: 100%;
color: var(--text-color);
border-radius: 0;
@@ -51,6 +51,10 @@
flex-shrink: 0;
}
&.hasSubContent {
align-items: flex-start;
}
&.disabled {
opacity: 0.4;
background: none !important;

View File

@@ -255,6 +255,8 @@
--zindex-alerts: @zindex-alerts;
--zindex-tooltip: @zindex-tooltip;
--page-bottom-padding: 100px;
// Store the current responsive screen mode in a CSS variable, to make it
// available to the JS code.
--flarum-screen: none;

View File

@@ -36,6 +36,10 @@
}
.DiscussionListItem-author-avatar {
display: block;
+ .tooltip {
width: max-content;
}
}
.DiscussionListItem-badges {
margin-top: 10px;

View File

@@ -21,6 +21,7 @@
--content-width: 100%;
--sidebar-width: 190px;
--gap: 50px;
padding-bottom: var(--page-bottom-padding);
&-container {
gap: var(--gap);

View File

@@ -429,7 +429,7 @@
cursor: text;
overflow: hidden;
margin-top: 50px;
margin-left: calc(0px - var(--post-padding));
margin-left: 0;
padding-left: var(--post-padding);
border: 2px dashed var(--control-bg);
color: var(--muted-color);
@@ -439,7 +439,7 @@
appearance: none;
-webkit-appearance: none;
text-align: left;
width: calc(100% + var(--post-padding));
width: 100%;
.Post-container {
display: grid;
@@ -469,6 +469,8 @@
.ReplyPlaceholder {
border-color: transparent;
transition: border-color 0.2s;
margin-left: calc(0px - var(--post-padding));
width: calc(100% + var(--post-padding));
.Post-header {
position: relative;

View File

@@ -50,12 +50,12 @@
text-transform: uppercase;
font-weight: bold;
color: var(--muted-color);
padding: 20px 20px 20px calc(~"var(--avatar-column-width) + 20px");
padding: 20px 20px 20px var(--avatar-column-width);
font-size: 12px;
@media @phone {
margin: 0 -15px;
padding: 20px 15px;
margin: 0;
padding: 20px 0;
}
}

View File

@@ -310,6 +310,7 @@ core:
participate_heading: Participate
post_without_throttle_label: Reply multiple times without waiting
read_heading: Read
remove_scope_label: Remove scope of {scope}
rename_discussions_label: Rename discussions
reply_to_discussions_label: Reply to discussions
search_users_label: => core.ref.search_users
@@ -359,11 +360,6 @@ core:
display_name:
title: Display name
edit_user:
button: => core.ref.edit
title: => core.ref.edit_user
tooltip: Edit {username}
email:
title: => core.ref.email
visibility_hide: Hide email address
@@ -376,6 +372,12 @@ core:
join_time:
title: Joined
user_actions:
title: Actions
edit_user:
button: => core.ref.edit
tooltip: Edit {username}
user_id:
title: ID

View File

@@ -33,7 +33,6 @@ use Tobyz\JsonApiServer\Pagination\Pagination;
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
use function Tobyz\JsonApiServer\json_api_response;
use function Tobyz\JsonApiServer\parse_sort_string;
class Index extends Endpoint
{
@@ -69,24 +68,18 @@ class Index extends Endpoint
protected function setUp(): void
{
$this->route('GET', '/')
->query(function ($query, ?Pagination $pagination, Context $context): Context {
->query(function ($query, ?Pagination $pagination, Context $context, array $filters, ?array $sort, int $offset, ?int $limit): Context {
$collection = $context->collection;
// This model has a searcher API, so we'll use that instead of the default.
// The searcher API allows swapping the default search engine for a custom one.
/** @var SearchManager $search */
$search = $context->api->getContainer()->make(SearchManager::class);
$modelClass = $collection instanceof AbstractDatabaseResource ? $collection->model() : null;
if ($query instanceof Builder && $search->searchable($modelClass)) {
$actor = $context->getActor();
$extracts = $this->defaultExtracts($context);
$filters = $this->extractFilterValue($context, $extracts);
$sort = $this->extractSortValue($context, $extracts);
$limit = $this->extractLimitValue($context, $extracts);
$offset = $this->extractOffsetValue($context, $extracts);
$sortIsDefault = ! $context->queryParam('sort');
$results = $search->query(
@@ -100,8 +93,8 @@ class Index extends Endpoint
else {
$context = $context->withQuery($query);
$this->applySorts($query, $context);
$this->applyFilters($query, $context);
$this->applySorts($query, $context, $sort);
$this->applyFilters($query, $context, $filters);
if ($pagination && method_exists($pagination, 'apply')) {
$pagination->apply($query);
@@ -129,8 +122,20 @@ class Index extends Endpoint
$pagination = ($this->paginationResolver)($context);
$extracts = $this->defaultExtracts($context);
$filters = $this->extractFilterValue($context, $extracts);
$sort = $this->extractSortValue($context, $extracts);
$limit = $this->extractLimitValue($context, $extracts);
$offset = $this->extractOffsetValue($context, $extracts);
if ($pagination instanceof OffsetPagination) {
$pagination->offset = $offset;
$pagination->limit = $limit;
}
if ($this->query) {
$context = ($this->query)($query, $pagination, $context);
$context = ($this->query)($query, $pagination, $context, $filters, $sort, $offset, $limit);
if (! $context instanceof Context) {
throw new RuntimeException('The Index endpoint query closure must return a Context instance.');
@@ -139,8 +144,8 @@ class Index extends Endpoint
/** @var Context $context */
$context = $context->withQuery($query);
$this->applySorts($query, $context);
$this->applyFilters($query, $context);
$this->applySorts($query, $context, $sort);
$this->applyFilters($query, $context, $filters);
if ($pagination) {
$pagination->apply($query);
@@ -205,9 +210,9 @@ class Index extends Endpoint
return $this;
}
final protected function applySorts($query, Context $context): void
final protected function applySorts($query, Context $context, ?array $sort): void
{
if (! ($sortString = $context->queryParam('sort', $this->defaultSort))) {
if (! $sort) {
return;
}
@@ -219,7 +224,7 @@ class Index extends Endpoint
$sorts = $collection->resolveSorts();
foreach (parse_sort_string($sortString) as [$name, $direction]) {
foreach ($sort as $name => $direction) {
foreach ($sorts as $field) {
if ($field->name === $name && $field->isVisible($context)) {
$field->apply($query, $direction, $context);
@@ -233,18 +238,12 @@ class Index extends Endpoint
}
}
final protected function applyFilters($query, Context $context): void
final protected function applyFilters($query, Context $context, array $filters): void
{
if (! ($filters = $context->queryParam('filter'))) {
if (empty($filters)) {
return;
}
if (! is_array($filters)) {
throw (new BadRequestException('filter must be an array'))->setSource([
'parameter' => 'filter',
]);
}
$collection = $context->collection;
if (! $collection instanceof Listable) {

View File

@@ -129,16 +129,16 @@ class PostResource extends AbstractDatabaseResource
$sort = $defaultExtracts['sort'];
$filter = $defaultExtracts['filter'];
if (count($filter) > 1 || ! isset($filter['discussion']) || $sort) {
if (count($filter) > 1 || ! isset($filter['discussion']) || ($sort && $sort !== ['number' => 'asc'])) {
throw new BadRequestException(
'You can only use page[near] with filter[discussion] and the default sort order'
);
}
$limit = $defaultExtracts['limit'];
$offset = $this->posts->getIndexForNumber((int) $filter['discussion'], $near, $context->getActor());
$index = $this->posts->getIndexForNumber((int) $filter['discussion'], $near, $context->getActor());
return max(0, $offset - $limit / 2);
return max(0, $index - $limit / 2);
}
return $defaultExtracts['offset'];
@@ -150,6 +150,7 @@ class PostResource extends AbstractDatabaseResource
'hiddenUser',
'discussion'
])
->defaultSort('number')
->paginate(static::$defaultLimit),
];
}

View File

@@ -35,6 +35,6 @@ class SendmailDriver implements DriverInterface
public function buildTransport(SettingsRepositoryInterface $settings): TransportInterface
{
return (new SendmailTransportFactory())->create(new Dsn('', 'sendmail'));
return (new SendmailTransportFactory())->create(new Dsn('sendmail', 'default'));
}
}

View File

@@ -85,23 +85,14 @@ class PostRepository
*/
public function getIndexForNumber(int $discussionId, int $number, ?User $actor = null): int
{
if (! ($discussion = Discussion::find($discussionId))) {
if (! ($discussion = Discussion::query()->find($discussionId))) {
return 0;
}
$query = $discussion->posts()
->whereVisibleTo($actor)
->where('created_at', '<', function ($query) use ($discussionId, $number) {
$query->select('created_at')
->from('posts')
->where('discussion_id', $discussionId)
->whereNotNull('number')
->take(1)
// We don't add $number as a binding because for some
// reason doing so makes the bindings go out of order.
->orderByRaw('ABS(CAST(number AS SIGNED) - '.(int) $number.')');
});
->where('number', '<=', $number)
->orderBy('number');
return $query->count();
}

View File

@@ -40,8 +40,10 @@ class ListTest extends TestCase
$this->request('GET', '/api/groups')
);
$this->assertEquals(200, $response->getStatusCode());
$data = json_decode($response->getBody()->getContents(), true);
$body = $response->getBody()->getContents();
$this->assertEquals(200, $response->getStatusCode(), $body);
$data = json_decode($body, true);
// The four default groups created by the installer
$this->assertEquals(['1', '2', '3', '4'], Arr::pluck($data['data'], 'id'));