Compare commits

...

12 Commits

Author SHA1 Message Date
Eugen Rochko
7c335688c9
Merge 9b56d00b5c into c442589593 2025-07-10 08:06:39 +00:00
Matt Jankowski
c442589593
Use ActiveModel::Attributes in FollowLimitable concern (#35327)
Some checks failed
Chromatic / Run Chromatic (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Bundler Audit / security (push) Has been cancelled
CSS Linting / lint (push) Has been cancelled
Haml Linting / lint (push) Has been cancelled
2025-07-10 07:40:56 +00:00
renovate[bot]
28633a504a
chore(deps): update dependency json-schema to v5.2.1 (#35337)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-10 07:38:07 +00:00
Matt Jankowski
ad78701b6f
Mark private methods in AnnualReport::TopStatuses (#35256) 2025-07-10 07:35:40 +00:00
Matt Jankowski
1496488771
Add Status#not_replying_to_account scope for annual report classes (#35257) 2025-07-10 07:35:04 +00:00
renovate[bot]
dd3d958e75
fix(deps): update dependency core-js to v3.44.0 (#35284)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-10 07:23:54 +00:00
github-actions[bot]
b363a3651d
New Crowdin Translations (automated) (#35335)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-10 07:23:27 +00:00
renovate[bot]
86645fc14c
chore(deps): update dependency rubocop to v1.78.0 (#35289)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-10 07:23:23 +00:00
Matt Jankowski
f9beecb343
Improve Accounts CLI prune spec (#35302) 2025-07-10 07:23:09 +00:00
Matt Jankowski
4ecfbd3920
Add Status.only_polls (and without polls) scope (#35330) 2025-07-10 07:13:22 +00:00
Claire
a315934314
Fix styling of external log-in button (#35320) 2025-07-10 06:56:40 +00:00
Eugen Rochko
9b56d00b5c WIP: Add starter packs 2025-06-03 19:30:20 +02:00
67 changed files with 974 additions and 150 deletions

View File

@ -365,7 +365,7 @@ GEM
json-ld-preloaded (3.3.1)
json-ld (~> 3.3)
rdf (~> 3.3)
json-schema (5.1.1)
json-schema (5.2.1)
addressable (~> 2.8)
bigdecimal (~> 3.1)
jsonapi-renderer (0.2.2)
@ -761,7 +761,7 @@ GEM
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9)
rspec-support (3.13.4)
rubocop (1.77.0)
rubocop (1.78.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)

View File

@ -1,25 +1,30 @@
# frozen_string_literal: true
class Api::V1::Lists::AccountsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show]
include Authorization
before_action -> { authorize_if_got_token! :read, :'read:lists' }, only: [:show]
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show]
before_action :require_user!
before_action :require_user!, except: [:show]
before_action :set_list
after_action :insert_pagination_headers, only: :show
def show
authorize @list, :show?
@accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer
end
def create
authorize @list, :update?
AddAccountsToListService.new.call(@list, Account.find(account_ids))
render_empty
end
def destroy
authorize @list, :update?
RemoveAccountsFromListService.new.call(@list, Account.where(id: account_ids))
render_empty
end
@ -27,7 +32,7 @@ class Api::V1::Lists::AccountsController < Api::BaseController
private
def set_list
@list = List.where(account: current_account).find(params[:list_id])
@list = List.find(params[:list_id])
end
def load_accounts

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class Api::V1::Lists::FollowsController < Api::BaseController
before_action -> { doorkeeper_authorize! :follow, :write, :'write:follows' }
before_action :require_user!
before_action :set_list
def create
FollowFromPublicListWorker.perform_async(current_account.id, @list.id)
render json: {}, status: 202
end
private
def set_list
@list = List.where(type: :public_list).find(params[:list_id])
end
end

View File

@ -1,10 +1,13 @@
# frozen_string_literal: true
class Api::V1::ListsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index, :show]
include Authorization
before_action -> { authorize_if_got_token! :read, :'read:lists' }, only: [:show]
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index]
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show]
before_action :require_user!
before_action :require_user!, except: [:show]
before_action :set_list, except: [:index, :create]
def index
@ -13,6 +16,7 @@ class Api::V1::ListsController < Api::BaseController
end
def show
authorize @list, :show?
render json: @list, serializer: REST::ListSerializer
end
@ -22,11 +26,13 @@ class Api::V1::ListsController < Api::BaseController
end
def update
authorize @list, :update?
@list.update!(list_params)
render json: @list, serializer: REST::ListSerializer
end
def destroy
authorize @list, :destroy?
@list.destroy!
render_empty
end
@ -34,10 +40,10 @@ class Api::V1::ListsController < Api::BaseController
private
def set_list
@list = List.where(account: current_account).find(params[:id])
@list = List.find(params[:id])
end
def list_params
params.permit(:title, :replies_policy, :exclusive)
params.permit(:title, :description, :type, :replies_policy, :exclusive)
end
end

View File

@ -1,23 +1,25 @@
# frozen_string_literal: true
class Api::V1::Timelines::ListController < Api::V1::Timelines::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:lists' }
before_action :require_user!
include Authorization
before_action -> { authorize_if_got_token! :read, :'read:lists' }
before_action :set_list
before_action :set_statuses
PERMITTED_PARAMS = %i(limit).freeze
def show
authorize @list, :show?
render json: @statuses,
each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id)
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
end
private
def set_list
@list = List.where(account: current_account).find(params[:id])
@list = List.find(params[:id])
end
def set_statuses

View File

@ -7,6 +7,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
layout :determine_layout
before_action :set_invite, only: [:new, :create]
before_action :set_list, only: [:new, :create]
before_action :check_enabled_registrations, only: [:new, :create]
before_action :configure_sign_up_params, only: [:create]
before_action :set_sessions, only: [:edit, :update]
@ -109,6 +110,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end
end
def set_list
@list = List.where(type: :public_list).find_by(id: params[:list_id])
end
def determine_layout
%w(edit update).include?(action_name) ? 'admin' : 'auth'
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class ListsController < ApplicationController
include WebAppControllerConcern
before_action :set_list
def show; end
private
def set_list
@list = List.where(type: :public_list).find(params[:id])
end
end

View File

@ -66,7 +66,7 @@ module ApplicationHelper
def provider_sign_in_link(provider)
label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize)
link_to label, omniauth_authorize_path(:user, provider), class: "button button-#{provider}", method: :post
link_to label, omniauth_authorize_path(:user, provider), class: "btn button-#{provider}", method: :post
end
def locale_direction

View File

@ -32,3 +32,6 @@ export const apiRemoveAccountFromList = (listId: string, accountId: string) =>
apiRequestDelete(`v1/lists/${listId}/accounts`, {
account_ids: [accountId],
});
export const apiFollowList = (listId: string) =>
apiRequestPost(`v1/lists/${listId}/follow`);

View File

@ -1,10 +1,21 @@
// See app/serializers/rest/list_serializer.rb
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
export type RepliesPolicyType = 'list' | 'followed' | 'none';
export type ListType = 'private_list' | 'public_list';
export interface ApiListJSON {
id: string;
url?: string;
title: string;
slug?: string;
type: ListType;
description: string;
created_at: string;
updated_at: string;
exclusive: boolean;
replies_policy: RepliesPolicyType;
account?: ApiAccountJSON;
}

View File

@ -19,7 +19,7 @@ const messages = defineMessages({
export const CopyIconButton: React.FC<{
title: string;
value: string;
className: string;
className?: string;
}> = ({ title, value, className }) => {
const [copied, setCopied] = useState(false);
const dispatch = useAppDispatch();

View File

@ -1,25 +0,0 @@
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { Avatar } from 'mastodon/components/avatar';
import { useAppSelector } from 'mastodon/store';
export const AuthorLink = ({ accountId }) => {
const account = useAppSelector(state => state.getIn(['accounts', accountId]));
if (!account) {
return null;
}
return (
<Link to={`/@${account.get('acct')}`} className='story__details__shared__author-link' data-hover-card-account={accountId}>
<Avatar account={account} size={16} />
<bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
</Link>
);
};
AuthorLink.propTypes = {
accountId: PropTypes.string.isRequired,
};

View File

@ -0,0 +1,25 @@
import { Link } from 'react-router-dom';
import { Avatar } from 'mastodon/components/avatar';
import { useAppSelector } from 'mastodon/store';
export const AuthorLink: React.FC<{
accountId: string;
}> = ({ accountId }) => {
const account = useAppSelector((state) => state.accounts.get(accountId));
if (!account) {
return null;
}
return (
<Link
to={`/@${account.acct}`}
className='story__details__shared__author-link'
data-hover-card-account={accountId}
>
<Avatar account={account} size={16} />
<bdi dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
</Link>
);
};

View File

@ -8,6 +8,7 @@ import { Link } from 'react-router-dom';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import PackageIcon from '@/material-icons/400-24px/package_2.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchLists } from 'mastodon/actions/lists';
import { openModal } from 'mastodon/actions/modal';
@ -16,6 +17,8 @@ import { ColumnHeader } from 'mastodon/components/column_header';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import type { MenuItem } from 'mastodon/models/dropdown_menu';
import type { List } from 'mastodon/models/list';
import { getOrderedLists } from 'mastodon/selectors/lists';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -25,12 +28,12 @@ const messages = defineMessages({
edit: { id: 'lists.edit', defaultMessage: 'Edit list' },
delete: { id: 'lists.delete', defaultMessage: 'Delete list' },
more: { id: 'status.more', defaultMessage: 'More' },
copyLink: { id: '', defaultMessage: 'Copy link' },
});
const ListItem: React.FC<{
id: string;
title: string;
}> = ({ id, title }) => {
list: List;
}> = ({ list }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
@ -39,25 +42,54 @@ const ListItem: React.FC<{
openModal({
modalType: 'CONFIRM_DELETE_LIST',
modalProps: {
listId: id,
listId: list.id,
},
}),
);
}, [dispatch, id]);
}, [dispatch, list]);
const menu = useMemo(
() => [
{ text: intl.formatMessage(messages.edit), to: `/lists/${id}/edit` },
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
],
[intl, id, handleDeleteClick],
);
const handleCopyClick = useCallback(() => {
void navigator.clipboard.writeText(list.url);
}, [list]);
const menu = useMemo(() => {
const tmp: MenuItem[] = [
{ text: intl.formatMessage(messages.edit), to: `/lists/${list.id}/edit` },
{
text: intl.formatMessage(messages.delete),
action: handleDeleteClick,
dangerous: true,
},
];
if (list.type === 'public_list') {
tmp.unshift(
{
text: intl.formatMessage(messages.copyLink),
action: handleCopyClick,
},
null,
);
}
return tmp;
}, [intl, list, handleDeleteClick, handleCopyClick]);
return (
<div className='lists__item'>
<Link to={`/lists/${id}`} className='lists__item__title'>
<Icon id='list-ul' icon={ListAltIcon} />
<span>{title}</span>
<Link
to={
list.type === 'public_list'
? `/starter-pack/${list.id}-${list.slug}`
: `/lists/${list.id}`
}
className='lists__item__title'
>
<Icon
id={list.type === 'public_list' ? 'package' : 'list-ul'}
icon={list.type === 'public_list' ? PackageIcon : ListAltIcon}
/>
<span>{list.title}</span>
</Link>
<Dropdown
@ -128,7 +160,7 @@ const Lists: React.FC<{
bindToDocument={!multiColumn}
>
{lists.map((list) => (
<ListItem key={list.id} id={list.id} title={list.title} />
<ListItem key={list.id} list={list} />
))}
</ScrollableList>

View File

@ -161,6 +161,7 @@ const ListMembers: React.FC<{
const { id } = useParams<{ id: string }>();
const intl = useIntl();
const list = useAppSelector((state) => state.lists.get(id));
const [searching, setSearching] = useState(false);
const [accountIds, setAccountIds] = useState<string[]>([]);
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
@ -288,7 +289,14 @@ const ListMembers: React.FC<{
{displayedAccountIds.length > 0 && <div className='spacer' />}
<div className='column-footer'>
<Link to={`/lists/${id}`} className='button button--block'>
<Link
to={
list?.type === 'public_list'
? `/starter-pack/${id}-${list.slug}`
: `/lists/${id}`
}
className='button button--block'
>
<FormattedMessage id='lists.done' defaultMessage='Done' />
</Link>
</div>

View File

@ -84,7 +84,9 @@ const NewList: React.FC<{
id ? state.lists.get(id) : undefined,
);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [exclusive, setExclusive] = useState(false);
const [isPublic, setIsPublic] = useState(false);
const [repliesPolicy, setRepliesPolicy] = useState<RepliesPolicyType>('list');
const [submitting, setSubmitting] = useState(false);
@ -109,6 +111,13 @@ const NewList: React.FC<{
[setTitle],
);
const handleDescriptionChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLTextAreaElement>) => {
setDescription(value);
},
[setDescription],
);
const handleExclusiveChange = useCallback(
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
setExclusive(checked);
@ -116,6 +125,13 @@ const NewList: React.FC<{
[setExclusive],
);
const handleIsPublicChange = useCallback(
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
setIsPublic(checked);
},
[setIsPublic],
);
const handleRepliesPolicyChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLSelectElement>) => {
setRepliesPolicy(value as RepliesPolicyType);
@ -131,8 +147,10 @@ const NewList: React.FC<{
updateList({
id,
title,
description,
exclusive,
replies_policy: repliesPolicy,
type: isPublic ? 'public_list' : 'private_list',
}),
).then(() => {
setSubmitting(false);
@ -142,8 +160,10 @@ const NewList: React.FC<{
void dispatch(
createList({
title,
description,
exclusive,
replies_policy: repliesPolicy,
type: isPublic ? 'public_list' : 'private_list',
}),
).then((result) => {
setSubmitting(false);
@ -156,7 +176,17 @@ const NewList: React.FC<{
return '';
});
}
}, [history, dispatch, setSubmitting, id, title, exclusive, repliesPolicy]);
}, [
history,
dispatch,
setSubmitting,
id,
title,
description,
exclusive,
isPublic,
repliesPolicy,
]);
return (
<Column
@ -198,6 +228,28 @@ const NewList: React.FC<{
</div>
</div>
<div className='fields-group'>
<div className='input with_label'>
<div className='label_input'>
<label htmlFor='list_title'>
<FormattedMessage
id='lists.list_description'
defaultMessage='Description'
/>
</label>
<div className='label_input__wrapper'>
<textarea
id='list_description'
value={description}
onChange={handleDescriptionChange}
maxLength={120}
/>
</div>
</div>
</div>
</div>
<div className='fields-group'>
<div className='input with_label'>
<div className='label_input'>
@ -244,6 +296,32 @@ const NewList: React.FC<{
</div>
)}
<div className='fields-group'>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label className='app-form__toggle'>
<div className='app-form__toggle__label'>
<strong>
<FormattedMessage
id='lists.make_public'
defaultMessage='Make public'
/>
</strong>
<span className='hint'>
<FormattedMessage
id='lists.make_public_hint'
defaultMessage='When you make a list public, anyone with a link can see it.'
/>
</span>
</div>
<div className='app-form__toggle__toggle'>
<div>
<Toggle checked={isPublic} onChange={handleIsPublicChange} />
</div>
</div>
</label>
</div>
<div className='fields-group'>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label className='app-form__toggle'>

View File

@ -0,0 +1,120 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { openModal } from 'mastodon/actions/modal';
import { apiFollowList } from 'mastodon/api/lists';
import { Button } from 'mastodon/components/button';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { AuthorLink } from 'mastodon/features/explore/components/author_link';
import { useIdentity } from 'mastodon/identity_context';
import { registrationsOpen, sso_redirect, me } from 'mastodon/initial_state';
import type { List } from 'mastodon/models/list';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
export const Hero: React.FC<{
list: List;
}> = ({ list }) => {
const { signedIn } = useIdentity();
const dispatch = useAppDispatch();
const signupUrl = useAppSelector(
(state) =>
state.server.getIn(['server', 'registrations', 'url'], null) ??
'/auth/sign_up',
) as string;
const handleClosedRegistrationsClick = useCallback(() => {
dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS', modalProps: {} }));
}, [dispatch]);
const handleFollowAll = useCallback(() => {
apiFollowList(list.id)
.then(() => {
// TODO
return '';
})
.catch(() => {
// TODO
});
}, [list]);
let signUpButton;
if (sso_redirect) {
signUpButton = (
<a href={sso_redirect} data-method='post' className='button'>
<FormattedMessage id='' defaultMessage='Create account' />
</a>
);
} else if (registrationsOpen) {
signUpButton = (
<a href={`${signupUrl}?list_id=${list.id}`} className='button'>
<FormattedMessage id='' defaultMessage='Create account' />
</a>
);
} else {
signUpButton = (
<Button onClick={handleClosedRegistrationsClick}>
<FormattedMessage id='' defaultMessage='Create account' />
</Button>
);
}
return (
<div className='lists__hero'>
<div className='lists__hero__title'>
<h1>{list.title}</h1>
<p>
{list.description.length > 0 ? (
list.description
) : (
<FormattedMessage id='' defaultMessage='No description given.' />
)}
</p>
</div>
<div className='lists__hero__meta'>
<FormattedMessage
id=''
defaultMessage='Public list by {name}'
values={{
name: list.account_id && <AuthorLink accountId={list.account_id} />,
}}
>
{(chunks) => (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>{chunks}</>
)}
</FormattedMessage>
<span aria-hidden>{' · '}</span>
<FormattedMessage
id=''
defaultMessage='Created {timeAgo}'
values={{
timeAgo: (
<RelativeTimestamp timestamp={list.created_at} short={false} />
),
}}
/>
</div>
<div className='lists__hero__actions'>
{!signedIn && signUpButton}
{me !== list.account_id && (
<Button onClick={handleFollowAll} secondary={!signedIn}>
<FormattedMessage id='' defaultMessage='Follow all' />
</Button>
)}
{me === list.account_id && (
<Link className='button' to={`/lists/${list.id}/edit`}>
<FormattedMessage id='' defaultMessage='Edit list' />
</Link>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,136 @@
import { useEffect, useCallback } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { Helmet } from 'react-helmet';
import { NavLink, useParams, Route, Switch } from 'react-router-dom';
import PackageIcon from '@/material-icons/400-24px/package_2.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import { fetchList } from 'mastodon/actions/lists';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { CopyIconButton } from 'mastodon/components/copy_icon_button';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import type { List } from 'mastodon/models/list';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { Hero } from './components/hero';
import { Members } from './members';
import { Statuses } from './statuses';
interface PublicListParams {
id: string;
slug?: string;
}
const messages = defineMessages({
copyLink: { id: '', defaultMessage: 'Copy link' },
shareLink: { id: '', defaultMessage: 'Share link' },
});
const CopyLinkButton: React.FC<{
list: List;
}> = ({ list }) => {
const intl = useIntl();
const handleClick = useCallback(() => {
void navigator.share({
url: list.url,
});
}, [list]);
if ('share' in navigator) {
return (
<button
className='column-header__button'
onClick={handleClick}
title={intl.formatMessage(messages.shareLink)}
aria-label={intl.formatMessage(messages.shareLink)}
>
<Icon id='' icon={ShareIcon} />
</button>
);
}
return (
<CopyIconButton
className='column-header__button'
title={intl.formatMessage(messages.copyLink)}
value={list.url}
/>
);
};
const PublicList: React.FC<{
multiColumn: boolean;
}> = ({ multiColumn }) => {
const { id } = useParams<PublicListParams>();
const dispatch = useAppDispatch();
const list = useAppSelector((state) => state.lists.get(id));
const accountId = list?.account_id;
const slug = list?.slug ? `${list.id}-${list.slug}` : list?.id;
useEffect(() => {
dispatch(fetchList(id));
}, [dispatch, id]);
if (typeof list === 'undefined') {
return (
<Column>
<LoadingIndicator />
</Column>
);
} else if (list === null || !accountId) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
return (
<Column>
<ColumnHeader
icon='package'
iconComponent={PackageIcon}
title={list.title}
multiColumn={multiColumn}
extraButton={<CopyLinkButton list={list} />}
/>
<Hero list={list} />
<div className='account__section-headline'>
<NavLink exact to={`/starter-pack/${slug}`}>
<FormattedMessage tagName='div' id='' defaultMessage='Members' />
</NavLink>
<NavLink exact to={`/starter-pack/${slug}/posts`}>
<FormattedMessage tagName='div' id='' defaultMessage='Posts' />
</NavLink>
</div>
<Switch>
<Route
path={['/starter-pack/:id(\\d+)', '/starter-pack/:id(\\d+)-:slug']}
exact
component={Members}
/>
<Route
path={[
'/starter-pack/:id(\\d+)/posts',
'/starter-pack/:id(\\d+)-:slug/posts',
]}
component={Statuses}
/>
</Switch>
<Helmet>
<title>{list.title}</title>
<meta name='robots' content='all' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default PublicList;

View File

@ -0,0 +1,48 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { importFetchedAccounts } from 'mastodon/actions/importer';
import { apiGetAccounts } from 'mastodon/api/lists';
import { Account } from 'mastodon/components/account';
import ScrollableList from 'mastodon/components/scrollable_list';
import { useAppDispatch } from 'mastodon/store';
export const Members: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const { id }: { id: string } = useParams();
const [accountIds, setAccountIds] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const dispatch = useAppDispatch();
useEffect(() => {
setLoading(true);
apiGetAccounts(id)
.then((data) => {
dispatch(importFetchedAccounts(data));
setAccountIds(data.map((a) => a.id));
setLoading(false);
return '';
})
.catch(() => {
setLoading(false);
});
}, [dispatch, id]);
return (
<ScrollableList
scrollKey={`public_list/${id}/members`}
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
isLoading={loading}
showLoading={loading && accountIds.length === 0}
hasMore={false}
>
{accountIds.map((accountId) => (
<Account key={accountId} id={accountId} withBio={false} />
))}
</ScrollableList>
);
};

View File

@ -0,0 +1,35 @@
import { useCallback, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { expandListTimeline } from 'mastodon/actions/timelines';
import StatusList from 'mastodon/features/ui/containers/status_list_container';
import { useAppDispatch } from 'mastodon/store';
export const Statuses: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const { id }: { id: string } = useParams();
const dispatch = useAppDispatch();
const handleLoadMore = useCallback(
(maxId: string) => {
void dispatch(expandListTimeline(id, { maxId }));
},
[dispatch, id],
);
useEffect(() => {
void dispatch(expandListTimeline(id));
}, [dispatch, id]);
return (
<StatusList
scrollKey={`public_list/${id}/statuses`}
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
timelineId={`list:${id}`}
onLoadMore={handleLoadMore}
/>
);
};

View File

@ -62,6 +62,7 @@ import {
Lists,
ListEdit,
ListMembers,
PublicList,
Blocks,
DomainBlocks,
Mutes,
@ -217,6 +218,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/lists/:id/edit' component={ListEdit} content={children} />
<WrappedRoute path='/lists/:id/members' component={ListMembers} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
<WrappedRoute path={['/starter-pack/:id(\\d+)', '/starter-pack/:id(\\d+)-:slug']} component={PublicList} content={children} />
<WrappedRoute path='/notifications' component={Notifications} content={children} exact />
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact />

View File

@ -38,6 +38,10 @@ export function ListTimeline () {
return import('../../list_timeline');
}
export function PublicList () {
return import(/* webpackChunkName: "features/public_list" */'../../public_list');
}
export function Lists () {
return import('../../lists');
}

View File

@ -386,7 +386,7 @@
"follow_suggestions.similar_to_recently_followed_longer": "Minder om profiler, du har fulgt for nylig",
"follow_suggestions.view_all": "Vis alle",
"follow_suggestions.who_to_follow": "Hvem, som skal følges",
"followed_tags": "Hashtag, som følges",
"followed_tags": "Hashtags, som følges",
"footer.about": "Om",
"footer.directory": "Profiloversigt",
"footer.get_app": "Hent appen",
@ -560,7 +560,7 @@
"navigation_bar.favourites": "Favoritter",
"navigation_bar.filters": "Skjulte ord",
"navigation_bar.follow_requests": "Følgeanmodninger",
"navigation_bar.followed_tags": "Hashtag, som følges",
"navigation_bar.followed_tags": "Hashtags, som følges",
"navigation_bar.follows_and_followers": "Følges og følgere",
"navigation_bar.import_export": "Import og eksport",
"navigation_bar.lists": "Lister",

View File

@ -569,6 +569,7 @@
"notification.admin.sign_up.name_and_others": "{name} eta {count, plural, one {erabiltzaile # gehiago} other {# erabiltzaile gehiago}} erregistratu dira",
"notification.favourite": "{name}(e)k zure bidalketa gogoko du",
"notification.favourite.name_and_others_with_link": "{name} eta <a>{count, plural, one {erabiltzaile # gehiagok} other {# erabiltzaile gehiagok}}</a> zure bidalketa gogoko dute",
"notification.favourite_pm": "{name}-ek zure aipamen pribatua gogokoetan jarri du",
"notification.follow": "{name}(e)k jarraitzen dizu",
"notification.follow_request": "{name}(e)k zu jarraitzeko eskaera egin du",
"notification.follow_request.name_and_others": "{name} eta {count, plural, one {erabiltzaile # gehiagok} other {# erabiltzaile gehiagok}} zu jarraitzeko eskaera egin dute",
@ -902,5 +903,7 @@
"video.hide": "Ezkutatu bideoa",
"video.pause": "Pausatu",
"video.play": "Jo",
"video.unmute": "Soinua ezarri",
"video.volume_down": "Bolumena jaitsi",
"video.volume_up": "Bolumena Igo"
}

View File

@ -356,6 +356,7 @@
"hashtag.counter_by_accounts": "{count, plural, one {{counter} partisipante} other {{counter} partisipantes}}",
"hashtag.counter_by_uses": "{count, plural, one {{counter} publikasyon} other {{counter} publikasyones}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} publikasyon} other {{counter} publikasyones}} oy",
"hashtag.feature": "Avalia en profil",
"hashtag.follow": "Sige etiketa",
"hashtag.mute": "Silensia #{hashtag}",
"hashtag.unfeature": "No avalia en profil",
@ -390,6 +391,7 @@
"interaction_modal.title.reblog": "Repartaja publikasyon de {name}",
"interaction_modal.title.reply": "Arisponde a publikasyon de {name}",
"interaction_modal.title.vote": "Vota en la anketa de {name}",
"interaction_modal.username_prompt": "Por enshemplo {example}",
"intervals.full.days": "{number, plural, one {# diya} other {# diyas}}",
"intervals.full.hours": "{number, plural, one {# ora} other {# oras}}",
"intervals.full.minutes": "{number, plural, one {# minuto} other {# minutos}}",
@ -442,6 +444,7 @@
"lists.delete": "Efasa lista",
"lists.done": "Fecho",
"lists.edit": "Edita lista",
"lists.list_name": "Nombre de lista",
"lists.new_list_name": "Nombre de mueva lista",
"lists.replies_policy.followed": "Kualseker utilizador segido",
"lists.replies_policy.list": "Miembros de la lista",
@ -738,6 +741,7 @@
"status.reblogs.empty": "Ainda nadie tiene repartajado esta publikasyon. Kuando algien lo aga, se amostrara aki.",
"status.redraft": "Efasa i eskrive de muevo",
"status.remove_bookmark": "Kita markador",
"status.remove_favourite": "Kita de los favoritos",
"status.replied_in_thread": "Arispondo en filo",
"status.replied_to": "Arispondio a {name}",
"status.reply": "Arisponde",
@ -758,6 +762,7 @@
"subscribed_languages.save": "Guadra trokamientos",
"subscribed_languages.target": "Troka linguas abonadas para {target}",
"tabs_bar.home": "Linya prinsipala",
"tabs_bar.menu": "Menu",
"tabs_bar.notifications": "Avizos",
"tabs_bar.publish": "Mueva publikasyon",
"tabs_bar.search": "Bushkeda",

View File

@ -3,16 +3,31 @@ import { Record } from 'immutable';
import type { ApiListJSON } from 'mastodon/api_types/lists';
type ListShape = Required<ApiListJSON>; // no changes from server shape
interface ListShape extends Required<Omit<ApiListJSON, 'account'>> {
account_id?: string;
}
export type List = RecordOf<ListShape>;
const ListFactory = Record<ListShape>({
id: '',
url: '',
title: '',
slug: '',
description: '',
type: 'private_list',
exclusive: false,
replies_policy: 'list',
account_id: undefined,
created_at: '',
updated_at: '',
});
export function createList(attributes: Partial<ListShape>) {
return ListFactory(attributes);
}
export const createList = (serverJSON: ApiListJSON): List => {
const { account, ...listJSON } = serverJSON;
return ListFactory({
...listJSON,
account_id: account?.id,
});
};

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M440-91v-366L120-642v321q0 22 10.5 40t29.5 29L440-91Zm80 0 280-161q19-11 29.5-29t10.5-40v-321L520-457v366Zm159-550 118-69-277-159q-19-11-40-11t-40 11l-79 45 318 183ZM480-526l119-68-317-184-120 69 318 183Z"/></svg>

After

Width:  |  Height:  |  Size: 310 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M440-183v-274L200-596v274l240 139Zm80 0 240-139v-274L520-457v274Zm-80 92L160-252q-19-11-29.5-29T120-321v-318q0-22 10.5-40t29.5-29l280-161q19-11 40-11t40 11l280 161q19 11 29.5 29t10.5 40v318q0 22-10.5 40T800-252L520-91q-19 11-40 11t-40-11Zm200-528 77-44-237-137-78 45 238 136Zm-160 93 78-45-237-137-78 45 237 137Z"/></svg>

After

Width:  |  Height:  |  Size: 418 B

View File

@ -4495,7 +4495,6 @@ a.status-card {
}
.column-header__buttons {
height: 48px;
display: flex;
}
@ -4542,6 +4541,16 @@ a.status-card {
color: $dark-text-color;
cursor: default;
}
&.icon-button {
background: transparent;
}
&.copied {
color: $valid-value-color;
transition: none;
background-color: rgba($valid-value-color, 0.15);
}
}
.no-reduce-motion .column-header__button .icon-sliders {
@ -11104,6 +11113,50 @@ noscript {
}
}
.lists__hero {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 24px;
border: 1px solid var(--background-border-color);
border-top: 0;
gap: 24px;
font-size: 14px;
line-height: 20px;
&__title {
text-align: center;
text-wrap: balance;
color: $secondary-text-color;
h1 {
font-size: 22px;
line-height: 28px;
font-weight: 600;
margin-bottom: 8px;
}
p {
font-weight: 400;
}
}
&__meta {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: $darker-text-color;
}
&__actions {
display: flex;
align-items: center;
gap: 4px;
}
}
.lists__item {
display: flex;
align-items: center;

View File

@ -28,7 +28,7 @@ class AnnualReport::Archetype < AnnualReport::Source
end
def polls_count
@polls_count ||= report_statuses.where.not(poll_id: nil).count
@polls_count ||= report_statuses.only_polls.count
end
def reblogs_count
@ -36,7 +36,7 @@ class AnnualReport::Archetype < AnnualReport::Source
end
def replies_count
@replies_count ||= report_statuses.where.not(in_reply_to_id: nil).where.not(in_reply_to_account_id: @account.id).count
@replies_count ||= report_statuses.where.not(in_reply_to_id: nil).not_replying_to_account(@account).count
end
def standalone_count

View File

@ -18,7 +18,7 @@ class AnnualReport::CommonlyInteractedWithAccounts < AnnualReport::Source
private
def commonly_interacted_with_accounts
report_statuses.where.not(in_reply_to_account_id: @account.id).group(:in_reply_to_account_id).having(minimum_interaction_count).order(count_all: :desc).limit(SET_SIZE).count
report_statuses.not_replying_to_account(@account).group(:in_reply_to_account_id).having(minimum_interaction_count).order(count_all: :desc).limit(SET_SIZE).count
end
def minimum_interaction_count

View File

@ -2,20 +2,44 @@
class AnnualReport::TopStatuses < AnnualReport::Source
def generate
top_reblogs = base_scope.order(reblogs_count: :desc).first&.id
top_favourites = base_scope.where.not(id: top_reblogs).order(favourites_count: :desc).first&.id
top_replies = base_scope.where.not(id: [top_reblogs, top_favourites]).order(replies_count: :desc).first&.id
{
top_statuses: {
by_reblogs: top_reblogs&.to_s,
by_favourites: top_favourites&.to_s,
by_replies: top_replies&.to_s,
by_reblogs: status_identifier(most_reblogged_status),
by_favourites: status_identifier(most_favourited_status),
by_replies: status_identifier(most_replied_status),
},
}
end
private
def status_identifier(status)
status.id.to_s if status.present?
end
def most_reblogged_status
base_scope
.order(reblogs_count: :desc)
.first
end
def most_favourited_status
base_scope
.excluding(most_reblogged_status)
.order(favourites_count: :desc)
.first
end
def most_replied_status
base_scope
.excluding(most_reblogged_status, most_favourited_status)
.order(replies_count: :desc)
.first
end
def base_scope
report_statuses.public_visibility.joins(:status_stat)
report_statuses
.public_visibility
.joins(:status_stat)
end
end

View File

@ -6,7 +6,7 @@ class AnnualReport::TypeDistribution < AnnualReport::Source
type_distribution: {
total: report_statuses.count,
reblogs: report_statuses.only_reblogs.count,
replies: report_statuses.where.not(in_reply_to_id: nil).where.not(in_reply_to_account_id: @account.id).count,
replies: report_statuses.where.not(in_reply_to_id: nil).not_replying_to_account(@account).count,
standalone: report_statuses.without_replies.without_reblogs.count,
},
}

View File

@ -158,7 +158,7 @@ class FeedManager
timeline_key = key(:list, list.id)
aggregate = list.account.user&.aggregates_reblogs?
query = from_account.statuses.list_eligible_visibility.includes(reblog: :account).limit(FeedManager::MAX_ITEMS / 4)
query = from_account.statuses.list_eligible_visibility(list).includes(reblog: :account).limit(FeedManager::MAX_ITEMS / 4)
if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4
oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true).first.last.to_i
@ -499,6 +499,8 @@ class FeedManager
# @param [List] list
# @return [Boolean]
def filter_from_list?(status, list)
return true if list.public_list? && !status.distributable?
if status.reply? && status.in_reply_to_account_id != status.account_id # Status is a reply to account other than status account
should_filter = status.in_reply_to_account_id != list.account_id # Status replies to account id other than list account
should_filter &&= !list.show_followed? # List show_followed? is false

View File

@ -161,7 +161,7 @@ class AccountStatusesCleanupPolicy < ApplicationRecord
end
def without_poll_scope
Status.where(poll_id: nil)
Status.without_polls
end
def without_popular_scope

View File

@ -4,14 +4,8 @@ module FollowLimitable
extend ActiveSupport::Concern
included do
validates_with FollowLimitValidator, on: :create, unless: :bypass_follow_limit?
end
validates_with FollowLimitValidator, on: :create, unless: :bypass_follow_limit
def bypass_follow_limit=(value)
@bypass_follow_limit = value
end
def bypass_follow_limit?
@bypass_follow_limit
attribute :bypass_follow_limit, :boolean, default: false
end
end

View File

@ -5,20 +5,25 @@
# Table name: lists
#
# id :bigint(8) not null, primary key
# account_id :bigint(8) not null
# description :text default(""), not null
# exclusive :boolean default(FALSE), not null
# replies_policy :integer default("list"), not null
# title :string default(""), not null
# type :integer default("private_list"), not null
# created_at :datetime not null
# updated_at :datetime not null
# replies_policy :integer default("list"), not null
# exclusive :boolean default(FALSE), not null
# account_id :bigint(8) not null
#
class List < ApplicationRecord
self.inheritance_column = nil
include Paginable
PER_ACCOUNT_LIMIT = 50
enum :replies_policy, { list: 0, followed: 1, none: 2 }, prefix: :show, validate: true
enum :type, { private_list: 0, public_list: 1 }
belongs_to :account
@ -26,12 +31,21 @@ class List < ApplicationRecord
has_many :accounts, through: :list_accounts
has_many :active_accounts, -> { merge(ListAccount.active) }, through: :list_accounts, source: :account
validates :title, presence: true
validates :title, presence: true, length: { maximum: 30 }
validates :description, length: { maximum: 160 }
validate :validate_account_lists_limit, on: :create
before_destroy :clean_feed_manager
def slug
title.parameterize
end
def to_url_param
{ id:, slug: }
end
private
def validate_account_lists_limit

View File

@ -121,7 +121,10 @@ class Status < ApplicationRecord
scope :without_replies, -> { not_reply.or(reply_to_account) }
scope :not_reply, -> { where(reply: false) }
scope :only_reblogs, -> { where.not(reblog_of_id: nil) }
scope :only_polls, -> { where.not(poll_id: nil) }
scope :without_polls, -> { where(poll_id: nil) }
scope :reply_to_account, -> { where(arel_table[:in_reply_to_account_id].eq arel_table[:account_id]) }
scope :not_replying_to_account, ->(account) { where.not(in_reply_to_account: account) }
scope :without_reblogs, -> { where(statuses: { reblog_of_id: nil }) }
scope :tagged_with, ->(tag_ids) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag_ids }) }
scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) }
@ -136,6 +139,9 @@ class Status < ApplicationRecord
scope :tagged_with_none, lambda { |tag_ids|
where('NOT EXISTS (SELECT * FROM statuses_tags forbidden WHERE forbidden.status_id = statuses.id AND forbidden.tag_id IN (?))', tag_ids)
}
scope :distributable_visibility, -> { where(visibility: %i(public unlisted)) }
scope :list_eligible_visibility, ->(list = nil) { where(visibility: list&.public_list? ? %i(public unlisted) : %i(public unlisted private)) }
scope :not_direct_visibility, -> { where.not(visibility: :direct) }
after_create_commit :trigger_create_webhooks
after_update_commit :trigger_update_webhooks

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class ListPolicy < ApplicationPolicy
def show?
record.public_list? || owned?
end
def update?
owned?
end
def destroy?
owned?
end
private
def owned?
user_signed_in? && record.account_id == current_account.id
end
end

View File

@ -1,9 +1,20 @@
# frozen_string_literal: true
class REST::ListSerializer < ActiveModel::Serializer
attributes :id, :title, :replies_policy, :exclusive
include RoutingHelper
attributes :id, :title, :description, :type, :replies_policy,
:exclusive, :created_at, :updated_at
attribute :slug, if: -> { object.public_list? }
attribute :url, if: -> { object.public_list? }
has_one :account, serializer: REST::AccountSerializer, if: -> { object.public_list? }
def id
object.id.to_s
end
def url
public_list_url(object.to_url_param)
end
end

View File

@ -25,6 +25,6 @@
.rules-list__hint= translation.hint
.stacked-actions
- accept_path = @invite_code.present? ? public_invite_url(invite_code: @invite_code, accept: @accept_token) : new_user_registration_path(accept: @accept_token)
- accept_path = @invite_code.present? ? public_invite_url(invite_code: @invite_code, accept: @accept_token) : new_user_registration_path(accept: @accept_token, list_id: @list&.id)
= link_to t('auth.rules.accept'), accept_path, class: 'button'
= link_to t('auth.rules.back'), root_path, class: 'button button-tertiary'

View File

@ -0,0 +1,7 @@
%meta{ name: 'description', content: list.description }/
= opengraph 'og:url', public_list_url(list.to_url_param)
= opengraph 'og:site_name', site_title
= opengraph 'og:title', yield(:page_title).strip
= opengraph 'og:description', list.description
= opengraph 'twitter:card', 'summary'

View File

@ -0,0 +1,6 @@
- content_for :page_title, @list.title
- content_for :header_tags do
= render 'og', list: @list
= render partial: 'shared/web_app'

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class FollowFromPublicListWorker
include Sidekiq::Worker
def perform(into_account_id, list_id)
list = List.where(type: :public_list).find(list_id)
into_account = Account.find(into_account_id)
list.accounts.find_each do |target_account|
FollowService.new.call(into_account, target_account)
rescue
# Skip past disallowed follows
end
rescue ActiveRecord::RecordNotFound
true
end
end

View File

@ -1347,7 +1347,7 @@ da:
your_appeal_rejected: Din appel er afvist
edit_profile:
basic_information: Oplysninger
hint_html: "<strong>Tilpas hvad folk ser på din offentlige profil og ved siden af dine indlæg.</strong> Andre personer vil mere sandsynligt følge dig tilbage og interagere med dig, når du har en udfyldt profil og et profilbillede."
hint_html: "<strong>Tilpas, hvad folk ser på din offentlige profil og ved siden af dine indlæg.</strong> Andre personer er mere tilbøjelige til at følge dig tilbage og interagere med dig, når du har en udfyldt profil og et profilbillede."
other: Andre
emoji_styles:
auto: Auto

View File

@ -1944,7 +1944,7 @@ de:
contrast: Mastodon (Hoher Kontrast)
default: Mastodon (Dunkel)
mastodon-light: Mastodon (Hell)
system: Automatisch (mit System synchronisieren)
system: Automatisch (wie Betriebssystem)
time:
formats:
default: "%d. %b %Y, %H:%M Uhr"

View File

@ -467,8 +467,11 @@ eu:
fasp:
debug:
callbacks:
created_at: Sortua hemen
delete: Ezabatu
ip: IP helbidea
request_body: Eskaeraren edukia
title: Atzera-deiak araztu
providers:
active: Aktibo
base_url: Oinarrizko URL-a
@ -822,6 +825,7 @@ eu:
destroyed_msg: Guneko igoera ongi ezabatu da!
software_updates:
critical_update: Kritikoa — mesedez, eguneratu azkar
description: Gomendagarria da Mastodon instalazioa eguneratuta mantentzea azken konponketa eta funtzioez baliatzeko. Gainera, batzuetan ezinbestekoa da Mastodon garaiz eguneratzea segurtasun arazoak saihesteko. Arrazoi hauengatik, Mastodonek 30 minuturo eguneratzeak egiaztatzen ditu, eta zure posta elektroniko bidezko jakinarazpenen lehentasunen arabera jakinaraziko dizu.
documentation_link: Informazio gehiago
release_notes: Bertsio oharrak
title: Eguneraketak eskuragarri

View File

@ -1851,6 +1851,8 @@ fr-CA:
limit: Vous avez déjà épinglé le nombre maximum de messages
ownership: Vous ne pouvez pas épingler un message ne vous appartenant pas
reblog: Un partage ne peut pas être épinglé
quote_policies:
followers: Abonné·e·s et utilisateur·trice·s mentionné·e·s
title: "%{name}: « %{quote} »"
visibilities:
direct: Direct
@ -1904,6 +1906,8 @@ fr-CA:
does_not_match_previous_name: ne correspond pas au nom précédent
terms_of_service:
title: Conditions d'utilisation
terms_of_service_interstitial:
title: Les conditions d'utilisation de %{domain} ont changées
themes:
contrast: Mastodon (Contraste élevé)
default: Mastodon (Sombre)

View File

@ -1851,6 +1851,8 @@ fr:
limit: Vous avez déjà épinglé le nombre maximum de messages
ownership: Vous ne pouvez pas épingler un message ne vous appartenant pas
reblog: Un partage ne peut pas être épinglé
quote_policies:
followers: Abonné·e·s et utilisateur·trice·s mentionné·e·s
title: "%{name}: « %{quote} »"
visibilities:
direct: Direct
@ -1904,6 +1906,8 @@ fr:
does_not_match_previous_name: ne correspond pas au nom précédent
terms_of_service:
title: Conditions d'utilisation
terms_of_service_interstitial:
title: Les conditions d'utilisation de %{domain} ont changées
themes:
contrast: Mastodon (Contraste élevé)
default: Mastodon (Sombre)

View File

@ -1406,6 +1406,10 @@ ga:
basic_information: Eolas bunúsach
hint_html: "<strong>Saincheap a bhfeiceann daoine ar do phróifíl phoiblí agus in aice le do phostálacha.</strong> Is dóichí go leanfaidh daoine eile ar ais tú agus go n-idirghníomhóidh siad leat nuair a bhíonn próifíl líonta agus pictiúr próifíle agat."
other: Eile
emoji_styles:
auto: Uath
native: Dúchasach
twemoji: Twemoji
errors:
'400': Bhí an t-iarratas a chuir tú isteach neamhbhailí nó míchumtha.
'403': Níl cead agat an leathanach seo a fheiceáil.

View File

@ -1351,6 +1351,10 @@ it:
basic_information: Informazioni di base
hint_html: "<strong>Personalizza ciò che le persone vedono sul tuo profilo pubblico e accanto ai tuoi post.</strong> È più probabile che altre persone ti seguano e interagiscano con te quando hai un profilo compilato e un'immagine del profilo."
other: Altro
emoji_styles:
auto: Automatico
native: Nativo
twemoji: Twemoji
errors:
'400': La richiesta che hai inviato non è valida o non è corretta.
'403': Non sei autorizzato a visualizzare questa pagina.

View File

@ -186,6 +186,7 @@ lad:
create_domain_block: Kriya bloko de domeno
create_email_domain_block: Kriya bloko de domeno de posta
create_ip_block: Kriya regla de IP
create_relay: Kriya relevo
create_unavailable_domain: Kriya domeno no desponivle
create_user_role: Kriya rolo
demote_user: Degrada utilizador
@ -197,6 +198,7 @@ lad:
destroy_email_domain_block: Efasa bloko de domeno de posta
destroy_instance: Efasa domeno
destroy_ip_block: Efasa regla de IP
destroy_relay: Efasa relevo
destroy_status: Efasa publikasyon
destroy_unavailable_domain: Efasa domeno no desponivle
destroy_user_role: Efasa rolo
@ -205,6 +207,7 @@ lad:
disable_sign_in_token_auth_user: Inkapasita la autentifikasyon por token de posta elektronika para el utilizador
disable_user: Inkapasita utilizador
enable_custom_emoji: Kapasita emoji personalizados
enable_relay: Aktiva relevo
enable_sign_in_token_auth_user: Kapasita la autentifikasyon por token de posta para el utilizador
enable_user: Kapasita utilizador
memorialize_account: Transforma en kuento komemorativo
@ -229,6 +232,7 @@ lad:
update_custom_emoji: Aktualiza emoji personalizado
update_domain_block: Aktualiza bloko de domeno
update_ip_block: Aktualiza regla de IP
update_report: Aktualiza raporto
update_status: Aktualiza publikasyon
update_user_role: Aktualiza rolo
actions:
@ -466,10 +470,13 @@ lad:
fasp:
debug:
callbacks:
created_at: Kriyado en
delete: Efasa
ip: Adreso IP
providers:
active: Aktivo
delete: Efasa
finish_registration: Finaliza enrejistrasyon
name: Nombre
registrations:
confirm: Konfirma
@ -542,6 +549,12 @@ lad:
all: Todos
limited: Limitado
title: Moderasyon
moderation_notes:
create: Adjusta nota de moderasyon
created_msg: Nota de moderasyon de sirvidor kriyada kon sukseso!
description_html: Ve i desha notas a otros moderadores i a tu yo futuro
destroyed_msg: Nota de moderasyon de sirvidor efasada kon sukseso!
title: Notas de moderasyon
private_comment: Komento privado
public_comment: Komento publiko
purge: Purga
@ -748,6 +761,7 @@ lad:
title: Rolos
rules:
add_new: Adjusta regla
add_translation: Adjusta traduksyon
delete: Efasa
description_html: Aunke la majorita afirma aver meldado i estar de akodro kon los terminos de servisyo, la djente normalmente no los melda asta dempues de ke surja algun problema. <strong>Az ke sea mas kolay ver las normas de tu sirvidor de un vistazo estipulándolas en una lista de puntos.</strong> Aprova ke kada norma sea corta i kolay, ama sin estar divididas en munchos puntos.
edit: Edita regla
@ -920,6 +934,9 @@ lad:
updated_msg: Konfigurasyon de etiketas aktualizada kon sukseso
terms_of_service:
changelog: Ke troko
current: Aktual
generates:
action: Djenera
history: Istorya
live: En bivo
publish: Publika
@ -1245,6 +1262,10 @@ lad:
basic_information: Enformasyon bazika
hint_html: "<strong>Personaliza lo ke la djente ve en tu profil publiko i kon tus publikasyones.</strong> Es mas probavle ke otras personas te sigan i enteraktuen kontigo kuando kompletas tu profil i foto."
other: Otros
emoji_styles:
auto: Otomatiko
native: Nativo
twemoji: Twemoji
errors:
'400': La solisitasyon ke enviates no fue valida o fue malformada.
'403': No tienes permiso para ver esta pajina.

View File

@ -583,7 +583,7 @@ nl:
created_msg: Aanmaken van servermoderatie-opmerking geslaagd!
description_html: Opmerkingen bekijken, en voor jezelf en andere moderatoren achterlaten
destroyed_msg: Verwijderen van servermoderatie-opmerking geslaagd!
placeholder: Informatie over deze server, genomen acties of iets anders die jou kunnen helpen om deze server in de toekomst te moderen.
placeholder: Informatie over deze server, genomen acties of iets anders die jou kunnen helpen om deze server in de toekomst te modereren.
title: Moderatie-opmerkingen
private_comment: Privé-opmerking
public_comment: Openbare opmerking

View File

@ -61,7 +61,7 @@ da:
setting_display_media_default: Skjul medier med sensitiv-markering
setting_display_media_hide_all: Skjul altid medier
setting_display_media_show_all: Vis altid medier
setting_emoji_style: Hvordan emojis skal vises. "Auto" vil forsøge at bruge indbyggede emojis, men skifter tilbage til Twemoji for ældre browsere.
setting_emoji_style: Hvordan emojis skal vises. "Auto" vil forsøge at bruge indbyggede emojis, men skifter tilbage til Twemoji i ældre webbrowsere.
setting_system_scrollbars_ui: Gælder kun for computerwebbrowsere baseret på Safari og Chrome
setting_use_blurhash: Gradienter er baseret på de skjulte grafikelementers farver, men slører alle detaljer
setting_use_pending_items: Skjul tidslinjeopdateringer bag et klik i stedet for brug af auto-feedrulning

View File

@ -61,7 +61,7 @@ de:
setting_display_media_default: Medien mit Inhaltswarnung ausblenden
setting_display_media_hide_all: Medien immer ausblenden
setting_display_media_show_all: Medien mit Inhaltswarnung immer anzeigen
setting_emoji_style: Darstellung von Emojis. „Automatisch“ verwendet native Emojis, für ältere Browser jedoch Twemoji.
setting_emoji_style: 'Wie Emojis dargestellt werden: „Automatisch“ verwendet native Emojis, für veraltete Browser wird jedoch Twemoji verwendet.'
setting_system_scrollbars_ui: Betrifft nur Desktop-Browser, die auf Chrome oder Safari basieren
setting_use_blurhash: Der Farbverlauf basiert auf den Farben der ausgeblendeten Medien, verschleiert aber jegliche Details
setting_use_pending_items: Neue Beiträge hinter einem Klick verstecken, anstatt automatisch zu scrollen
@ -248,7 +248,7 @@ de:
setting_missing_alt_text_modal: Bestätigungsdialog anzeigen, bevor Medien ohne Bildbeschreibung veröffentlicht werden
setting_reduce_motion: Bewegung in Animationen verringern
setting_system_font_ui: Standardschriftart des Browsers verwenden
setting_system_scrollbars_ui: Bildlaufleiste des Systems verwenden
setting_system_scrollbars_ui: Bildlaufleiste des Betriebssystems verwenden
setting_theme: Design
setting_trends: Heutige Trends anzeigen
setting_unfollow_modal: Bestätigungsdialog beim Entfolgen eines Profils anzeigen

View File

@ -61,6 +61,7 @@ ga:
setting_display_media_default: Folaigh meáin atá marcáilte mar íogair
setting_display_media_hide_all: Folaigh meáin i gcónaí
setting_display_media_show_all: Taispeáin meáin i gcónaí
setting_emoji_style: Conas emojis a thaispeáint. Déanfaidh "Auto" iarracht emoji dúchasacha a úsáid, ach titeann sé ar ais go Twemoji le haghaidh seanbhrabhsálaithe.
setting_system_scrollbars_ui: Ní bhaineann sé ach le brabhsálaithe deisce bunaithe ar Safari agus Chrome
setting_use_blurhash: Tá grádáin bunaithe ar dhathanna na n-amharcanna ceilte ach cuireann siad salach ar aon mhionsonraí
setting_use_pending_items: Folaigh nuashonruithe amlíne taobh thiar de chlic seachas an fotha a scrollú go huathoibríoch
@ -244,6 +245,7 @@ ga:
setting_display_media_default: Réamhshocrú
setting_display_media_hide_all: Cuir uile i bhfolach
setting_display_media_show_all: Taispeáin uile
setting_emoji_style: Stíl Emoji
setting_expand_spoilers: Méadaigh postálacha atá marcáilte le rabhaidh inneachair i gcónaí
setting_hide_network: Folaigh do ghraf sóisialta
setting_missing_alt_text_modal: Taispeáin dialóg deimhnithe sula bpostálann tú meán gan alt téacs

View File

@ -61,6 +61,7 @@ it:
setting_display_media_default: Nascondi media segnati come sensibili
setting_display_media_hide_all: Nascondi sempre tutti i media
setting_display_media_show_all: Mostra sempre i media segnati come sensibili
setting_emoji_style: Come visualizzare gli emoji. "Automatico" proverà a usare gli emoji nativi, ma per i browser più vecchi ricorrerà a Twemoji.
setting_system_scrollbars_ui: Si applica solo ai browser desktop basati su Safari e Chrome
setting_use_blurhash: I gradienti sono basati sui colori delle immagini nascoste ma offuscano tutti i dettagli
setting_use_pending_items: Fare clic per mostrare i nuovi messaggi invece di aggiornare la timeline automaticamente
@ -241,6 +242,7 @@ it:
setting_display_media_default: Predefinita
setting_display_media_hide_all: Nascondi tutti
setting_display_media_show_all: Mostra tutti
setting_emoji_style: Stile emoji
setting_expand_spoilers: Espandi sempre post con content warning
setting_hide_network: Nascondi la tua rete
setting_missing_alt_text_modal: Chiedi di confermare prima di pubblicare media senza testo alternativo

View File

@ -61,7 +61,7 @@ nl:
setting_display_media_default: Als gevoelig gemarkeerde media verbergen
setting_display_media_hide_all: Media altijd verbergen
setting_display_media_show_all: Media altijd tonen
setting_emoji_style: Waarmee moeten emojis worden weergegeven. "Auto" probeert de systeemeigen emojis te gebruiken, maar valt terug op Twemoji voor oudere webbrowsers.
setting_emoji_style: Waarmee moeten emojis worden weergegeven. Auto probeert de systeemeigen emojis te gebruiken, maar valt terug op Twemoji voor oudere webbrowsers.
setting_system_scrollbars_ui: Alleen van toepassing op desktopbrowsers gebaseerd op Safari en Chrome
setting_use_blurhash: Wazige kleurovergangen zijn gebaseerd op de kleuren van de verborgen media, waarmee elk detail verdwijnt
setting_use_pending_items: De tijdlijn wordt bijgewerkt door op het aantal nieuwe items te klikken, in plaats van dat deze automatisch wordt bijgewerkt

View File

@ -234,6 +234,7 @@ uk:
setting_display_media_default: За промовчанням
setting_display_media_hide_all: Сховати всі
setting_display_media_show_all: Показати всі
setting_emoji_style: Стиль емодзі
setting_expand_spoilers: Завжди розгортати дописи з попередженнями про вміст
setting_hide_network: Сховати вашу мережу
setting_missing_alt_text_modal: Запитувати перед розміщенням медіа без альтернативного тексту

View File

@ -1338,6 +1338,9 @@ uk:
basic_information: Основна інформація
hint_html: "<strong>Налаштуйте те, що люди бачитимуть у вашому загальнодоступному профілі та поруч із вашими дописами.</strong> Інші люди з більшою ймовірністю підпишуться на вас та взаємодіятимуть з вами, якщо у вас є заповнений профіль та зображення профілю."
other: Інше
emoji_styles:
auto: Авто
native: Рідний
errors:
'400': Ваш запит був недійсним або неправильним.
'403': У Вас немає доступу до перегляду даної сторінки.

View File

@ -188,6 +188,11 @@ Rails.application.routes.draw do
get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy, format: false
get '/backups/:id/download', to: 'backups#download', as: :download_backup, format: false
constraints(id: /[\d]+/) do
get '/starter-pack/:id(-:slug)', to: 'lists#show', as: :public_list
get '/starter-pack/:id(-:slug)/posts', to: 'lists#show', format: false
end
resource :authorize_interaction, only: [:show]
resource :share, only: [:show]

View File

@ -230,6 +230,7 @@ namespace :api, format: false do
resources :lists, only: [:index, :create, :show, :update, :destroy] do
resource :accounts, only: [:show, :create, :destroy], module: :lists
resource :follow, only: [:create], module: :lists
end
namespace :featured_tags do

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class AddTypeToLists < ActiveRecord::Migration[7.2]
def change
add_column :lists, :type, :integer, default: 0, null: false
add_column :lists, :description, :text, default: '', null: false
end
end

View File

@ -191,8 +191,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
t.boolean "hide_collections"
t.integer "avatar_storage_schema_version"
t.integer "header_storage_schema_version"
t.datetime "sensitized_at", precision: nil
t.integer "suspension_origin"
t.datetime "sensitized_at", precision: nil
t.boolean "trendable"
t.datetime "reviewed_at", precision: nil
t.datetime "requested_review_at", precision: nil
@ -613,12 +613,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
end
create_table "ip_blocks", force: :cascade do |t|
t.inet "ip", default: "0.0.0.0", null: false
t.integer "severity", default: 0, null: false
t.datetime "expires_at", precision: nil
t.text "comment", default: "", null: false
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.datetime "expires_at", precision: nil
t.inet "ip", default: "0.0.0.0", null: false
t.integer "severity", default: 0, null: false
t.text "comment", default: "", null: false
t.index ["ip"], name: "index_ip_blocks_on_ip", unique: true
end
@ -640,6 +640,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
t.datetime "updated_at", precision: nil, null: false
t.integer "replies_policy", default: 0, null: false
t.boolean "exclusive", default: false, null: false
t.integer "type", default: 0, null: false
t.text "description", default: "", null: false
t.index ["account_id"], name: "index_lists_on_account_id"
end
@ -1498,9 +1500,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
add_index "instances", ["domain"], name: "index_instances_on_domain", unique: true
create_view "user_ips", sql_definition: <<-SQL
SELECT user_id,
ip,
max(used_at) AS used_at
SELECT t0.user_id,
t0.ip,
max(t0.used_at) AS used_at
FROM ( SELECT users.id AS user_id,
users.sign_up_ip AS ip,
users.created_at AS used_at
@ -1517,7 +1519,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
login_activities.created_at
FROM login_activities
WHERE (login_activities.success = true)) t0
GROUP BY user_id, ip;
GROUP BY t0.user_id, t0.ip;
SQL
create_view "account_summaries", materialized: true, sql_definition: <<-SQL
SELECT accounts.id AS account_id,
@ -1538,9 +1540,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
add_index "account_summaries", ["account_id"], name: "index_account_summaries_on_account_id", unique: true
create_view "global_follow_recommendations", materialized: true, sql_definition: <<-SQL
SELECT account_id,
sum(rank) AS rank,
array_agg(reason) AS reason
SELECT t0.account_id,
sum(t0.rank) AS rank,
array_agg(t0.reason) AS reason
FROM ( SELECT account_summaries.account_id,
((count(follows.id))::numeric / (1.0 + (count(follows.id))::numeric)) AS rank,
'most_followed'::text AS reason
@ -1564,8 +1566,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
WHERE (follow_recommendation_suppressions.account_id = statuses.account_id)))))
GROUP BY account_summaries.account_id
HAVING (sum((status_stats.reblogs_count + status_stats.favourites_count)) >= (5)::numeric)) t0
GROUP BY account_id
ORDER BY (sum(rank)) DESC;
GROUP BY t0.account_id
ORDER BY (sum(t0.rank)) DESC;
SQL
add_index "global_follow_recommendations", ["account_id"], name: "index_global_follow_recommendations_on_account_id", unique: true

