Compare commits

...

5 Commits

Author SHA1 Message Date
Eugen Rochko
0dfa6ee7e8
Merge 9b56d00b5c into 94bceb8683 2025-07-11 14:05:42 +00:00
Echo
94bceb8683
Expose enabled features to the frontend (#35348)
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
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
2025-07-11 13:15:22 +00:00
Claire
88b0f3a172
Simplify DatabaseViewRecord.refresh (#35252) 2025-07-11 08:36:05 +00:00
github-actions[bot]
b69b5ba775
New Crowdin Translations (automated) (#35344)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-11 08:14:39 +00:00
Eugen Rochko
9b56d00b5c WIP: Add starter packs 2025-06-03 19:30:20 +02:00
47 changed files with 821 additions and 98 deletions

View File

@ -1,25 +1,30 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::Lists::AccountsController < Api::BaseController 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 -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show]
before_action :require_user! before_action :require_user!, except: [:show]
before_action :set_list before_action :set_list
after_action :insert_pagination_headers, only: :show after_action :insert_pagination_headers, only: :show
def show def show
authorize @list, :show?
@accounts = load_accounts @accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer render json: @accounts, each_serializer: REST::AccountSerializer
end end
def create def create
authorize @list, :update?
AddAccountsToListService.new.call(@list, Account.find(account_ids)) AddAccountsToListService.new.call(@list, Account.find(account_ids))
render_empty render_empty
end end
def destroy def destroy
authorize @list, :update?
RemoveAccountsFromListService.new.call(@list, Account.where(id: account_ids)) RemoveAccountsFromListService.new.call(@list, Account.where(id: account_ids))
render_empty render_empty
end end
@ -27,7 +32,7 @@ class Api::V1::Lists::AccountsController < Api::BaseController
private private
def set_list def set_list
@list = List.where(account: current_account).find(params[:list_id]) @list = List.find(params[:list_id])
end end
def load_accounts 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 # frozen_string_literal: true
class Api::V1::ListsController < Api::BaseController 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 -> { 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] before_action :set_list, except: [:index, :create]
def index def index
@ -13,6 +16,7 @@ class Api::V1::ListsController < Api::BaseController
end end
def show def show
authorize @list, :show?
render json: @list, serializer: REST::ListSerializer render json: @list, serializer: REST::ListSerializer
end end
@ -22,11 +26,13 @@ class Api::V1::ListsController < Api::BaseController
end end
def update def update
authorize @list, :update?
@list.update!(list_params) @list.update!(list_params)
render json: @list, serializer: REST::ListSerializer render json: @list, serializer: REST::ListSerializer
end end
def destroy def destroy
authorize @list, :destroy?
@list.destroy! @list.destroy!
render_empty render_empty
end end
@ -34,10 +40,10 @@ class Api::V1::ListsController < Api::BaseController
private private
def set_list def set_list
@list = List.where(account: current_account).find(params[:id]) @list = List.find(params[:id])
end end
def list_params def list_params
params.permit(:title, :replies_policy, :exclusive) params.permit(:title, :description, :type, :replies_policy, :exclusive)
end end
end end

View File

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

View File

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

@ -32,3 +32,6 @@ export const apiRemoveAccountFromList = (listId: string, accountId: string) =>
apiRequestDelete(`v1/lists/${listId}/accounts`, { apiRequestDelete(`v1/lists/${listId}/accounts`, {
account_ids: [accountId], 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 // See app/serializers/rest/list_serializer.rb
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
export type RepliesPolicyType = 'list' | 'followed' | 'none'; export type RepliesPolicyType = 'list' | 'followed' | 'none';
export type ListType = 'private_list' | 'public_list';
export interface ApiListJSON { export interface ApiListJSON {
id: string; id: string;
url?: string;
title: string; title: string;
slug?: string;
type: ListType;
description: string;
created_at: string;
updated_at: string;
exclusive: boolean; exclusive: boolean;
replies_policy: RepliesPolicyType; replies_policy: RepliesPolicyType;
account?: ApiAccountJSON;
} }

View File

@ -19,7 +19,7 @@ const messages = defineMessages({
export const CopyIconButton: React.FC<{ export const CopyIconButton: React.FC<{
title: string; title: string;
value: string; value: string;
className: string; className?: string;
}> = ({ title, value, className }) => { }> = ({ title, value, className }) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const dispatch = useAppDispatch(); 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 AddIcon from '@/material-icons/400-24px/add.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.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 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 SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchLists } from 'mastodon/actions/lists'; import { fetchLists } from 'mastodon/actions/lists';
import { openModal } from 'mastodon/actions/modal'; 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 { Dropdown } from 'mastodon/components/dropdown_menu';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list'; 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 { getOrderedLists } from 'mastodon/selectors/lists';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -25,12 +28,12 @@ const messages = defineMessages({
edit: { id: 'lists.edit', defaultMessage: 'Edit list' }, edit: { id: 'lists.edit', defaultMessage: 'Edit list' },
delete: { id: 'lists.delete', defaultMessage: 'Delete list' }, delete: { id: 'lists.delete', defaultMessage: 'Delete list' },
more: { id: 'status.more', defaultMessage: 'More' }, more: { id: 'status.more', defaultMessage: 'More' },
copyLink: { id: '', defaultMessage: 'Copy link' },
}); });
const ListItem: React.FC<{ const ListItem: React.FC<{
id: string; list: List;
title: string; }> = ({ list }) => {
}> = ({ id, title }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
@ -39,25 +42,54 @@ const ListItem: React.FC<{
openModal({ openModal({
modalType: 'CONFIRM_DELETE_LIST', modalType: 'CONFIRM_DELETE_LIST',
modalProps: { modalProps: {
listId: id, listId: list.id,
}, },
}), }),
); );
}, [dispatch, id]); }, [dispatch, list]);
const menu = useMemo( const handleCopyClick = useCallback(() => {
() => [ void navigator.clipboard.writeText(list.url);
{ text: intl.formatMessage(messages.edit), to: `/lists/${id}/edit` }, }, [list]);
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
], const menu = useMemo(() => {
[intl, id, handleDeleteClick], 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 ( return (
<div className='lists__item'> <div className='lists__item'>
<Link to={`/lists/${id}`} className='lists__item__title'> <Link
<Icon id='list-ul' icon={ListAltIcon} /> to={
<span>{title}</span> 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> </Link>
<Dropdown <Dropdown
@ -128,7 +160,7 @@ const Lists: React.FC<{
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >
{lists.map((list) => ( {lists.map((list) => (
<ListItem key={list.id} id={list.id} title={list.title} /> <ListItem key={list.id} list={list} />
))} ))}
</ScrollableList> </ScrollableList>

View File

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

View File

@ -84,7 +84,9 @@ const NewList: React.FC<{
id ? state.lists.get(id) : undefined, id ? state.lists.get(id) : undefined,
); );
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [exclusive, setExclusive] = useState(false); const [exclusive, setExclusive] = useState(false);
const [isPublic, setIsPublic] = useState(false);
const [repliesPolicy, setRepliesPolicy] = useState<RepliesPolicyType>('list'); const [repliesPolicy, setRepliesPolicy] = useState<RepliesPolicyType>('list');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@ -109,6 +111,13 @@ const NewList: React.FC<{
[setTitle], [setTitle],
); );
const handleDescriptionChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLTextAreaElement>) => {
setDescription(value);
},
[setDescription],
);
const handleExclusiveChange = useCallback( const handleExclusiveChange = useCallback(
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => { ({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
setExclusive(checked); setExclusive(checked);
@ -116,6 +125,13 @@ const NewList: React.FC<{
[setExclusive], [setExclusive],
); );
const handleIsPublicChange = useCallback(
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
setIsPublic(checked);
},
[setIsPublic],
);
const handleRepliesPolicyChange = useCallback( const handleRepliesPolicyChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLSelectElement>) => { ({ target: { value } }: React.ChangeEvent<HTMLSelectElement>) => {
setRepliesPolicy(value as RepliesPolicyType); setRepliesPolicy(value as RepliesPolicyType);
@ -131,8 +147,10 @@ const NewList: React.FC<{
updateList({ updateList({
id, id,
title, title,
description,
exclusive, exclusive,
replies_policy: repliesPolicy, replies_policy: repliesPolicy,
type: isPublic ? 'public_list' : 'private_list',
}), }),
).then(() => { ).then(() => {
setSubmitting(false); setSubmitting(false);
@ -142,8 +160,10 @@ const NewList: React.FC<{
void dispatch( void dispatch(
createList({ createList({
title, title,
description,
exclusive, exclusive,
replies_policy: repliesPolicy, replies_policy: repliesPolicy,
type: isPublic ? 'public_list' : 'private_list',
}), }),
).then((result) => { ).then((result) => {
setSubmitting(false); setSubmitting(false);
@ -156,7 +176,17 @@ const NewList: React.FC<{
return ''; return '';
}); });
} }
}, [history, dispatch, setSubmitting, id, title, exclusive, repliesPolicy]); }, [
history,
dispatch,
setSubmitting,
id,
title,
description,
exclusive,
isPublic,
repliesPolicy,
]);
return ( return (
<Column <Column
@ -198,6 +228,28 @@ const NewList: React.FC<{
</div> </div>
</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='fields-group'>
<div className='input with_label'> <div className='input with_label'>
<div className='label_input'> <div className='label_input'>
@ -244,6 +296,32 @@ const NewList: React.FC<{
</div> </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'> <div className='fields-group'>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label className='app-form__toggle'> <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, Lists,
ListEdit, ListEdit,
ListMembers, ListMembers,
PublicList,
Blocks, Blocks,
DomainBlocks, DomainBlocks,
Mutes, Mutes,
@ -217,6 +218,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/lists/:id/edit' component={ListEdit} content={children} /> <WrappedRoute path='/lists/:id/edit' component={ListEdit} content={children} />
<WrappedRoute path='/lists/:id/members' component={ListMembers} content={children} /> <WrappedRoute path='/lists/:id/members' component={ListMembers} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} 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' component={Notifications} content={children} exact />
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact /> <WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} 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'); return import('../../list_timeline');
} }
export function PublicList () {
return import(/* webpackChunkName: "features/public_list" */'../../public_list');
}
export function Lists () { export function Lists () {
return import('../../lists'); return import('../../lists');
} }

View File

@ -1,6 +1,5 @@
// @ts-check // @ts-check
/** /**
* @typedef {[code: string, name: string, localName: string]} InitialStateLanguage * @typedef {[code: string, name: string, localName: string]} InitialStateLanguage
*/ */
@ -64,6 +63,7 @@
* @property {boolean=} critical_updates_pending * @property {boolean=} critical_updates_pending
* @property {InitialStateMeta} meta * @property {InitialStateMeta} meta
* @property {Role?} role * @property {Role?} role
* @property {string[]} features
*/ */
const element = document.getElementById('initial-state'); const element = document.getElementById('initial-state');
@ -140,4 +140,12 @@ export function getAccessToken() {
return getMeta('access_token'); return getMeta('access_token');
} }
/**
* @param {string} feature
* @returns {boolean}
*/
export function isFeatureEnabled(feature) {
return initialState?.features?.includes(feature) || false;
}
export default initialState; export default initialState;

View File

@ -219,6 +219,9 @@
"confirmations.delete_list.confirm": "Elimina", "confirmations.delete_list.confirm": "Elimina",
"confirmations.delete_list.message": "Segur que vols suprimir permanentment aquesta llista?", "confirmations.delete_list.message": "Segur que vols suprimir permanentment aquesta llista?",
"confirmations.delete_list.title": "Eliminar la llista?", "confirmations.delete_list.title": "Eliminar la llista?",
"confirmations.discard_draft.confirm": "Descarta i continua",
"confirmations.discard_draft.edit.cancel": "Continua l'edició",
"confirmations.discard_draft.post.cancel": "Reprendre l'esborrany",
"confirmations.discard_edit_media.confirm": "Descarta", "confirmations.discard_edit_media.confirm": "Descarta",
"confirmations.discard_edit_media.message": "Tens canvis no desats en la descripció del contingut o en la previsualització, els vols descartar?", "confirmations.discard_edit_media.message": "Tens canvis no desats en la descripció del contingut o en la previsualització, els vols descartar?",
"confirmations.follow_to_list.confirm": "Seguir i afegir a una llista", "confirmations.follow_to_list.confirm": "Seguir i afegir a una llista",
@ -792,6 +795,7 @@
"report_notification.categories.violation": "Violació de norma", "report_notification.categories.violation": "Violació de norma",
"report_notification.categories.violation_sentence": "violació de normes", "report_notification.categories.violation_sentence": "violació de normes",
"report_notification.open": "Obre l'informe", "report_notification.open": "Obre l'informe",
"search.clear": "Esborra la cerca",
"search.no_recent_searches": "No hi ha cerques recents", "search.no_recent_searches": "No hi ha cerques recents",
"search.placeholder": "Cerca", "search.placeholder": "Cerca",
"search.quick_action.account_search": "Perfils coincidint amb {x}", "search.quick_action.account_search": "Perfils coincidint amb {x}",

View File

@ -572,7 +572,7 @@
"navigation_bar.mutes": "Skjulte brugere", "navigation_bar.mutes": "Skjulte brugere",
"navigation_bar.opened_in_classic_interface": "Indlæg, konti og visse andre sider åbnes som standard i den klassiske webgrænseflade.", "navigation_bar.opened_in_classic_interface": "Indlæg, konti og visse andre sider åbnes som standard i den klassiske webgrænseflade.",
"navigation_bar.preferences": "Præferencer", "navigation_bar.preferences": "Præferencer",
"navigation_bar.privacy_and_reach": "Fortrolighed og udbredelse", "navigation_bar.privacy_and_reach": "Fortrolighed og rækkevidde",
"navigation_bar.search": "Søg", "navigation_bar.search": "Søg",
"navigation_bar.search_trends": "Søg/Trender", "navigation_bar.search_trends": "Søg/Trender",
"navigation_panel.collapse_followed_tags": "Sammenfold menuen Fulgte hashtags", "navigation_panel.collapse_followed_tags": "Sammenfold menuen Fulgte hashtags",

View File

@ -3,16 +3,31 @@ import { Record } from 'immutable';
import type { ApiListJSON } from 'mastodon/api_types/lists'; 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>; export type List = RecordOf<ListShape>;
const ListFactory = Record<ListShape>({ const ListFactory = Record<ListShape>({
id: '', id: '',
url: '',
title: '', title: '',
slug: '',
description: '',
type: 'private_list',
exclusive: false, exclusive: false,
replies_policy: 'list', replies_policy: 'list',
account_id: undefined,
created_at: '',
updated_at: '',
}); });
export function createList(attributes: Partial<ListShape>) { export const createList = (serverJSON: ApiListJSON): List => {
return ListFactory(attributes); 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 { .column-header__buttons {
height: 48px;
display: flex; display: flex;
} }
@ -4542,6 +4541,16 @@ a.status-card {
color: $dark-text-color; color: $dark-text-color;
cursor: default; 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 { .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 { .lists__item {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -158,7 +158,7 @@ class FeedManager
timeline_key = key(:list, list.id) timeline_key = key(:list, list.id)
aggregate = list.account.user&.aggregates_reblogs? 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 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 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 # @param [List] list
# @return [Boolean] # @return [Boolean]
def filter_from_list?(status, list) 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 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 = 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 should_filter &&= !list.show_followed? # List show_followed? is false

View File

@ -10,12 +10,6 @@ module DatabaseViewRecord
concurrently: true, concurrently: true,
cascade: false cascade: false
) )
rescue ActiveRecord::StatementInvalid
Scenic.database.refresh_materialized_view(
table_name,
concurrently: false,
cascade: false
)
end end
end end

View File

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

View File

@ -139,6 +139,9 @@ class Status < ApplicationRecord
scope :tagged_with_none, lambda { |tag_ids| 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) 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_create_commit :trigger_create_webhooks
after_update_commit :trigger_update_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

@ -5,7 +5,7 @@ class InitialStateSerializer < ActiveModel::Serializer
attributes :meta, :compose, :accounts, attributes :meta, :compose, :accounts,
:media_attachments, :settings, :media_attachments, :settings,
:languages :languages, :features
attribute :critical_updates_pending, if: -> { object&.role&.can?(:view_devops) && SoftwareUpdate.check_enabled? } attribute :critical_updates_pending, if: -> { object&.role&.can?(:view_devops) && SoftwareUpdate.check_enabled? }
@ -85,6 +85,10 @@ class InitialStateSerializer < ActiveModel::Serializer
LanguagesHelper::SUPPORTED_LOCALES.map { |(key, value)| [key, value[0], value[1]] } LanguagesHelper::SUPPORTED_LOCALES.map { |(key, value)| [key, value[0], value[1]] }
end end
def features
Mastodon::Feature.enabled_features
end
private private
def default_meta_store def default_meta_store

View File

@ -1,9 +1,20 @@
# frozen_string_literal: true # frozen_string_literal: true
class REST::ListSerializer < ActiveModel::Serializer 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 def id
object.id.to_s object.id.to_s
end end
def url
public_list_url(object.to_url_param)
end
end end

View File

@ -25,6 +25,6 @@
.rules-list__hint= translation.hint .rules-list__hint= translation.hint
.stacked-actions .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.accept'), accept_path, class: 'button'
= link_to t('auth.rules.back'), root_path, class: 'button button-tertiary' = 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

@ -578,6 +578,11 @@ ca:
all: Totes all: Totes
limited: Limitades limited: Limitades
title: Moderació title: Moderació
moderation_notes:
create: Afegeix una nota de moderació
created_msg: S'ha creat la nota de moderació d'instància.
destroyed_msg: S'ha esborrat la nota de moderació d'instància.
title: Notes de moderació
private_comment: Comentari privat private_comment: Comentari privat
public_comment: Comentari públic public_comment: Comentari públic
purge: Purga purge: Purga
@ -1339,6 +1344,10 @@ ca:
basic_information: Informació bàsica basic_information: Informació bàsica
hint_html: "<strong>Personalitza el que la gent veu en el teu perfil públic i a prop dels teus tuts..</strong> És més probable que altres persones et segueixin i interaccionin amb tu quan tens emplenat el teu perfil i amb la teva imatge." hint_html: "<strong>Personalitza el que la gent veu en el teu perfil públic i a prop dels teus tuts..</strong> És més probable que altres persones et segueixin i interaccionin amb tu quan tens emplenat el teu perfil i amb la teva imatge."
other: Altres other: Altres
emoji_styles:
auto: Automàtic
native: Nadiu
twemoji: Twemoji
errors: errors:
'400': La sol·licitud que vas emetre no era vàlida o no era correcta. '400': La sol·licitud que vas emetre no era vàlida o no era correcta.
'403': No tens permís per a veure aquesta pàgina. '403': No tens permís per a veure aquesta pàgina.

View File

@ -653,7 +653,7 @@ da:
mark_as_sensitive_description_html: Medierne i det anmeldte indlæg markeres som sensitive, og en advarsel (strike) registreres mhp. eskalering ved evt. fremtidige overtrædelser fra samme konto. mark_as_sensitive_description_html: Medierne i det anmeldte indlæg markeres som sensitive, og en advarsel (strike) registreres mhp. eskalering ved evt. fremtidige overtrædelser fra samme konto.
other_description_html: Se flere muligheder for at kontrollere kontoens adfærd og tilpasse kommunikationen til den anmeldte konto. other_description_html: Se flere muligheder for at kontrollere kontoens adfærd og tilpasse kommunikationen til den anmeldte konto.
resolve_description_html: Ingen foranstaltninger træffes mod den anmeldte konto, ingen advarsel (strike) registreres og anmeldelsen lukkes. resolve_description_html: Ingen foranstaltninger træffes mod den anmeldte konto, ingen advarsel (strike) registreres og anmeldelsen lukkes.
silence_description_html: Kontoen vil kun være synlig for følgerene eller dem, som manuelt slå den op, hvilket markant begrænser dens udbredelse. Kan altid omgøres. Lukker alle indrapporteringer af kontoen. silence_description_html: Kontoen vil kun være synlig for dem, der allerede følger den eller manuelt slår den op, hvilket alvorligt begrænser dens rækkevidde. Kan altid omgøres. Lukker alle indrapporteringer af denne konto.
suspend_description_html: Kontoen inkl. alt indhold utilgængeliggøres og interaktion umuliggøres, og den slettes på et tidspunkt. Kan omgøres inden for 30 dage. Lukker alle indrapporteringer af kontoen. suspend_description_html: Kontoen inkl. alt indhold utilgængeliggøres og interaktion umuliggøres, og den slettes på et tidspunkt. Kan omgøres inden for 30 dage. Lukker alle indrapporteringer af kontoen.
actions_description_html: Afgør, hvilke foranstaltning, der skal træffes for at løse denne anmeldelse. Ved en straffende foranstaltning mod den anmeldte konto, fremsendes en e-mailnotifikation, undtagen når kategorien <strong>Spam</strong> er valgt. actions_description_html: Afgør, hvilke foranstaltning, der skal træffes for at løse denne anmeldelse. Ved en straffende foranstaltning mod den anmeldte konto, fremsendes en e-mailnotifikation, undtagen når kategorien <strong>Spam</strong> er valgt.
actions_description_remote_html: Fastslå en nødvendig handling mhp. at løse denne anmeldelse. Dette vil kun påvirke <strong>din</strong> servers kommunikation med, og indholdshåndtering for, fjernkontoen. actions_description_remote_html: Fastslå en nødvendig handling mhp. at løse denne anmeldelse. Dette vil kun påvirke <strong>din</strong> servers kommunikation med, og indholdshåndtering for, fjernkontoen.
@ -1266,8 +1266,8 @@ da:
user_privacy_agreement_html: Jeg accepterer <a href="%{privacy_policy_path}" target="_blank">fortrolighedspolitikken</a> user_privacy_agreement_html: Jeg accepterer <a href="%{privacy_policy_path}" target="_blank">fortrolighedspolitikken</a>
author_attribution: author_attribution:
example_title: Eksempeltekst example_title: Eksempeltekst
hint_html: Skriver du nyheder eller blogartikler uden for Mastodon? Styr, hvordan man bliver krediteret, når disse deles på Mastodon. hint_html: Skriver du nyheder eller blogartikler uden for Mastodon? Styr, hvordan du bliver krediteret, når de bliver delt på Mastodon.
instructions: 'Sørg for, at denne kode er i artikelens HTML:' instructions: 'Sørg for, at denne kode er i din artikels HTML:'
more_from_html: Flere fra %{name} more_from_html: Flere fra %{name}
s_blog: "%{name}s blog" s_blog: "%{name}s blog"
then_instructions: Tilføj dernæst publikationsdomænenavnet i feltet nedenfor. then_instructions: Tilføj dernæst publikationsdomænenavnet i feltet nedenfor.
@ -1718,11 +1718,11 @@ da:
hint_html: "<strong>Tilpas hvordan din profil og dine indlæg kan findes.</strong> En række funktioner i Mastodon kan hjælpe dig med at nå ud til et bredere publikum, hvis du aktiverer dem. Tjek indstillingerne herunder for at sikre, at de passer til dit brugsscenarie." hint_html: "<strong>Tilpas hvordan din profil og dine indlæg kan findes.</strong> En række funktioner i Mastodon kan hjælpe dig med at nå ud til et bredere publikum, hvis du aktiverer dem. Tjek indstillingerne herunder for at sikre, at de passer til dit brugsscenarie."
privacy: Privatliv privacy: Privatliv
privacy_hint_html: Styr, hvor meget der ønskes synliggjort til gavn for andre. Folk finder interessante profiler og apps ved at tjekke andres følgere ud, samt se hvilke apps de sender fra, men dine præferencer ønskes muligvis ikke synliggjort. privacy_hint_html: Styr, hvor meget der ønskes synliggjort til gavn for andre. Folk finder interessante profiler og apps ved at tjekke andres følgere ud, samt se hvilke apps de sender fra, men dine præferencer ønskes muligvis ikke synliggjort.
reach: Udbredelse reach: Rækkevidde
reach_hint_html: Indstil om du vil blive opdaget og fulgt af nye mennesker. Ønsker du, at dine indlæg skal vises på Udforsk-siden? Ønsker du, at andre skal se dig i deres følg-anbefalinger? Ønsker du at acceptere alle nye følgere automatisk, eller vil du have detaljeret kontrol over hver og en? reach_hint_html: Indstil om du vil blive opdaget og fulgt af nye mennesker. Ønsker du, at dine indlæg skal vises på Udforsk-siden? Ønsker du, at andre skal se dig i deres følg-anbefalinger? Ønsker du at acceptere alle nye følgere automatisk, eller vil du have detaljeret kontrol over hver og en?
search: Søg search: Søgning
search_hint_html: Indstil hvordan du vil findes. Ønsker du, at folk skal finde dig gennem hvad du har skrevet offentligt? Vil du have folk udenfor Mastodon til at finde din profil, når de søger på nettet? Vær opmærksom på, at det ikke kan garanteres at dine offentlige indlæg er udelukket fra alle søgemaskiner. search_hint_html: Indstil hvordan du vil findes. Ønsker du, at folk skal finde dig gennem hvad du har skrevet offentligt? Vil du have folk udenfor Mastodon til at finde din profil, når de søger på nettet? Vær opmærksom på, at det ikke kan garanteres at dine offentlige indlæg er udelukket fra alle søgemaskiner.
title: Fortrolighed og udbredelse title: Fortrolighed og rækkevidde
privacy_policy: privacy_policy:
title: Privatlivspolitik title: Privatlivspolitik
reactions: reactions:
@ -1923,7 +1923,7 @@ da:
'7889238': 3 måneder '7889238': 3 måneder
min_age_label: Alderstærskel min_age_label: Alderstærskel
min_favs: Behold indlæg favoritmarkeret mindst min_favs: Behold indlæg favoritmarkeret mindst
min_favs_hint: Sletter ingen dine egne indlæg, som har modtaget minimum dette antal favoritmarkeringer. Lad stå tomt for at slette indlæg uanset antal favoritmarkeringer min_favs_hint: Sletter ingen af dine egne indlæg, som har modtaget minimum dette antal favoritmarkeringer. Lad stå tom for at slette indlæg uanset antal favoritmarkeringer
min_reblogs: Behold indlæg fremhævet mindst min_reblogs: Behold indlæg fremhævet mindst
min_reblogs_hint: Sletter ingen af dine egne indlæg, som er fremhævet flere end dette antal gange. Lad stå tom for at slette indlæg uanset antallet af fremhævelser min_reblogs_hint: Sletter ingen af dine egne indlæg, som er fremhævet flere end dette antal gange. Lad stå tom for at slette indlæg uanset antallet af fremhævelser
stream_entries: stream_entries:
@ -2095,7 +2095,7 @@ da:
verification: verification:
extra_instructions_html: <strong>Tip:</strong> Linket på din hjemmeside kan være usynligt. Den vigtige del er <code>rel="me"</code> , som forhindrer impersonation på websteder med brugergenereret indhold. Du kan endda bruge et <code>link</code> tag i overskriften på siden i stedet for <code>a</code>, men HTML skal være tilgængelig uden at udføre JavaScript. extra_instructions_html: <strong>Tip:</strong> Linket på din hjemmeside kan være usynligt. Den vigtige del er <code>rel="me"</code> , som forhindrer impersonation på websteder med brugergenereret indhold. Du kan endda bruge et <code>link</code> tag i overskriften på siden i stedet for <code>a</code>, men HTML skal være tilgængelig uden at udføre JavaScript.
here_is_how: Sådan gør du here_is_how: Sådan gør du
hint_html: "<strong>Bekræftelse af din identitet på Mastodon er for alle.</strong> Baseret på åbne webstandarder, nu og for evigt gratis. Alt du behøver er en personlig hjemmeside, som folk genkende dig ved. Når du linker til denne hjemmeside fra din profil, vi vil kontrollere, at hjemmesiden linker tilbage til din profil og vise en visuel indikator på det." hint_html: "<strong>Verificering af din identitet på Mastodon er for alle.</strong> Baseret på åbne webstandarder, nu og for altid gratis. Alt, hvad du behøver, er en personlig hjemmeside, som folk kender dig fra. Når du linker til denne hjemmeside fra din profil, kontrollerer vi, at hjemmesiden linker tilbage til din profil, og viser en visuel indikator på den."
instructions_html: Kopier og indsæt koden nedenfor i HTML på din hjemmeside. Tilføj derefter adressen på din hjemmeside i et af de ekstra felter på din profil på fanen "Redigér profil" og gem ændringer. instructions_html: Kopier og indsæt koden nedenfor i HTML på din hjemmeside. Tilføj derefter adressen på din hjemmeside i et af de ekstra felter på din profil på fanen "Redigér profil" og gem ændringer.
verification: Bekræftelse verification: Bekræftelse
verified_links: Dine bekræftede links verified_links: Dine bekræftede links

View File

@ -1349,6 +1349,10 @@ hu:
basic_information: Általános információk basic_information: Általános információk
hint_html: "<strong>Tedd egyedivé, mi látnak mások a profilodon és a bejegyzéseid mellett.</strong> Mások nagyobb eséllyel követnek vissza és lépnek veled kapcsolatba, ha van kitöltött profilod és profilképed." hint_html: "<strong>Tedd egyedivé, mi látnak mások a profilodon és a bejegyzéseid mellett.</strong> Mások nagyobb eséllyel követnek vissza és lépnek veled kapcsolatba, ha van kitöltött profilod és profilképed."
other: Egyéb other: Egyéb
emoji_styles:
auto: Automatikus
native: Natív
twemoji: Twemoji
errors: errors:
'400': A küldött kérés érvénytelen vagy hibás volt. '400': A küldött kérés érvénytelen vagy hibás volt.
'403': Nincs jogosultságod az oldal megtekintéséhez. '403': Nincs jogosultságod az oldal megtekintéséhez.

View File

@ -61,6 +61,7 @@ ca:
setting_display_media_default: Amaga el contingut gràfic marcat com a sensible setting_display_media_default: Amaga el contingut gràfic marcat com a sensible
setting_display_media_hide_all: Oculta sempre tot el contingut multimèdia setting_display_media_hide_all: Oculta sempre tot el contingut multimèdia
setting_display_media_show_all: Mostra sempre el contingut gràfic setting_display_media_show_all: Mostra sempre el contingut gràfic
setting_emoji_style: Com mostrar els emojis. "Automàtic" provarà de fer servir els emojis nadius, però revertirà a twemojis en els navegadors antics.
setting_system_scrollbars_ui: S'aplica només als navegadors d'escriptori basats en Safari i Chrome setting_system_scrollbars_ui: S'aplica només als navegadors d'escriptori basats en Safari i Chrome
setting_use_blurhash: Els degradats es basen en els colors de les imatges ocultes, però n'enfosqueixen els detalls setting_use_blurhash: Els degradats es basen en els colors de les imatges ocultes, però n'enfosqueixen els detalls
setting_use_pending_items: Amaga les actualitzacions de la línia de temps després de fer un clic, en lloc de desplaçar-les automàticament setting_use_pending_items: Amaga les actualitzacions de la línia de temps després de fer un clic, en lloc de desplaçar-les automàticament
@ -240,6 +241,7 @@ ca:
setting_display_media_default: Per defecte setting_display_media_default: Per defecte
setting_display_media_hide_all: Amaga-ho tot setting_display_media_hide_all: Amaga-ho tot
setting_display_media_show_all: Mostra-ho tot setting_display_media_show_all: Mostra-ho tot
setting_emoji_style: Estil d'emojis
setting_expand_spoilers: Desplega sempre els tuts marcats amb advertències de contingut setting_expand_spoilers: Desplega sempre els tuts marcats amb advertències de contingut
setting_hide_network: Amaga la teva xarxa setting_hide_network: Amaga la teva xarxa
setting_missing_alt_text_modal: Mostra un diàleg de confirmació abans de publicar contingut sense text alternatiu setting_missing_alt_text_modal: Mostra un diàleg de confirmació abans de publicar contingut sense text alternatiu

View File

@ -61,6 +61,7 @@ hu:
setting_display_media_default: Kényes tartalomnak jelölt média elrejtése setting_display_media_default: Kényes tartalomnak jelölt média elrejtése
setting_display_media_hide_all: Média elrejtése mindig setting_display_media_hide_all: Média elrejtése mindig
setting_display_media_show_all: Média megjelenítése mindig setting_display_media_show_all: Média megjelenítése mindig
setting_emoji_style: Az emodzsik megjelenítési módja. Az „Automatikus” megpróbálja a natív emodzsikat használni, de az örökölt böngészők esetén a Twemojira vált vissza.
setting_system_scrollbars_ui: Csak Chrome és Safari alapú asztali böngészőkre vonatkozik setting_system_scrollbars_ui: Csak Chrome és Safari alapú asztali böngészőkre vonatkozik
setting_use_blurhash: A kihomályosítás az eredeti képből történik, de minden részletet elrejt setting_use_blurhash: A kihomályosítás az eredeti képből történik, de minden részletet elrejt
setting_use_pending_items: Idővonal frissítése csak kattintásra automatikus görgetés helyett setting_use_pending_items: Idővonal frissítése csak kattintásra automatikus görgetés helyett
@ -241,6 +242,7 @@ hu:
setting_display_media_default: Alapértelmezés setting_display_media_default: Alapértelmezés
setting_display_media_hide_all: Mindent elrejt setting_display_media_hide_all: Mindent elrejt
setting_display_media_show_all: Mindent mutat setting_display_media_show_all: Mindent mutat
setting_emoji_style: Emodzsistílus
setting_expand_spoilers: Tartalmi figyelmeztetéssel ellátott bejegyzések automatikus kinyitása setting_expand_spoilers: Tartalmi figyelmeztetéssel ellátott bejegyzések automatikus kinyitása
setting_hide_network: Hálózatod elrejtése setting_hide_network: Hálózatod elrejtése
setting_missing_alt_text_modal: Megerősítési párbeszédablak megjelenítése a helyettesítő szöveg nélküli média közzététele előtt setting_missing_alt_text_modal: Megerősítési párbeszédablak megjelenítése a helyettesítő szöveg nélküli média közzététele előtt

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 '/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 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 :authorize_interaction, only: [:show]
resource :share, 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 resources :lists, only: [:index, :create, :show, :update, :destroy] do
resource :accounts, only: [:show, :create, :destroy], module: :lists resource :accounts, only: [:show, :create, :destroy], module: :lists
resource :follow, only: [:create], module: :lists
end end
namespace :featured_tags do 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.boolean "hide_collections"
t.integer "avatar_storage_schema_version" t.integer "avatar_storage_schema_version"
t.integer "header_storage_schema_version" t.integer "header_storage_schema_version"
t.datetime "sensitized_at", precision: nil
t.integer "suspension_origin" t.integer "suspension_origin"
t.datetime "sensitized_at", precision: nil
t.boolean "trendable" t.boolean "trendable"
t.datetime "reviewed_at", precision: nil t.datetime "reviewed_at", precision: nil
t.datetime "requested_review_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 end
create_table "ip_blocks", force: :cascade do |t| 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 "created_at", precision: nil, null: false
t.datetime "updated_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 t.index ["ip"], name: "index_ip_blocks_on_ip", unique: true
end 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.datetime "updated_at", precision: nil, null: false
t.integer "replies_policy", default: 0, null: false t.integer "replies_policy", default: 0, null: false
t.boolean "exclusive", default: false, 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" t.index ["account_id"], name: "index_lists_on_account_id"
end 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 add_index "instances", ["domain"], name: "index_instances_on_domain", unique: true
create_view "user_ips", sql_definition: <<-SQL create_view "user_ips", sql_definition: <<-SQL
SELECT user_id, SELECT t0.user_id,
ip, t0.ip,
max(used_at) AS used_at max(t0.used_at) AS used_at
FROM ( SELECT users.id AS user_id, FROM ( SELECT users.id AS user_id,
users.sign_up_ip AS ip, users.sign_up_ip AS ip,
users.created_at AS used_at 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 login_activities.created_at
FROM login_activities FROM login_activities
WHERE (login_activities.success = true)) t0 WHERE (login_activities.success = true)) t0
GROUP BY user_id, ip; GROUP BY t0.user_id, t0.ip;
SQL SQL
create_view "account_summaries", materialized: true, sql_definition: <<-SQL create_view "account_summaries", materialized: true, sql_definition: <<-SQL
SELECT accounts.id AS account_id, 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 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 create_view "global_follow_recommendations", materialized: true, sql_definition: <<-SQL
SELECT account_id, SELECT t0.account_id,
sum(rank) AS rank, sum(t0.rank) AS rank,
array_agg(reason) AS reason array_agg(t0.reason) AS reason
FROM ( SELECT account_summaries.account_id, FROM ( SELECT account_summaries.account_id,
((count(follows.id))::numeric / (1.0 + (count(follows.id))::numeric)) AS rank, ((count(follows.id))::numeric / (1.0 + (count(follows.id))::numeric)) AS rank,
'most_followed'::text AS reason '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))))) WHERE (follow_recommendation_suppressions.account_id = statuses.account_id)))))
GROUP BY account_summaries.account_id GROUP BY account_summaries.account_id
HAVING (sum((status_stats.reblogs_count + status_stats.favourites_count)) >= (5)::numeric)) t0 HAVING (sum((status_stats.reblogs_count + status_stats.favourites_count)) >= (5)::numeric)) t0
GROUP BY account_id GROUP BY t0.account_id
ORDER BY (sum(rank)) DESC; ORDER BY (sum(t0.rank)) DESC;
SQL SQL
add_index "global_follow_recommendations", ["account_id"], name: "index_global_follow_recommendations_on_account_id", unique: true add_index "global_follow_recommendations", ["account_id"], name: "index_global_follow_recommendations_on_account_id", unique: true