mirror of
https://github.com/mastodon/mastodon.git
synced 2025-07-14 16:28:16 +00:00
Compare commits
12 Commits
87b2c13349
...
7c335688c9
Author | SHA1 | Date | |
---|---|---|---|
![]() |
7c335688c9 | ||
![]() |
c442589593 | ||
![]() |
28633a504a | ||
![]() |
ad78701b6f | ||
![]() |
1496488771 | ||
![]() |
dd3d958e75 | ||
![]() |
b363a3651d | ||
![]() |
86645fc14c | ||
![]() |
f9beecb343 | ||
![]() |
4ecfbd3920 | ||
![]() |
a315934314 | ||
![]() |
9b56d00b5c |
|
@ -365,7 +365,7 @@ GEM
|
|||
json-ld-preloaded (3.3.1)
|
||||
json-ld (~> 3.3)
|
||||
rdf (~> 3.3)
|
||||
json-schema (5.1.1)
|
||||
json-schema (5.2.1)
|
||||
addressable (~> 2.8)
|
||||
bigdecimal (~> 3.1)
|
||||
jsonapi-renderer (0.2.2)
|
||||
|
@ -761,7 +761,7 @@ GEM
|
|||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 9)
|
||||
rspec-support (3.13.4)
|
||||
rubocop (1.77.0)
|
||||
rubocop (1.78.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
|
|
|
@ -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
|
|
@ -66,7 +66,7 @@ module ApplicationHelper
|
|||
|
||||
def provider_sign_in_link(provider)
|
||||
label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize)
|
||||
link_to label, omniauth_authorize_path(:user, provider), class: "button button-#{provider}", method: :post
|
||||
link_to label, omniauth_authorize_path(:user, provider), class: "btn button-#{provider}", method: :post
|
||||
end
|
||||
|
||||
def locale_direction
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -386,7 +386,7 @@
|
|||
"follow_suggestions.similar_to_recently_followed_longer": "Minder om profiler, du har fulgt for nylig",
|
||||
"follow_suggestions.view_all": "Vis alle",
|
||||
"follow_suggestions.who_to_follow": "Hvem, som skal følges",
|
||||
"followed_tags": "Hashtag, som følges",
|
||||
"followed_tags": "Hashtags, som følges",
|
||||
"footer.about": "Om",
|
||||
"footer.directory": "Profiloversigt",
|
||||
"footer.get_app": "Hent appen",
|
||||
|
@ -560,7 +560,7 @@
|
|||
"navigation_bar.favourites": "Favoritter",
|
||||
"navigation_bar.filters": "Skjulte ord",
|
||||
"navigation_bar.follow_requests": "Følgeanmodninger",
|
||||
"navigation_bar.followed_tags": "Hashtag, som følges",
|
||||
"navigation_bar.followed_tags": "Hashtags, som følges",
|
||||
"navigation_bar.follows_and_followers": "Følges og følgere",
|
||||
"navigation_bar.import_export": "Import og eksport",
|
||||
"navigation_bar.lists": "Lister",
|
||||
|
|
|
@ -569,6 +569,7 @@
|
|||
"notification.admin.sign_up.name_and_others": "{name} eta {count, plural, one {erabiltzaile # gehiago} other {# erabiltzaile gehiago}} erregistratu dira",
|
||||
"notification.favourite": "{name}(e)k zure bidalketa gogoko du",
|
||||
"notification.favourite.name_and_others_with_link": "{name} eta <a>{count, plural, one {erabiltzaile # gehiagok} other {# erabiltzaile gehiagok}}</a> zure bidalketa gogoko dute",
|
||||
"notification.favourite_pm": "{name}-ek zure aipamen pribatua gogokoetan jarri du",
|
||||
"notification.follow": "{name}(e)k jarraitzen dizu",
|
||||
"notification.follow_request": "{name}(e)k zu jarraitzeko eskaera egin du",
|
||||
"notification.follow_request.name_and_others": "{name} eta {count, plural, one {erabiltzaile # gehiagok} other {# erabiltzaile gehiagok}} zu jarraitzeko eskaera egin dute",
|
||||
|
@ -902,5 +903,7 @@
|
|||
"video.hide": "Ezkutatu bideoa",
|
||||
"video.pause": "Pausatu",
|
||||
"video.play": "Jo",
|
||||
"video.unmute": "Soinua ezarri",
|
||||
"video.volume_down": "Bolumena jaitsi",
|
||||
"video.volume_up": "Bolumena Igo"
|
||||
}
|
||||
|
|
|
@ -356,6 +356,7 @@
|
|||
"hashtag.counter_by_accounts": "{count, plural, one {{counter} partisipante} other {{counter} partisipantes}}",
|
||||
"hashtag.counter_by_uses": "{count, plural, one {{counter} publikasyon} other {{counter} publikasyones}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} publikasyon} other {{counter} publikasyones}} oy",
|
||||
"hashtag.feature": "Avalia en profil",
|
||||
"hashtag.follow": "Sige etiketa",
|
||||
"hashtag.mute": "Silensia #{hashtag}",
|
||||
"hashtag.unfeature": "No avalia en profil",
|
||||
|
@ -390,6 +391,7 @@
|
|||
"interaction_modal.title.reblog": "Repartaja publikasyon de {name}",
|
||||
"interaction_modal.title.reply": "Arisponde a publikasyon de {name}",
|
||||
"interaction_modal.title.vote": "Vota en la anketa de {name}",
|
||||
"interaction_modal.username_prompt": "Por enshemplo {example}",
|
||||
"intervals.full.days": "{number, plural, one {# diya} other {# diyas}}",
|
||||
"intervals.full.hours": "{number, plural, one {# ora} other {# oras}}",
|
||||
"intervals.full.minutes": "{number, plural, one {# minuto} other {# minutos}}",
|
||||
|
@ -442,6 +444,7 @@
|
|||
"lists.delete": "Efasa lista",
|
||||
"lists.done": "Fecho",
|
||||
"lists.edit": "Edita lista",
|
||||
"lists.list_name": "Nombre de lista",
|
||||
"lists.new_list_name": "Nombre de mueva lista",
|
||||
"lists.replies_policy.followed": "Kualseker utilizador segido",
|
||||
"lists.replies_policy.list": "Miembros de la lista",
|
||||
|
@ -738,6 +741,7 @@
|
|||
"status.reblogs.empty": "Ainda nadie tiene repartajado esta publikasyon. Kuando algien lo aga, se amostrara aki.",
|
||||
"status.redraft": "Efasa i eskrive de muevo",
|
||||
"status.remove_bookmark": "Kita markador",
|
||||
"status.remove_favourite": "Kita de los favoritos",
|
||||
"status.replied_in_thread": "Arispondo en filo",
|
||||
"status.replied_to": "Arispondio a {name}",
|
||||
"status.reply": "Arisponde",
|
||||
|
@ -758,6 +762,7 @@
|
|||
"subscribed_languages.save": "Guadra trokamientos",
|
||||
"subscribed_languages.target": "Troka linguas abonadas para {target}",
|
||||
"tabs_bar.home": "Linya prinsipala",
|
||||
"tabs_bar.menu": "Menu",
|
||||
"tabs_bar.notifications": "Avizos",
|
||||
"tabs_bar.publish": "Mueva publikasyon",
|
||||
"tabs_bar.search": "Bushkeda",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -28,7 +28,7 @@ class AnnualReport::Archetype < AnnualReport::Source
|
|||
end
|
||||
|
||||
def polls_count
|
||||
@polls_count ||= report_statuses.where.not(poll_id: nil).count
|
||||
@polls_count ||= report_statuses.only_polls.count
|
||||
end
|
||||
|
||||
def reblogs_count
|
||||
|
@ -36,7 +36,7 @@ class AnnualReport::Archetype < AnnualReport::Source
|
|||
end
|
||||
|
||||
def replies_count
|
||||
@replies_count ||= report_statuses.where.not(in_reply_to_id: nil).where.not(in_reply_to_account_id: @account.id).count
|
||||
@replies_count ||= report_statuses.where.not(in_reply_to_id: nil).not_replying_to_account(@account).count
|
||||
end
|
||||
|
||||
def standalone_count
|
||||
|
|
|
@ -18,7 +18,7 @@ class AnnualReport::CommonlyInteractedWithAccounts < AnnualReport::Source
|
|||
private
|
||||
|
||||
def commonly_interacted_with_accounts
|
||||
report_statuses.where.not(in_reply_to_account_id: @account.id).group(:in_reply_to_account_id).having(minimum_interaction_count).order(count_all: :desc).limit(SET_SIZE).count
|
||||
report_statuses.not_replying_to_account(@account).group(:in_reply_to_account_id).having(minimum_interaction_count).order(count_all: :desc).limit(SET_SIZE).count
|
||||
end
|
||||
|
||||
def minimum_interaction_count
|
||||
|
|
|
@ -2,20 +2,44 @@
|
|||
|
||||
class AnnualReport::TopStatuses < AnnualReport::Source
|
||||
def generate
|
||||
top_reblogs = base_scope.order(reblogs_count: :desc).first&.id
|
||||
top_favourites = base_scope.where.not(id: top_reblogs).order(favourites_count: :desc).first&.id
|
||||
top_replies = base_scope.where.not(id: [top_reblogs, top_favourites]).order(replies_count: :desc).first&.id
|
||||
|
||||
{
|
||||
top_statuses: {
|
||||
by_reblogs: top_reblogs&.to_s,
|
||||
by_favourites: top_favourites&.to_s,
|
||||
by_replies: top_replies&.to_s,
|
||||
by_reblogs: status_identifier(most_reblogged_status),
|
||||
by_favourites: status_identifier(most_favourited_status),
|
||||
by_replies: status_identifier(most_replied_status),
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def status_identifier(status)
|
||||
status.id.to_s if status.present?
|
||||
end
|
||||
|
||||
def most_reblogged_status
|
||||
base_scope
|
||||
.order(reblogs_count: :desc)
|
||||
.first
|
||||
end
|
||||
|
||||
def most_favourited_status
|
||||
base_scope
|
||||
.excluding(most_reblogged_status)
|
||||
.order(favourites_count: :desc)
|
||||
.first
|
||||
end
|
||||
|
||||
def most_replied_status
|
||||
base_scope
|
||||
.excluding(most_reblogged_status, most_favourited_status)
|
||||
.order(replies_count: :desc)
|
||||
.first
|
||||
end
|
||||
|
||||
def base_scope
|
||||
report_statuses.public_visibility.joins(:status_stat)
|
||||
report_statuses
|
||||
.public_visibility
|
||||
.joins(:status_stat)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,7 +6,7 @@ class AnnualReport::TypeDistribution < AnnualReport::Source
|
|||
type_distribution: {
|
||||
total: report_statuses.count,
|
||||
reblogs: report_statuses.only_reblogs.count,
|
||||
replies: report_statuses.where.not(in_reply_to_id: nil).where.not(in_reply_to_account_id: @account.id).count,
|
||||
replies: report_statuses.where.not(in_reply_to_id: nil).not_replying_to_account(@account).count,
|
||||
standalone: report_statuses.without_replies.without_reblogs.count,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -161,7 +161,7 @@ class AccountStatusesCleanupPolicy < ApplicationRecord
|
|||
end
|
||||
|
||||
def without_poll_scope
|
||||
Status.where(poll_id: nil)
|
||||
Status.without_polls
|
||||
end
|
||||
|
||||
def without_popular_scope
|
||||
|
|
|
@ -4,14 +4,8 @@ module FollowLimitable
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
validates_with FollowLimitValidator, on: :create, unless: :bypass_follow_limit?
|
||||
end
|
||||
validates_with FollowLimitValidator, on: :create, unless: :bypass_follow_limit
|
||||
|
||||
def bypass_follow_limit=(value)
|
||||
@bypass_follow_limit = value
|
||||
end
|
||||
|
||||
def bypass_follow_limit?
|
||||
@bypass_follow_limit
|
||||
attribute :bypass_follow_limit, :boolean, default: false
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -121,7 +121,10 @@ class Status < ApplicationRecord
|
|||
scope :without_replies, -> { not_reply.or(reply_to_account) }
|
||||
scope :not_reply, -> { where(reply: false) }
|
||||
scope :only_reblogs, -> { where.not(reblog_of_id: nil) }
|
||||
scope :only_polls, -> { where.not(poll_id: nil) }
|
||||
scope :without_polls, -> { where(poll_id: nil) }
|
||||
scope :reply_to_account, -> { where(arel_table[:in_reply_to_account_id].eq arel_table[:account_id]) }
|
||||
scope :not_replying_to_account, ->(account) { where.not(in_reply_to_account: account) }
|
||||
scope :without_reblogs, -> { where(statuses: { reblog_of_id: nil }) }
|
||||
scope :tagged_with, ->(tag_ids) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag_ids }) }
|
||||
scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) }
|
||||
|
@ -136,6 +139,9 @@ class Status < ApplicationRecord
|
|||
scope :tagged_with_none, lambda { |tag_ids|
|
||||
where('NOT EXISTS (SELECT * FROM statuses_tags forbidden WHERE forbidden.status_id = statuses.id AND forbidden.tag_id IN (?))', tag_ids)
|
||||
}
|
||||
scope :distributable_visibility, -> { where(visibility: %i(public unlisted)) }
|
||||
scope :list_eligible_visibility, ->(list = nil) { where(visibility: list&.public_list? ? %i(public unlisted) : %i(public unlisted private)) }
|
||||
scope :not_direct_visibility, -> { where.not(visibility: :direct) }
|
||||
|
||||
after_create_commit :trigger_create_webhooks
|
||||
after_update_commit :trigger_update_webhooks
|
||||
|
|
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
|
|
@ -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
|
|
@ -1347,7 +1347,7 @@ da:
|
|||
your_appeal_rejected: Din appel er afvist
|
||||
edit_profile:
|
||||
basic_information: Oplysninger
|
||||
hint_html: "<strong>Tilpas hvad folk ser på din offentlige profil og ved siden af dine indlæg.</strong> Andre personer vil mere sandsynligt følge dig tilbage og interagere med dig, når du har en udfyldt profil og et profilbillede."
|
||||
hint_html: "<strong>Tilpas, hvad folk ser på din offentlige profil og ved siden af dine indlæg.</strong> Andre personer er mere tilbøjelige til at følge dig tilbage og interagere med dig, når du har en udfyldt profil og et profilbillede."
|
||||
other: Andre
|
||||
emoji_styles:
|
||||
auto: Auto
|
||||
|
|
|
@ -1944,7 +1944,7 @@ de:
|
|||
contrast: Mastodon (Hoher Kontrast)
|
||||
default: Mastodon (Dunkel)
|
||||
mastodon-light: Mastodon (Hell)
|
||||
system: Automatisch (mit System synchronisieren)
|
||||
system: Automatisch (wie Betriebssystem)
|
||||
time:
|
||||
formats:
|
||||
default: "%d. %b %Y, %H:%M Uhr"
|
||||
|
|
|
@ -467,8 +467,11 @@ eu:
|
|||
fasp:
|
||||
debug:
|
||||
callbacks:
|
||||
created_at: Sortua hemen
|
||||
delete: Ezabatu
|
||||
ip: IP helbidea
|
||||
request_body: Eskaeraren edukia
|
||||
title: Atzera-deiak araztu
|
||||
providers:
|
||||
active: Aktibo
|
||||
base_url: Oinarrizko URL-a
|
||||
|
@ -822,6 +825,7 @@ eu:
|
|||
destroyed_msg: Guneko igoera ongi ezabatu da!
|
||||
software_updates:
|
||||
critical_update: Kritikoa — mesedez, eguneratu azkar
|
||||
description: Gomendagarria da Mastodon instalazioa eguneratuta mantentzea azken konponketa eta funtzioez baliatzeko. Gainera, batzuetan ezinbestekoa da Mastodon garaiz eguneratzea segurtasun arazoak saihesteko. Arrazoi hauengatik, Mastodonek 30 minuturo eguneratzeak egiaztatzen ditu, eta zure posta elektroniko bidezko jakinarazpenen lehentasunen arabera jakinaraziko dizu.
|
||||
documentation_link: Informazio gehiago
|
||||
release_notes: Bertsio oharrak
|
||||
title: Eguneraketak eskuragarri
|
||||
|
|
|
@ -1851,6 +1851,8 @@ fr-CA:
|
|||
limit: Vous avez déjà épinglé le nombre maximum de messages
|
||||
ownership: Vous ne pouvez pas épingler un message ne vous appartenant pas
|
||||
reblog: Un partage ne peut pas être épinglé
|
||||
quote_policies:
|
||||
followers: Abonné·e·s et utilisateur·trice·s mentionné·e·s
|
||||
title: "%{name} : « %{quote} »"
|
||||
visibilities:
|
||||
direct: Direct
|
||||
|
@ -1904,6 +1906,8 @@ fr-CA:
|
|||
does_not_match_previous_name: ne correspond pas au nom précédent
|
||||
terms_of_service:
|
||||
title: Conditions d'utilisation
|
||||
terms_of_service_interstitial:
|
||||
title: Les conditions d'utilisation de %{domain} ont changées
|
||||
themes:
|
||||
contrast: Mastodon (Contraste élevé)
|
||||
default: Mastodon (Sombre)
|
||||
|
|
|
@ -1851,6 +1851,8 @@ fr:
|
|||
limit: Vous avez déjà épinglé le nombre maximum de messages
|
||||
ownership: Vous ne pouvez pas épingler un message ne vous appartenant pas
|
||||
reblog: Un partage ne peut pas être épinglé
|
||||
quote_policies:
|
||||
followers: Abonné·e·s et utilisateur·trice·s mentionné·e·s
|
||||
title: "%{name} : « %{quote} »"
|
||||
visibilities:
|
||||
direct: Direct
|
||||
|
@ -1904,6 +1906,8 @@ fr:
|
|||
does_not_match_previous_name: ne correspond pas au nom précédent
|
||||
terms_of_service:
|
||||
title: Conditions d'utilisation
|
||||
terms_of_service_interstitial:
|
||||
title: Les conditions d'utilisation de %{domain} ont changées
|
||||
themes:
|
||||
contrast: Mastodon (Contraste élevé)
|
||||
default: Mastodon (Sombre)
|
||||
|
|
|
@ -1406,6 +1406,10 @@ ga:
|
|||
basic_information: Eolas bunúsach
|
||||
hint_html: "<strong>Saincheap a bhfeiceann daoine ar do phróifíl phoiblí agus in aice le do phostálacha.</strong> Is dóichí go leanfaidh daoine eile ar ais tú agus go n-idirghníomhóidh siad leat nuair a bhíonn próifíl líonta agus pictiúr próifíle agat."
|
||||
other: Eile
|
||||
emoji_styles:
|
||||
auto: Uath
|
||||
native: Dúchasach
|
||||
twemoji: Twemoji
|
||||
errors:
|
||||
'400': Bhí an t-iarratas a chuir tú isteach neamhbhailí nó míchumtha.
|
||||
'403': Níl cead agat an leathanach seo a fheiceáil.
|
||||
|
|
|
@ -1351,6 +1351,10 @@ it:
|
|||
basic_information: Informazioni di base
|
||||
hint_html: "<strong>Personalizza ciò che le persone vedono sul tuo profilo pubblico e accanto ai tuoi post.</strong> È più probabile che altre persone ti seguano e interagiscano con te quando hai un profilo compilato e un'immagine del profilo."
|
||||
other: Altro
|
||||
emoji_styles:
|
||||
auto: Automatico
|
||||
native: Nativo
|
||||
twemoji: Twemoji
|
||||
errors:
|
||||
'400': La richiesta che hai inviato non è valida o non è corretta.
|
||||
'403': Non sei autorizzato a visualizzare questa pagina.
|
||||
|
|
|
@ -186,6 +186,7 @@ lad:
|
|||
create_domain_block: Kriya bloko de domeno
|
||||
create_email_domain_block: Kriya bloko de domeno de posta
|
||||
create_ip_block: Kriya regla de IP
|
||||
create_relay: Kriya relevo
|
||||
create_unavailable_domain: Kriya domeno no desponivle
|
||||
create_user_role: Kriya rolo
|
||||
demote_user: Degrada utilizador
|
||||
|
@ -197,6 +198,7 @@ lad:
|
|||
destroy_email_domain_block: Efasa bloko de domeno de posta
|
||||
destroy_instance: Efasa domeno
|
||||
destroy_ip_block: Efasa regla de IP
|
||||
destroy_relay: Efasa relevo
|
||||
destroy_status: Efasa publikasyon
|
||||
destroy_unavailable_domain: Efasa domeno no desponivle
|
||||
destroy_user_role: Efasa rolo
|
||||
|
@ -205,6 +207,7 @@ lad:
|
|||
disable_sign_in_token_auth_user: Inkapasita la autentifikasyon por token de posta elektronika para el utilizador
|
||||
disable_user: Inkapasita utilizador
|
||||
enable_custom_emoji: Kapasita emoji personalizados
|
||||
enable_relay: Aktiva relevo
|
||||
enable_sign_in_token_auth_user: Kapasita la autentifikasyon por token de posta para el utilizador
|
||||
enable_user: Kapasita utilizador
|
||||
memorialize_account: Transforma en kuento komemorativo
|
||||
|
@ -229,6 +232,7 @@ lad:
|
|||
update_custom_emoji: Aktualiza emoji personalizado
|
||||
update_domain_block: Aktualiza bloko de domeno
|
||||
update_ip_block: Aktualiza regla de IP
|
||||
update_report: Aktualiza raporto
|
||||
update_status: Aktualiza publikasyon
|
||||
update_user_role: Aktualiza rolo
|
||||
actions:
|
||||
|
@ -466,10 +470,13 @@ lad:
|
|||
fasp:
|
||||
debug:
|
||||
callbacks:
|
||||
created_at: Kriyado en
|
||||
delete: Efasa
|
||||
ip: Adreso IP
|
||||
providers:
|
||||
active: Aktivo
|
||||
delete: Efasa
|
||||
finish_registration: Finaliza enrejistrasyon
|
||||
name: Nombre
|
||||
registrations:
|
||||
confirm: Konfirma
|
||||
|
@ -542,6 +549,12 @@ lad:
|
|||
all: Todos
|
||||
limited: Limitado
|
||||
title: Moderasyon
|
||||
moderation_notes:
|
||||
create: Adjusta nota de moderasyon
|
||||
created_msg: Nota de moderasyon de sirvidor kriyada kon sukseso!
|
||||
description_html: Ve i desha notas a otros moderadores i a tu yo futuro
|
||||
destroyed_msg: Nota de moderasyon de sirvidor efasada kon sukseso!
|
||||
title: Notas de moderasyon
|
||||
private_comment: Komento privado
|
||||
public_comment: Komento publiko
|
||||
purge: Purga
|
||||
|
@ -748,6 +761,7 @@ lad:
|
|||
title: Rolos
|
||||
rules:
|
||||
add_new: Adjusta regla
|
||||
add_translation: Adjusta traduksyon
|
||||
delete: Efasa
|
||||
description_html: Aunke la majorita afirma aver meldado i estar de akodro kon los terminos de servisyo, la djente normalmente no los melda asta dempues de ke surja algun problema. <strong>Az ke sea mas kolay ver las normas de tu sirvidor de un vistazo estipulándolas en una lista de puntos.</strong> Aprova ke kada norma sea corta i kolay, ama sin estar divididas en munchos puntos.
|
||||
edit: Edita regla
|
||||
|
@ -920,6 +934,9 @@ lad:
|
|||
updated_msg: Konfigurasyon de etiketas aktualizada kon sukseso
|
||||
terms_of_service:
|
||||
changelog: Ke troko
|
||||
current: Aktual
|
||||
generates:
|
||||
action: Djenera
|
||||
history: Istorya
|
||||
live: En bivo
|
||||
publish: Publika
|
||||
|
@ -1245,6 +1262,10 @@ lad:
|
|||
basic_information: Enformasyon bazika
|
||||
hint_html: "<strong>Personaliza lo ke la djente ve en tu profil publiko i kon tus publikasyones.</strong> Es mas probavle ke otras personas te sigan i enteraktuen kontigo kuando kompletas tu profil i foto."
|
||||
other: Otros
|
||||
emoji_styles:
|
||||
auto: Otomatiko
|
||||
native: Nativo
|
||||
twemoji: Twemoji
|
||||
errors:
|
||||
'400': La solisitasyon ke enviates no fue valida o fue malformada.
|
||||
'403': No tienes permiso para ver esta pajina.
|
||||
|
|
|
@ -583,7 +583,7 @@ nl:
|
|||
created_msg: Aanmaken van servermoderatie-opmerking geslaagd!
|
||||
description_html: Opmerkingen bekijken, en voor jezelf en andere moderatoren achterlaten
|
||||
destroyed_msg: Verwijderen van servermoderatie-opmerking geslaagd!
|
||||
placeholder: Informatie over deze server, genomen acties of iets anders die jou kunnen helpen om deze server in de toekomst te moderen.
|
||||
placeholder: Informatie over deze server, genomen acties of iets anders die jou kunnen helpen om deze server in de toekomst te modereren.
|
||||
title: Moderatie-opmerkingen
|
||||
private_comment: Privé-opmerking
|
||||
public_comment: Openbare opmerking
|
||||
|
|
|
@ -61,7 +61,7 @@ da:
|
|||
setting_display_media_default: Skjul medier med sensitiv-markering
|
||||
setting_display_media_hide_all: Skjul altid medier
|
||||
setting_display_media_show_all: Vis altid medier
|
||||
setting_emoji_style: Hvordan emojis skal vises. "Auto" vil forsøge at bruge indbyggede emojis, men skifter tilbage til Twemoji for ældre browsere.
|
||||
setting_emoji_style: Hvordan emojis skal vises. "Auto" vil forsøge at bruge indbyggede emojis, men skifter tilbage til Twemoji i ældre webbrowsere.
|
||||
setting_system_scrollbars_ui: Gælder kun for computerwebbrowsere baseret på Safari og Chrome
|
||||
setting_use_blurhash: Gradienter er baseret på de skjulte grafikelementers farver, men slører alle detaljer
|
||||
setting_use_pending_items: Skjul tidslinjeopdateringer bag et klik i stedet for brug af auto-feedrulning
|
||||
|
|
|
@ -61,7 +61,7 @@ de:
|
|||
setting_display_media_default: Medien mit Inhaltswarnung ausblenden
|
||||
setting_display_media_hide_all: Medien immer ausblenden
|
||||
setting_display_media_show_all: Medien mit Inhaltswarnung immer anzeigen
|
||||
setting_emoji_style: Darstellung von Emojis. „Automatisch“ verwendet native Emojis, für ältere Browser jedoch Twemoji.
|
||||
setting_emoji_style: 'Wie Emojis dargestellt werden: „Automatisch“ verwendet native Emojis, für veraltete Browser wird jedoch Twemoji verwendet.'
|
||||
setting_system_scrollbars_ui: Betrifft nur Desktop-Browser, die auf Chrome oder Safari basieren
|
||||
setting_use_blurhash: Der Farbverlauf basiert auf den Farben der ausgeblendeten Medien, verschleiert aber jegliche Details
|
||||
setting_use_pending_items: Neue Beiträge hinter einem Klick verstecken, anstatt automatisch zu scrollen
|
||||
|
@ -248,7 +248,7 @@ de:
|
|||
setting_missing_alt_text_modal: Bestätigungsdialog anzeigen, bevor Medien ohne Bildbeschreibung veröffentlicht werden
|
||||
setting_reduce_motion: Bewegung in Animationen verringern
|
||||
setting_system_font_ui: Standardschriftart des Browsers verwenden
|
||||
setting_system_scrollbars_ui: Bildlaufleiste des Systems verwenden
|
||||
setting_system_scrollbars_ui: Bildlaufleiste des Betriebssystems verwenden
|
||||
setting_theme: Design
|
||||
setting_trends: Heutige Trends anzeigen
|
||||
setting_unfollow_modal: Bestätigungsdialog beim Entfolgen eines Profils anzeigen
|
||||
|
|
|
@ -61,6 +61,7 @@ ga:
|
|||
setting_display_media_default: Folaigh meáin atá marcáilte mar íogair
|
||||
setting_display_media_hide_all: Folaigh meáin i gcónaí
|
||||
setting_display_media_show_all: Taispeáin meáin i gcónaí
|
||||
setting_emoji_style: Conas emojis a thaispeáint. Déanfaidh "Auto" iarracht emoji dúchasacha a úsáid, ach titeann sé ar ais go Twemoji le haghaidh seanbhrabhsálaithe.
|
||||
setting_system_scrollbars_ui: Ní bhaineann sé ach le brabhsálaithe deisce bunaithe ar Safari agus Chrome
|
||||
setting_use_blurhash: Tá grádáin bunaithe ar dhathanna na n-amharcanna ceilte ach cuireann siad salach ar aon mhionsonraí
|
||||
setting_use_pending_items: Folaigh nuashonruithe amlíne taobh thiar de chlic seachas an fotha a scrollú go huathoibríoch
|
||||
|
@ -244,6 +245,7 @@ ga:
|
|||
setting_display_media_default: Réamhshocrú
|
||||
setting_display_media_hide_all: Cuir uile i bhfolach
|
||||
setting_display_media_show_all: Taispeáin uile
|
||||
setting_emoji_style: Stíl Emoji
|
||||
setting_expand_spoilers: Méadaigh postálacha atá marcáilte le rabhaidh inneachair i gcónaí
|
||||
setting_hide_network: Folaigh do ghraf sóisialta
|
||||
setting_missing_alt_text_modal: Taispeáin dialóg deimhnithe sula bpostálann tú meán gan alt téacs
|
||||
|
|
|
@ -61,6 +61,7 @@ it:
|
|||
setting_display_media_default: Nascondi media segnati come sensibili
|
||||
setting_display_media_hide_all: Nascondi sempre tutti i media
|
||||
setting_display_media_show_all: Mostra sempre i media segnati come sensibili
|
||||
setting_emoji_style: Come visualizzare gli emoji. "Automatico" proverà a usare gli emoji nativi, ma per i browser più vecchi ricorrerà a Twemoji.
|
||||
setting_system_scrollbars_ui: Si applica solo ai browser desktop basati su Safari e Chrome
|
||||
setting_use_blurhash: I gradienti sono basati sui colori delle immagini nascoste ma offuscano tutti i dettagli
|
||||
setting_use_pending_items: Fare clic per mostrare i nuovi messaggi invece di aggiornare la timeline automaticamente
|
||||
|
@ -241,6 +242,7 @@ it:
|
|||
setting_display_media_default: Predefinita
|
||||
setting_display_media_hide_all: Nascondi tutti
|
||||
setting_display_media_show_all: Mostra tutti
|
||||
setting_emoji_style: Stile emoji
|
||||
setting_expand_spoilers: Espandi sempre post con content warning
|
||||
setting_hide_network: Nascondi la tua rete
|
||||
setting_missing_alt_text_modal: Chiedi di confermare prima di pubblicare media senza testo alternativo
|
||||
|
|
|
@ -61,7 +61,7 @@ nl:
|
|||
setting_display_media_default: Als gevoelig gemarkeerde media verbergen
|
||||
setting_display_media_hide_all: Media altijd verbergen
|
||||
setting_display_media_show_all: Media altijd tonen
|
||||
setting_emoji_style: Waarmee moeten emojis worden weergegeven. "Auto" probeert de systeemeigen emojis te gebruiken, maar valt terug op Twemoji voor oudere webbrowsers.
|
||||
setting_emoji_style: Waarmee moeten emojis worden weergegeven. ‘Auto’ probeert de systeemeigen emojis te gebruiken, maar valt terug op Twemoji voor oudere webbrowsers.
|
||||
setting_system_scrollbars_ui: Alleen van toepassing op desktopbrowsers gebaseerd op Safari en Chrome
|
||||
setting_use_blurhash: Wazige kleurovergangen zijn gebaseerd op de kleuren van de verborgen media, waarmee elk detail verdwijnt
|
||||
setting_use_pending_items: De tijdlijn wordt bijgewerkt door op het aantal nieuwe items te klikken, in plaats van dat deze automatisch wordt bijgewerkt
|
||||
|
|
|
@ -234,6 +234,7 @@ uk:
|
|||
setting_display_media_default: За промовчанням
|
||||
setting_display_media_hide_all: Сховати всі
|
||||
setting_display_media_show_all: Показати всі
|
||||
setting_emoji_style: Стиль емодзі
|
||||
setting_expand_spoilers: Завжди розгортати дописи з попередженнями про вміст
|
||||
setting_hide_network: Сховати вашу мережу
|
||||
setting_missing_alt_text_modal: Запитувати перед розміщенням медіа без альтернативного тексту
|
||||
|
|
|
@ -1338,6 +1338,9 @@ uk:
|
|||
basic_information: Основна інформація
|
||||
hint_html: "<strong>Налаштуйте те, що люди бачитимуть у вашому загальнодоступному профілі та поруч із вашими дописами.</strong> Інші люди з більшою ймовірністю підпишуться на вас та взаємодіятимуть з вами, якщо у вас є заповнений профіль та зображення профілю."
|
||||
other: Інше
|
||||
emoji_styles:
|
||||
auto: Авто
|
||||
native: Рідний
|
||||
errors:
|
||||
'400': Ваш запит був недійсним або неправильним.
|
||||
'403': У Вас немає доступу до перегляду даної сторінки.
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -1288,49 +1288,64 @@ RSpec.describe Mastodon::CLI::Accounts do
|
|||
|
||||
describe '#prune' do
|
||||
let(:action) { :prune }
|
||||
let!(:local_account) { Fabricate(:account) }
|
||||
let!(:bot_account) { Fabricate(:account, bot: true, domain: 'example.com') }
|
||||
let!(:group_account) { Fabricate(:account, actor_type: 'Group', domain: 'example.com') }
|
||||
let!(:mentioned_account) { Fabricate(:account, domain: 'example.com') }
|
||||
let!(:prunable_accounts) do
|
||||
Fabricate.times(2, :account, domain: 'example.com', bot: false, suspended_at: nil, silenced_at: nil)
|
||||
end
|
||||
let(:viable_attrs) { { domain: 'example.com', bot: false, suspended: false, silenced: false } }
|
||||
let!(:local_account) { Fabricate(:account) }
|
||||
let!(:bot_account) { Fabricate(:account, bot: true, domain: 'example.com') }
|
||||
let!(:group_account) { Fabricate(:account, actor_type: 'Group', domain: 'example.com') }
|
||||
let!(:account_mentioned) { Fabricate(:account, viable_attrs) }
|
||||
let!(:account_with_favourite) { Fabricate(:account, viable_attrs) }
|
||||
let!(:account_with_status) { Fabricate(:account, viable_attrs) }
|
||||
let!(:account_with_follow) { Fabricate(:account, viable_attrs) }
|
||||
let!(:account_targeted_follow) { Fabricate(:account, viable_attrs) }
|
||||
let!(:account_with_block) { Fabricate(:account, viable_attrs) }
|
||||
let!(:account_targeted_block) { Fabricate(:account, viable_attrs) }
|
||||
let!(:account_targeted_mute) { Fabricate(:account, viable_attrs) }
|
||||
let!(:account_targeted_report) { Fabricate(:account, viable_attrs) }
|
||||
let!(:account_with_follow_request) { Fabricate(:account, viable_attrs) }
|
||||
let!(:account_targeted_follow_request) { Fabricate(:account, viable_attrs) }
|
||||
let!(:prunable_accounts) { Fabricate.times(2, :account, viable_attrs) }
|
||||
|
||||
before do
|
||||
Fabricate(:mention, account: mentioned_account, status: Fabricate(:status, account: Fabricate(:account)))
|
||||
Fabricate :mention, account: account_mentioned, status: Fabricate(:status, account: Fabricate(:account))
|
||||
Fabricate :favourite, account: account_with_favourite
|
||||
Fabricate :status, account: account_with_status
|
||||
Fabricate :follow, account: account_with_follow
|
||||
Fabricate :follow, target_account: account_targeted_follow
|
||||
Fabricate :block, account: account_with_block
|
||||
Fabricate :block, target_account: account_targeted_block
|
||||
Fabricate :mute, target_account: account_targeted_mute
|
||||
Fabricate :report, target_account: account_targeted_report
|
||||
Fabricate :follow_request, account: account_with_follow_request
|
||||
Fabricate :follow_request, target_account: account_targeted_follow_request
|
||||
stub_parallelize_with_progress!
|
||||
end
|
||||
|
||||
def expect_prune_remote_accounts_without_interaction
|
||||
prunable_account_ids = prunable_accounts.pluck(:id)
|
||||
|
||||
expect(Account.where(id: prunable_account_ids).count).to eq(0)
|
||||
end
|
||||
|
||||
it 'displays a successful message and handles accounts correctly' do
|
||||
expect { subject }
|
||||
.to output_results("OK, pruned #{prunable_accounts.size} accounts")
|
||||
expect_prune_remote_accounts_without_interaction
|
||||
expect_not_prune_local_accounts
|
||||
expect_not_prune_bot_accounts
|
||||
expect_not_prune_group_accounts
|
||||
expect_not_prune_mentioned_accounts
|
||||
expect(prunable_account_records)
|
||||
.to have_attributes(count: eq(0))
|
||||
expect(Account.all)
|
||||
.to include(local_account)
|
||||
.and include(bot_account)
|
||||
.and include(group_account)
|
||||
.and include(account_mentioned)
|
||||
.and include(account_with_favourite)
|
||||
.and include(account_with_status)
|
||||
.and include(account_with_follow)
|
||||
.and include(account_targeted_follow)
|
||||
.and include(account_with_block)
|
||||
.and include(account_targeted_block)
|
||||
.and include(account_targeted_mute)
|
||||
.and include(account_targeted_report)
|
||||
.and include(account_with_follow_request)
|
||||
.and include(account_targeted_follow_request)
|
||||
.and not_include(prunable_accounts.first)
|
||||
.and not_include(prunable_accounts.last)
|
||||
end
|
||||
|
||||
def expect_not_prune_local_accounts
|
||||
expect(Account.exists?(id: local_account.id)).to be(true)
|
||||
end
|
||||
|
||||
def expect_not_prune_bot_accounts
|
||||
expect(Account.exists?(id: bot_account.id)).to be(true)
|
||||
end
|
||||
|
||||
def expect_not_prune_group_accounts
|
||||
expect(Account.exists?(id: group_account.id)).to be(true)
|
||||
end
|
||||
|
||||
def expect_not_prune_mentioned_accounts
|
||||
expect(Account.exists?(id: mentioned_account.id)).to be true
|
||||
def prunable_account_records
|
||||
Account.where(id: prunable_accounts.pluck(:id))
|
||||
end
|
||||
|
||||
context 'with --dry-run option' do
|
||||
|
|
|
@ -191,6 +191,19 @@ RSpec.describe Status do
|
|||
end
|
||||
end
|
||||
|
||||
describe '.not_replying_to_account' do
|
||||
let(:account) { Fabricate :account }
|
||||
let!(:status_from_account) { Fabricate :status, account: account }
|
||||
let!(:reply_to_account_status) { Fabricate :status, thread: status_from_account }
|
||||
let!(:reply_to_other) { Fabricate :status, thread: Fabricate(:status) }
|
||||
|
||||
it 'returns records not in reply to provided account' do
|
||||
expect(described_class.not_replying_to_account(account))
|
||||
.to not_include(reply_to_account_status)
|
||||
.and include(reply_to_other)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#untrusted_favourites_count' do
|
||||
before do
|
||||
alice.update(domain: 'example.com')
|
||||
|
@ -363,6 +376,28 @@ RSpec.describe Status do
|
|||
end
|
||||
end
|
||||
|
||||
describe '.only_polls' do
|
||||
let!(:poll_status) { Fabricate :status, poll: Fabricate(:poll) }
|
||||
let!(:no_poll_status) { Fabricate :status }
|
||||
|
||||
it 'returns the expected statuses' do
|
||||
expect(described_class.only_polls)
|
||||
.to include(poll_status)
|
||||
.and not_include(no_poll_status)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.without_polls' do
|
||||
let!(:poll_status) { Fabricate :status, poll: Fabricate(:poll) }
|
||||
let!(:no_poll_status) { Fabricate :status }
|
||||
|
||||
it 'returns the expected statuses' do
|
||||
expect(described_class.without_polls)
|
||||
.to not_include(poll_status)
|
||||
.and include(no_poll_status)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.tagged_with' do
|
||||
let(:tag_cats) { Fabricate(:tag, name: 'cats') }
|
||||
let(:tag_dogs) { Fabricate(:tag, name: 'dogs') }
|
||||
|
|
|
@ -6032,9 +6032,9 @@ __metadata:
|
|||
linkType: hard
|
||||
|
||||
"core-js@npm:^3.30.2, core-js@npm:^3.41.0":
|
||||
version: 3.43.0
|
||||
resolution: "core-js@npm:3.43.0"
|
||||
checksum: 10c0/9d4ad66296e60380777de51d019b5c3e6cce023b7999750a5094f9a4b0ea53bf3600beb4ef11c56548f2c8791d43d4056e270d1cf55ba87273011aa7d4597871
|
||||
version: 3.44.0
|
||||
resolution: "core-js@npm:3.44.0"
|
||||
checksum: 10c0/759bf3dc5f75068e9425dddf895fd5531c38794a11ea1c2b65e5ef7c527fe3652d59e8c287e574a211af9bab3c057c5c3fa6f6a773f4e142af895106efad38a4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user