View File

@ -1288,49 +1288,64 @@ RSpec.describe Mastodon::CLI::Accounts do
describe '#prune' do
let(:action) { :prune }
let!(:local_account) { Fabricate(:account) }
let!(:bot_account) { Fabricate(:account, bot: true, domain: 'example.com') }
let!(:group_account) { Fabricate(:account, actor_type: 'Group', domain: 'example.com') }
let!(:mentioned_account) { Fabricate(:account, domain: 'example.com') }
let!(:prunable_accounts) do
Fabricate.times(2, :account, domain: 'example.com', bot: false, suspended_at: nil, silenced_at: nil)
end
let(:viable_attrs) { { domain: 'example.com', bot: false, suspended: false, silenced: false } }
let!(:local_account) { Fabricate(:account) }
let!(:bot_account) { Fabricate(:account, bot: true, domain: 'example.com') }
let!(:group_account) { Fabricate(:account, actor_type: 'Group', domain: 'example.com') }
let!(:account_mentioned) { Fabricate(:account, viable_attrs) }
let!(:account_with_favourite) { Fabricate(:account, viable_attrs) }
let!(:account_with_status) { Fabricate(:account, viable_attrs) }
let!(:account_with_follow) { Fabricate(:account, viable_attrs) }
let!(:account_targeted_follow) { Fabricate(:account, viable_attrs) }
let!(:account_with_block) { Fabricate(:account, viable_attrs) }
let!(:account_targeted_block) { Fabricate(:account, viable_attrs) }
let!(:account_targeted_mute) { Fabricate(:account, viable_attrs) }
let!(:account_targeted_report) { Fabricate(:account, viable_attrs) }
let!(:account_with_follow_request) { Fabricate(:account, viable_attrs) }
let!(:account_targeted_follow_request) { Fabricate(:account, viable_attrs) }
let!(:prunable_accounts) { Fabricate.times(2, :account, viable_attrs) }
before do
Fabricate(:mention, account: mentioned_account, status: Fabricate(:status, account: Fabricate(:account)))
Fabricate :mention, account: account_mentioned, status: Fabricate(:status, account: Fabricate(:account))
Fabricate :favourite, account: account_with_favourite
Fabricate :status, account: account_with_status
Fabricate :follow, account: account_with_follow
Fabricate :follow, target_account: account_targeted_follow
Fabricate :block, account: account_with_block
Fabricate :block, target_account: account_targeted_block
Fabricate :mute, target_account: account_targeted_mute
Fabricate :report, target_account: account_targeted_report
Fabricate :follow_request, account: account_with_follow_request
Fabricate :follow_request, target_account: account_targeted_follow_request
stub_parallelize_with_progress!
end
def expect_prune_remote_accounts_without_interaction
prunable_account_ids = prunable_accounts.pluck(:id)
expect(Account.where(id: prunable_account_ids).count).to eq(0)
end
it 'displays a successful message and handles accounts correctly' do
expect { subject }
.to output_results("OK, pruned #{prunable_accounts.size} accounts")
expect_prune_remote_accounts_without_interaction
expect_not_prune_local_accounts
expect_not_prune_bot_accounts
expect_not_prune_group_accounts
expect_not_prune_mentioned_accounts
expect(prunable_account_records)
.to have_attributes(count: eq(0))
expect(Account.all)
.to include(local_account)
.and include(bot_account)
.and include(group_account)
.and include(account_mentioned)
.and include(account_with_favourite)
.and include(account_with_status)
.and include(account_with_follow)
.and include(account_targeted_follow)
.and include(account_with_block)
.and include(account_targeted_block)
.and include(account_targeted_mute)
.and include(account_targeted_report)
.and include(account_with_follow_request)
.and include(account_targeted_follow_request)
.and not_include(prunable_accounts.first)
.and not_include(prunable_accounts.last)
end
def expect_not_prune_local_accounts
expect(Account.exists?(id: local_account.id)).to be(true)
end
def expect_not_prune_bot_accounts
expect(Account.exists?(id: bot_account.id)).to be(true)
end
def expect_not_prune_group_accounts
expect(Account.exists?(id: group_account.id)).to be(true)
end
def expect_not_prune_mentioned_accounts
expect(Account.exists?(id: mentioned_account.id)).to be true
def prunable_account_records
Account.where(id: prunable_accounts.pluck(:id))
end
context 'with --dry-run option' do

