mirror of
https://github.com/mastodon/mastodon.git
synced 2025-07-12 15:33:14 +00:00
Compare commits
5 Commits
7c335688c9
...
0dfa6ee7e8
Author | SHA1 | Date | |
---|---|---|---|
![]() |
0dfa6ee7e8 | ||
![]() |
94bceb8683 | ||
![]() |
88b0f3a172 | ||
![]() |
b69b5ba775 | ||
![]() |
9b56d00b5c |
|
@ -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
|
||||
|
|
18
app/controllers/api/v1/lists/follows_controller.rb
Normal file
18
app/controllers/api/v1/lists/follows_controller.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
15
app/controllers/lists_controller.rb
Normal file
15
app/controllers/lists_controller.rb
Normal 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
|
|
@ -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`);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'>
|
||||
|
|
120
app/javascript/mastodon/features/public_list/components/hero.tsx
Normal file
120
app/javascript/mastodon/features/public_list/components/hero.tsx
Normal 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>
|
||||
);
|
||||
};
|
136
app/javascript/mastodon/features/public_list/index.tsx
Normal file
136
app/javascript/mastodon/features/public_list/index.tsx
Normal 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;
|
48
app/javascript/mastodon/features/public_list/members.tsx
Normal file
48
app/javascript/mastodon/features/public_list/members.tsx
Normal 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>
|
||||
);
|
||||
};
|
35
app/javascript/mastodon/features/public_list/statuses.tsx
Normal file
35
app/javascript/mastodon/features/public_list/statuses.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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 />
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
// @ts-check
|
||||
|
||||
|
||||
/**
|
||||
* @typedef {[code: string, name: string, localName: string]} InitialStateLanguage
|
||||
*/
|
||||
|
@ -64,6 +63,7 @@
|
|||
* @property {boolean=} critical_updates_pending
|
||||
* @property {InitialStateMeta} meta
|
||||
* @property {Role?} role
|
||||
* @property {string[]} features
|
||||
*/
|
||||
|
||||
const element = document.getElementById('initial-state');
|
||||
|
@ -140,4 +140,12 @@ export function getAccessToken() {
|
|||
return getMeta('access_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} feature
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isFeatureEnabled(feature) {
|
||||
return initialState?.features?.includes(feature) || false;
|
||||
}
|
||||
|
||||
export default initialState;
|
||||
|
|
|
@ -219,6 +219,9 @@
|
|||
"confirmations.delete_list.confirm": "Elimina",
|
||||
"confirmations.delete_list.message": "Segur que vols suprimir permanentment aquesta 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.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",
|
||||
|
@ -792,6 +795,7 @@
|
|||
"report_notification.categories.violation": "Violació de norma",
|
||||
"report_notification.categories.violation_sentence": "violació de normes",
|
||||
"report_notification.open": "Obre l'informe",
|
||||
"search.clear": "Esborra la cerca",
|
||||
"search.no_recent_searches": "No hi ha cerques recents",
|
||||
"search.placeholder": "Cerca",
|
||||
"search.quick_action.account_search": "Perfils coincidint amb {x}",
|
||||
|
|
|
@ -572,7 +572,7 @@
|
|||
"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.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_trends": "Søg/Trender",
|
||||
"navigation_panel.collapse_followed_tags": "Sammenfold menuen Fulgte hashtags",
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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 |
1
app/javascript/material-icons/400-24px/package_2.svg
Normal file
1
app/javascript/material-icons/400-24px/package_2.svg
Normal 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 |
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -10,12 +10,6 @@ module DatabaseViewRecord
|
|||
concurrently: true,
|
||||
cascade: false
|
||||
)
|
||||
rescue ActiveRecord::StatementInvalid
|
||||
Scenic.database.refresh_materialized_view(
|
||||
table_name,
|
||||
concurrently: false,
|
||||
cascade: false
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -139,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
|
||||
|
|
21
app/policies/list_policy.rb
Normal file
21
app/policies/list_policy.rb
Normal 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
|
|
@ -5,7 +5,7 @@ class InitialStateSerializer < ActiveModel::Serializer
|
|||
|
||||
attributes :meta, :compose, :accounts,
|
||||
:media_attachments, :settings,
|
||||
:languages
|
||||
:languages, :features
|
||||
|
||||
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]] }
|
||||
end
|
||||
|
||||
def features
|
||||
Mastodon::Feature.enabled_features
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_meta_store
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
7
app/views/lists/_og.html.haml
Normal file
7
app/views/lists/_og.html.haml
Normal 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'
|
6
app/views/lists/show.html.haml
Normal file
6
app/views/lists/show.html.haml
Normal 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'
|
18
app/workers/follow_from_public_list_worker.rb
Normal file
18
app/workers/follow_from_public_list_worker.rb
Normal 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
|
|
@ -578,6 +578,11 @@ ca:
|
|||
all: Totes
|
||||
limited: Limitades
|
||||
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
|
||||
public_comment: Comentari públic
|
||||
purge: Purga
|
||||
|
@ -1339,6 +1344,10 @@ ca:
|
|||
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."
|
||||
other: Altres
|
||||
emoji_styles:
|
||||
auto: Automàtic
|
||||
native: Nadiu
|
||||
twemoji: Twemoji
|
||||
errors:
|
||||
'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.
|
||||
|
|
|
@ -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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
|
@ -1266,8 +1266,8 @@ da:
|
|||
user_privacy_agreement_html: Jeg accepterer <a href="%{privacy_policy_path}" target="_blank">fortrolighedspolitikken</a>
|
||||
author_attribution:
|
||||
example_title: Eksempeltekst
|
||||
hint_html: Skriver du nyheder eller blogartikler uden for Mastodon? Styr, hvordan man bliver krediteret, når disse deles på Mastodon.
|
||||
instructions: 'Sørg for, at denne kode er i artikelens HTML:'
|
||||
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 din artikels HTML:'
|
||||
more_from_html: Flere fra %{name}
|
||||
s_blog: "%{name}s blog"
|
||||
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."
|
||||
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.
|
||||
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?
|
||||
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.
|
||||
title: Fortrolighed og udbredelse
|
||||
title: Fortrolighed og rækkevidde
|
||||
privacy_policy:
|
||||
title: Privatlivspolitik
|
||||
reactions:
|
||||
|
@ -1923,7 +1923,7 @@ da:
|
|||
'7889238': 3 måneder
|
||||
min_age_label: Alderstærskel
|
||||
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_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:
|
||||
|
@ -2095,7 +2095,7 @@ da:
|
|||
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.
|
||||
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.
|
||||
verification: Bekræftelse
|
||||
verified_links: Dine bekræftede links
|
||||
|
|
|
@ -1349,6 +1349,10 @@ hu:
|
|||
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."
|
||||
other: Egyéb
|
||||
emoji_styles:
|
||||
auto: Automatikus
|
||||
native: Natív
|
||||
twemoji: Twemoji
|
||||
errors:
|
||||
'400': A küldött kérés érvénytelen vagy hibás volt.
|
||||
'403': Nincs jogosultságod az oldal megtekintéséhez.
|
||||
|
|
|
@ -61,6 +61,7 @@ ca:
|
|||
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_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_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
|
||||
|
@ -240,6 +241,7 @@ ca:
|
|||
setting_display_media_default: Per defecte
|
||||
setting_display_media_hide_all: Amaga-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_hide_network: Amaga la teva xarxa
|
||||
setting_missing_alt_text_modal: Mostra un diàleg de confirmació abans de publicar contingut sense text alternatiu
|
||||
|
|
|
@ -61,6 +61,7 @@ hu:
|
|||
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_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_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
|
||||
|
@ -241,6 +242,7 @@ hu:
|
|||
setting_display_media_default: Alapértelmezés
|
||||
setting_display_media_hide_all: Mindent elrejt
|
||||
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_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
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
8
db/migrate/20241126222644_add_type_to_lists.rb
Normal file
8
db/migrate/20241126222644_add_type_to_lists.rb
Normal 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
|
30
db/schema.rb
30
db/schema.rb
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user