View File

@ -191,6 +191,19 @@ RSpec.describe Status do
end
end
describe '.not_replying_to_account' do
let(:account) { Fabricate :account }
let!(:status_from_account) { Fabricate :status, account: account }
let!(:reply_to_account_status) { Fabricate :status, thread: status_from_account }
let!(:reply_to_other) { Fabricate :status, thread: Fabricate(:status) }
it 'returns records not in reply to provided account' do
expect(described_class.not_replying_to_account(account))
.to not_include(reply_to_account_status)
.and include(reply_to_other)
end
end
describe '#untrusted_favourites_count' do
before do
alice.update(domain: 'example.com')
@ -363,6 +376,28 @@ RSpec.describe Status do
end
end
describe '.only_polls' do
let!(:poll_status) { Fabricate :status, poll: Fabricate(:poll) }
let!(:no_poll_status) { Fabricate :status }
it 'returns the expected statuses' do
expect(described_class.only_polls)
.to include(poll_status)
.and not_include(no_poll_status)
end
end
describe '.without_polls' do
let!(:poll_status) { Fabricate :status, poll: Fabricate(:poll) }
let!(:no_poll_status) { Fabricate :status }
it 'returns the expected statuses' do
expect(described_class.without_polls)
.to not_include(poll_status)
.and include(no_poll_status)
end
end
describe '.tagged_with' do
let(:tag_cats) { Fabricate(:tag, name: 'cats') }
let(:tag_dogs) { Fabricate(:tag, name: 'dogs') }

View File

@ -6032,9 +6032,9 @@ __metadata:
linkType: hard
"core-js@npm:^3.30.2, core-js@npm:^3.41.0":
version: 3.43.0
resolution: "core-js@npm:3.43.0"
checksum: 10c0/9d4ad66296e60380777de51d019b5c3e6cce023b7999750a5094f9a4b0ea53bf3600beb4ef11c56548f2c8791d43d4056e270d1cf55ba87273011aa7d4597871
version: 3.44.0
resolution: "core-js@npm:3.44.0"
checksum: 10c0/759bf3dc5f75068e9425dddf895fd5531c38794a11ea1c2b65e5ef7c527fe3652d59e8c287e574a211af9bab3c057c5c3fa6f6a773f4e142af895106efad38a4
languageName: node
linkType: hard