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
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V1::Lists::AccountsController < Api::BaseController
|
class Api::V1::Lists::AccountsController < Api::BaseController
|
||||||
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show]
|
include Authorization
|
||||||
|
|
||||||
|
before_action -> { authorize_if_got_token! :read, :'read:lists' }, only: [:show]
|
||||||
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show]
|
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show]
|
||||||
|
|
||||||
before_action :require_user!
|
before_action :require_user!, except: [:show]
|
||||||
before_action :set_list
|
before_action :set_list
|
||||||
|
|
||||||
after_action :insert_pagination_headers, only: :show
|
after_action :insert_pagination_headers, only: :show
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
authorize @list, :show?
|
||||||
@accounts = load_accounts
|
@accounts = load_accounts
|
||||||
render json: @accounts, each_serializer: REST::AccountSerializer
|
render json: @accounts, each_serializer: REST::AccountSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
authorize @list, :update?
|
||||||
AddAccountsToListService.new.call(@list, Account.find(account_ids))
|
AddAccountsToListService.new.call(@list, Account.find(account_ids))
|
||||||
render_empty
|
render_empty
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
authorize @list, :update?
|
||||||
RemoveAccountsFromListService.new.call(@list, Account.where(id: account_ids))
|
RemoveAccountsFromListService.new.call(@list, Account.where(id: account_ids))
|
||||||
render_empty
|
render_empty
|
||||||
end
|
end
|
||||||
|
@ -27,7 +32,7 @@ class Api::V1::Lists::AccountsController < Api::BaseController
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_list
|
def set_list
|
||||||
@list = List.where(account: current_account).find(params[:list_id])
|
@list = List.find(params[:list_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_accounts
|
def load_accounts
|
||||||
|
|
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
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V1::ListsController < Api::BaseController
|
class Api::V1::ListsController < Api::BaseController
|
||||||
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index, :show]
|
include Authorization
|
||||||
|
|
||||||
|
before_action -> { authorize_if_got_token! :read, :'read:lists' }, only: [:show]
|
||||||
|
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index]
|
||||||
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show]
|
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show]
|
||||||
|
|
||||||
before_action :require_user!
|
before_action :require_user!, except: [:show]
|
||||||
before_action :set_list, except: [:index, :create]
|
before_action :set_list, except: [:index, :create]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
@ -13,6 +16,7 @@ class Api::V1::ListsController < Api::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
authorize @list, :show?
|
||||||
render json: @list, serializer: REST::ListSerializer
|
render json: @list, serializer: REST::ListSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -22,11 +26,13 @@ class Api::V1::ListsController < Api::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
authorize @list, :update?
|
||||||
@list.update!(list_params)
|
@list.update!(list_params)
|
||||||
render json: @list, serializer: REST::ListSerializer
|
render json: @list, serializer: REST::ListSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
authorize @list, :destroy?
|
||||||
@list.destroy!
|
@list.destroy!
|
||||||
render_empty
|
render_empty
|
||||||
end
|
end
|
||||||
|
@ -34,10 +40,10 @@ class Api::V1::ListsController < Api::BaseController
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_list
|
def set_list
|
||||||
@list = List.where(account: current_account).find(params[:id])
|
@list = List.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def list_params
|
def list_params
|
||||||
params.permit(:title, :replies_policy, :exclusive)
|
params.permit(:title, :description, :type, :replies_policy, :exclusive)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,23 +1,25 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V1::Timelines::ListController < Api::V1::Timelines::BaseController
|
class Api::V1::Timelines::ListController < Api::V1::Timelines::BaseController
|
||||||
before_action -> { doorkeeper_authorize! :read, :'read:lists' }
|
include Authorization
|
||||||
before_action :require_user!
|
|
||||||
|
before_action -> { authorize_if_got_token! :read, :'read:lists' }
|
||||||
before_action :set_list
|
before_action :set_list
|
||||||
before_action :set_statuses
|
before_action :set_statuses
|
||||||
|
|
||||||
PERMITTED_PARAMS = %i(limit).freeze
|
PERMITTED_PARAMS = %i(limit).freeze
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
authorize @list, :show?
|
||||||
render json: @statuses,
|
render json: @statuses,
|
||||||
each_serializer: REST::StatusSerializer,
|
each_serializer: REST::StatusSerializer,
|
||||||
relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id)
|
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_list
|
def set_list
|
||||||
@list = List.where(account: current_account).find(params[:id])
|
@list = List.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_statuses
|
def set_statuses
|
||||||
|
|
|
@ -7,6 +7,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||||
layout :determine_layout
|
layout :determine_layout
|
||||||
|
|
||||||
before_action :set_invite, only: [:new, :create]
|
before_action :set_invite, only: [:new, :create]
|
||||||
|
before_action :set_list, only: [:new, :create]
|
||||||
before_action :check_enabled_registrations, only: [:new, :create]
|
before_action :check_enabled_registrations, only: [:new, :create]
|
||||||
before_action :configure_sign_up_params, only: [:create]
|
before_action :configure_sign_up_params, only: [:create]
|
||||||
before_action :set_sessions, only: [:edit, :update]
|
before_action :set_sessions, only: [:edit, :update]
|
||||||
|
@ -109,6 +110,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_list
|
||||||
|
@list = List.where(type: :public_list).find_by(id: params[:list_id])
|
||||||
|
end
|
||||||
|
|
||||||
def determine_layout
|
def determine_layout
|
||||||
%w(edit update).include?(action_name) ? 'admin' : 'auth'
|
%w(edit update).include?(action_name) ? 'admin' : 'auth'
|
||||||
end
|
end
|
||||||
|
|
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`, {
|
apiRequestDelete(`v1/lists/${listId}/accounts`, {
|
||||||
account_ids: [accountId],
|
account_ids: [accountId],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const apiFollowList = (listId: string) =>
|
||||||
|
apiRequestPost(`v1/lists/${listId}/follow`);
|
||||||
|
|
|
@ -1,10 +1,21 @@
|
||||||
// See app/serializers/rest/list_serializer.rb
|
// See app/serializers/rest/list_serializer.rb
|
||||||
|
|
||||||
|
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||||
|
|
||||||
export type RepliesPolicyType = 'list' | 'followed' | 'none';
|
export type RepliesPolicyType = 'list' | 'followed' | 'none';
|
||||||
|
|
||||||
|
export type ListType = 'private_list' | 'public_list';
|
||||||
|
|
||||||
export interface ApiListJSON {
|
export interface ApiListJSON {
|
||||||
id: string;
|
id: string;
|
||||||
|
url?: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
slug?: string;
|
||||||
|
type: ListType;
|
||||||
|
description: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
exclusive: boolean;
|
exclusive: boolean;
|
||||||
replies_policy: RepliesPolicyType;
|
replies_policy: RepliesPolicyType;
|
||||||
|
account?: ApiAccountJSON;
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@ const messages = defineMessages({
|
||||||
export const CopyIconButton: React.FC<{
|
export const CopyIconButton: React.FC<{
|
||||||
title: string;
|
title: string;
|
||||||
value: string;
|
value: string;
|
||||||
className: string;
|
className?: string;
|
||||||
}> = ({ title, value, className }) => {
|
}> = ({ title, value, className }) => {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
|
@ -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 AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
|
import PackageIcon from '@/material-icons/400-24px/package_2.svg?react';
|
||||||
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
|
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
|
||||||
import { fetchLists } from 'mastodon/actions/lists';
|
import { fetchLists } from 'mastodon/actions/lists';
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
|
@ -16,6 +17,8 @@ import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||||
|
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||||
|
import type { List } from 'mastodon/models/list';
|
||||||
import { getOrderedLists } from 'mastodon/selectors/lists';
|
import { getOrderedLists } from 'mastodon/selectors/lists';
|
||||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
@ -25,12 +28,12 @@ const messages = defineMessages({
|
||||||
edit: { id: 'lists.edit', defaultMessage: 'Edit list' },
|
edit: { id: 'lists.edit', defaultMessage: 'Edit list' },
|
||||||
delete: { id: 'lists.delete', defaultMessage: 'Delete list' },
|
delete: { id: 'lists.delete', defaultMessage: 'Delete list' },
|
||||||
more: { id: 'status.more', defaultMessage: 'More' },
|
more: { id: 'status.more', defaultMessage: 'More' },
|
||||||
|
copyLink: { id: '', defaultMessage: 'Copy link' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const ListItem: React.FC<{
|
const ListItem: React.FC<{
|
||||||
id: string;
|
list: List;
|
||||||
title: string;
|
}> = ({ list }) => {
|
||||||
}> = ({ id, title }) => {
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
|
@ -39,25 +42,54 @@ const ListItem: React.FC<{
|
||||||
openModal({
|
openModal({
|
||||||
modalType: 'CONFIRM_DELETE_LIST',
|
modalType: 'CONFIRM_DELETE_LIST',
|
||||||
modalProps: {
|
modalProps: {
|
||||||
listId: id,
|
listId: list.id,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [dispatch, id]);
|
}, [dispatch, list]);
|
||||||
|
|
||||||
const menu = useMemo(
|
const handleCopyClick = useCallback(() => {
|
||||||
() => [
|
void navigator.clipboard.writeText(list.url);
|
||||||
{ text: intl.formatMessage(messages.edit), to: `/lists/${id}/edit` },
|
}, [list]);
|
||||||
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
|
|
||||||
],
|
const menu = useMemo(() => {
|
||||||
[intl, id, handleDeleteClick],
|
const tmp: MenuItem[] = [
|
||||||
);
|
{ text: intl.formatMessage(messages.edit), to: `/lists/${list.id}/edit` },
|
||||||
|
{
|
||||||
|
text: intl.formatMessage(messages.delete),
|
||||||
|
action: handleDeleteClick,
|
||||||
|
dangerous: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (list.type === 'public_list') {
|
||||||
|
tmp.unshift(
|
||||||
|
{
|
||||||
|
text: intl.formatMessage(messages.copyLink),
|
||||||
|
action: handleCopyClick,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmp;
|
||||||
|
}, [intl, list, handleDeleteClick, handleCopyClick]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='lists__item'>
|
<div className='lists__item'>
|
||||||
<Link to={`/lists/${id}`} className='lists__item__title'>
|
<Link
|
||||||
<Icon id='list-ul' icon={ListAltIcon} />
|
to={
|
||||||
<span>{title}</span>
|
list.type === 'public_list'
|
||||||
|
? `/starter-pack/${list.id}-${list.slug}`
|
||||||
|
: `/lists/${list.id}`
|
||||||
|
}
|
||||||
|
className='lists__item__title'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
id={list.type === 'public_list' ? 'package' : 'list-ul'}
|
||||||
|
icon={list.type === 'public_list' ? PackageIcon : ListAltIcon}
|
||||||
|
/>
|
||||||
|
<span>{list.title}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
@ -128,7 +160,7 @@ const Lists: React.FC<{
|
||||||
bindToDocument={!multiColumn}
|
bindToDocument={!multiColumn}
|
||||||
>
|
>
|
||||||
{lists.map((list) => (
|
{lists.map((list) => (
|
||||||
<ListItem key={list.id} id={list.id} title={list.title} />
|
<ListItem key={list.id} list={list} />
|
||||||
))}
|
))}
|
||||||
</ScrollableList>
|
</ScrollableList>
|
||||||
|
|
||||||
|
|
|
@ -161,6 +161,7 @@ const ListMembers: React.FC<{
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const list = useAppSelector((state) => state.lists.get(id));
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [accountIds, setAccountIds] = useState<string[]>([]);
|
const [accountIds, setAccountIds] = useState<string[]>([]);
|
||||||
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
|
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
|
||||||
|
@ -288,7 +289,14 @@ const ListMembers: React.FC<{
|
||||||
{displayedAccountIds.length > 0 && <div className='spacer' />}
|
{displayedAccountIds.length > 0 && <div className='spacer' />}
|
||||||
|
|
||||||
<div className='column-footer'>
|
<div className='column-footer'>
|
||||||
<Link to={`/lists/${id}`} className='button button--block'>
|
<Link
|
||||||
|
to={
|
||||||
|
list?.type === 'public_list'
|
||||||
|
? `/starter-pack/${id}-${list.slug}`
|
||||||
|
: `/lists/${id}`
|
||||||
|
}
|
||||||
|
className='button button--block'
|
||||||
|
>
|
||||||
<FormattedMessage id='lists.done' defaultMessage='Done' />
|
<FormattedMessage id='lists.done' defaultMessage='Done' />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -84,7 +84,9 @@ const NewList: React.FC<{
|
||||||
id ? state.lists.get(id) : undefined,
|
id ? state.lists.get(id) : undefined,
|
||||||
);
|
);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
const [exclusive, setExclusive] = useState(false);
|
const [exclusive, setExclusive] = useState(false);
|
||||||
|
const [isPublic, setIsPublic] = useState(false);
|
||||||
const [repliesPolicy, setRepliesPolicy] = useState<RepliesPolicyType>('list');
|
const [repliesPolicy, setRepliesPolicy] = useState<RepliesPolicyType>('list');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
@ -109,6 +111,13 @@ const NewList: React.FC<{
|
||||||
[setTitle],
|
[setTitle],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDescriptionChange = useCallback(
|
||||||
|
({ target: { value } }: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setDescription(value);
|
||||||
|
},
|
||||||
|
[setDescription],
|
||||||
|
);
|
||||||
|
|
||||||
const handleExclusiveChange = useCallback(
|
const handleExclusiveChange = useCallback(
|
||||||
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
|
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setExclusive(checked);
|
setExclusive(checked);
|
||||||
|
@ -116,6 +125,13 @@ const NewList: React.FC<{
|
||||||
[setExclusive],
|
[setExclusive],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleIsPublicChange = useCallback(
|
||||||
|
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setIsPublic(checked);
|
||||||
|
},
|
||||||
|
[setIsPublic],
|
||||||
|
);
|
||||||
|
|
||||||
const handleRepliesPolicyChange = useCallback(
|
const handleRepliesPolicyChange = useCallback(
|
||||||
({ target: { value } }: React.ChangeEvent<HTMLSelectElement>) => {
|
({ target: { value } }: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
setRepliesPolicy(value as RepliesPolicyType);
|
setRepliesPolicy(value as RepliesPolicyType);
|
||||||
|
@ -131,8 +147,10 @@ const NewList: React.FC<{
|
||||||
updateList({
|
updateList({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
|
description,
|
||||||
exclusive,
|
exclusive,
|
||||||
replies_policy: repliesPolicy,
|
replies_policy: repliesPolicy,
|
||||||
|
type: isPublic ? 'public_list' : 'private_list',
|
||||||
}),
|
}),
|
||||||
).then(() => {
|
).then(() => {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
@ -142,8 +160,10 @@ const NewList: React.FC<{
|
||||||
void dispatch(
|
void dispatch(
|
||||||
createList({
|
createList({
|
||||||
title,
|
title,
|
||||||
|
description,
|
||||||
exclusive,
|
exclusive,
|
||||||
replies_policy: repliesPolicy,
|
replies_policy: repliesPolicy,
|
||||||
|
type: isPublic ? 'public_list' : 'private_list',
|
||||||
}),
|
}),
|
||||||
).then((result) => {
|
).then((result) => {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
@ -156,7 +176,17 @@ const NewList: React.FC<{
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [history, dispatch, setSubmitting, id, title, exclusive, repliesPolicy]);
|
}, [
|
||||||
|
history,
|
||||||
|
dispatch,
|
||||||
|
setSubmitting,
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
exclusive,
|
||||||
|
isPublic,
|
||||||
|
repliesPolicy,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column
|
<Column
|
||||||
|
@ -198,6 +228,28 @@ const NewList: React.FC<{
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='fields-group'>
|
||||||
|
<div className='input with_label'>
|
||||||
|
<div className='label_input'>
|
||||||
|
<label htmlFor='list_title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='lists.list_description'
|
||||||
|
defaultMessage='Description'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className='label_input__wrapper'>
|
||||||
|
<textarea
|
||||||
|
id='list_description'
|
||||||
|
value={description}
|
||||||
|
onChange={handleDescriptionChange}
|
||||||
|
maxLength={120}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='fields-group'>
|
<div className='fields-group'>
|
||||||
<div className='input with_label'>
|
<div className='input with_label'>
|
||||||
<div className='label_input'>
|
<div className='label_input'>
|
||||||
|
@ -244,6 +296,32 @@ const NewList: React.FC<{
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className='fields-group'>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||||
|
<label className='app-form__toggle'>
|
||||||
|
<div className='app-form__toggle__label'>
|
||||||
|
<strong>
|
||||||
|
<FormattedMessage
|
||||||
|
id='lists.make_public'
|
||||||
|
defaultMessage='Make public'
|
||||||
|
/>
|
||||||
|
</strong>
|
||||||
|
<span className='hint'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='lists.make_public_hint'
|
||||||
|
defaultMessage='When you make a list public, anyone with a link can see it.'
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='app-form__toggle__toggle'>
|
||||||
|
<div>
|
||||||
|
<Toggle checked={isPublic} onChange={handleIsPublicChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='fields-group'>
|
<div className='fields-group'>
|
||||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||||
<label className='app-form__toggle'>
|
<label className='app-form__toggle'>
|
||||||
|
|
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,
|
Lists,
|
||||||
ListEdit,
|
ListEdit,
|
||||||
ListMembers,
|
ListMembers,
|
||||||
|
PublicList,
|
||||||
Blocks,
|
Blocks,
|
||||||
DomainBlocks,
|
DomainBlocks,
|
||||||
Mutes,
|
Mutes,
|
||||||
|
@ -217,6 +218,7 @@ class SwitchingColumnsArea extends PureComponent {
|
||||||
<WrappedRoute path='/lists/:id/edit' component={ListEdit} content={children} />
|
<WrappedRoute path='/lists/:id/edit' component={ListEdit} content={children} />
|
||||||
<WrappedRoute path='/lists/:id/members' component={ListMembers} content={children} />
|
<WrappedRoute path='/lists/:id/members' component={ListMembers} content={children} />
|
||||||
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
|
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
|
||||||
|
<WrappedRoute path={['/starter-pack/:id(\\d+)', '/starter-pack/:id(\\d+)-:slug']} component={PublicList} content={children} />
|
||||||
<WrappedRoute path='/notifications' component={Notifications} content={children} exact />
|
<WrappedRoute path='/notifications' component={Notifications} content={children} exact />
|
||||||
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
|
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
|
||||||
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact />
|
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact />
|
||||||
|
|
|
@ -38,6 +38,10 @@ export function ListTimeline () {
|
||||||
return import('../../list_timeline');
|
return import('../../list_timeline');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PublicList () {
|
||||||
|
return import(/* webpackChunkName: "features/public_list" */'../../public_list');
|
||||||
|
}
|
||||||
|
|
||||||
export function Lists () {
|
export function Lists () {
|
||||||
return import('../../lists');
|
return import('../../lists');
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {[code: string, name: string, localName: string]} InitialStateLanguage
|
* @typedef {[code: string, name: string, localName: string]} InitialStateLanguage
|
||||||
*/
|
*/
|
||||||
|
@ -64,6 +63,7 @@
|
||||||
* @property {boolean=} critical_updates_pending
|
* @property {boolean=} critical_updates_pending
|
||||||
* @property {InitialStateMeta} meta
|
* @property {InitialStateMeta} meta
|
||||||
* @property {Role?} role
|
* @property {Role?} role
|
||||||
|
* @property {string[]} features
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const element = document.getElementById('initial-state');
|
const element = document.getElementById('initial-state');
|
||||||
|
@ -140,4 +140,12 @@ export function getAccessToken() {
|
||||||
return getMeta('access_token');
|
return getMeta('access_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} feature
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isFeatureEnabled(feature) {
|
||||||
|
return initialState?.features?.includes(feature) || false;
|
||||||
|
}
|
||||||
|
|
||||||
export default initialState;
|
export default initialState;
|
||||||
|
|
|
@ -219,6 +219,9 @@
|
||||||
"confirmations.delete_list.confirm": "Elimina",
|
"confirmations.delete_list.confirm": "Elimina",
|
||||||
"confirmations.delete_list.message": "Segur que vols suprimir permanentment aquesta llista?",
|
"confirmations.delete_list.message": "Segur que vols suprimir permanentment aquesta llista?",
|
||||||
"confirmations.delete_list.title": "Eliminar la llista?",
|
"confirmations.delete_list.title": "Eliminar la llista?",
|
||||||
|
"confirmations.discard_draft.confirm": "Descarta i continua",
|
||||||
|
"confirmations.discard_draft.edit.cancel": "Continua l'edició",
|
||||||
|
"confirmations.discard_draft.post.cancel": "Reprendre l'esborrany",
|
||||||
"confirmations.discard_edit_media.confirm": "Descarta",
|
"confirmations.discard_edit_media.confirm": "Descarta",
|
||||||
"confirmations.discard_edit_media.message": "Tens canvis no desats en la descripció del contingut o en la previsualització, els vols descartar?",
|
"confirmations.discard_edit_media.message": "Tens canvis no desats en la descripció del contingut o en la previsualització, els vols descartar?",
|
||||||
"confirmations.follow_to_list.confirm": "Seguir i afegir a una llista",
|
"confirmations.follow_to_list.confirm": "Seguir i afegir a una llista",
|
||||||
|
@ -792,6 +795,7 @@
|
||||||
"report_notification.categories.violation": "Violació de norma",
|
"report_notification.categories.violation": "Violació de norma",
|
||||||
"report_notification.categories.violation_sentence": "violació de normes",
|
"report_notification.categories.violation_sentence": "violació de normes",
|
||||||
"report_notification.open": "Obre l'informe",
|
"report_notification.open": "Obre l'informe",
|
||||||
|
"search.clear": "Esborra la cerca",
|
||||||
"search.no_recent_searches": "No hi ha cerques recents",
|
"search.no_recent_searches": "No hi ha cerques recents",
|
||||||
"search.placeholder": "Cerca",
|
"search.placeholder": "Cerca",
|
||||||
"search.quick_action.account_search": "Perfils coincidint amb {x}",
|
"search.quick_action.account_search": "Perfils coincidint amb {x}",
|
||||||
|
|
|
@ -572,7 +572,7 @@
|
||||||
"navigation_bar.mutes": "Skjulte brugere",
|
"navigation_bar.mutes": "Skjulte brugere",
|
||||||
"navigation_bar.opened_in_classic_interface": "Indlæg, konti og visse andre sider åbnes som standard i den klassiske webgrænseflade.",
|
"navigation_bar.opened_in_classic_interface": "Indlæg, konti og visse andre sider åbnes som standard i den klassiske webgrænseflade.",
|
||||||
"navigation_bar.preferences": "Præferencer",
|
"navigation_bar.preferences": "Præferencer",
|
||||||
"navigation_bar.privacy_and_reach": "Fortrolighed og udbredelse",
|
"navigation_bar.privacy_and_reach": "Fortrolighed og rækkevidde",
|
||||||
"navigation_bar.search": "Søg",
|
"navigation_bar.search": "Søg",
|
||||||
"navigation_bar.search_trends": "Søg/Trender",
|
"navigation_bar.search_trends": "Søg/Trender",
|
||||||
"navigation_panel.collapse_followed_tags": "Sammenfold menuen Fulgte hashtags",
|
"navigation_panel.collapse_followed_tags": "Sammenfold menuen Fulgte hashtags",
|
||||||
|
|
|
@ -3,16 +3,31 @@ import { Record } from 'immutable';
|
||||||
|
|
||||||
import type { ApiListJSON } from 'mastodon/api_types/lists';
|
import type { ApiListJSON } from 'mastodon/api_types/lists';
|
||||||
|
|
||||||
type ListShape = Required<ApiListJSON>; // no changes from server shape
|
interface ListShape extends Required<Omit<ApiListJSON, 'account'>> {
|
||||||
|
account_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type List = RecordOf<ListShape>;
|
export type List = RecordOf<ListShape>;
|
||||||
|
|
||||||
const ListFactory = Record<ListShape>({
|
const ListFactory = Record<ListShape>({
|
||||||
id: '',
|
id: '',
|
||||||
|
url: '',
|
||||||
title: '',
|
title: '',
|
||||||
|
slug: '',
|
||||||
|
description: '',
|
||||||
|
type: 'private_list',
|
||||||
exclusive: false,
|
exclusive: false,
|
||||||
replies_policy: 'list',
|
replies_policy: 'list',
|
||||||
|
account_id: undefined,
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createList(attributes: Partial<ListShape>) {
|
export const createList = (serverJSON: ApiListJSON): List => {
|
||||||
return ListFactory(attributes);
|
const { account, ...listJSON } = serverJSON;
|
||||||
}
|
|
||||||
|
return ListFactory({
|
||||||
|
...listJSON,
|
||||||
|
account_id: account?.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
@ -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 {
|
.column-header__buttons {
|
||||||
height: 48px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4542,6 +4541,16 @@ a.status-card {
|
||||||
color: $dark-text-color;
|
color: $dark-text-color;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.icon-button {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.copied {
|
||||||
|
color: $valid-value-color;
|
||||||
|
transition: none;
|
||||||
|
background-color: rgba($valid-value-color, 0.15);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-reduce-motion .column-header__button .icon-sliders {
|
.no-reduce-motion .column-header__button .icon-sliders {
|
||||||
|
@ -11104,6 +11113,50 @@ noscript {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lists__hero {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 24px;
|
||||||
|
border: 1px solid var(--background-border-color);
|
||||||
|
border-top: 0;
|
||||||
|
gap: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
text-align: center;
|
||||||
|
text-wrap: balance;
|
||||||
|
color: $secondary-text-color;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: $darker-text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.lists__item {
|
.lists__item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
@ -158,7 +158,7 @@ class FeedManager
|
||||||
|
|
||||||
timeline_key = key(:list, list.id)
|
timeline_key = key(:list, list.id)
|
||||||
aggregate = list.account.user&.aggregates_reblogs?
|
aggregate = list.account.user&.aggregates_reblogs?
|
||||||
query = from_account.statuses.list_eligible_visibility.includes(reblog: :account).limit(FeedManager::MAX_ITEMS / 4)
|
query = from_account.statuses.list_eligible_visibility(list).includes(reblog: :account).limit(FeedManager::MAX_ITEMS / 4)
|
||||||
|
|
||||||
if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4
|
if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4
|
||||||
oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true).first.last.to_i
|
oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true).first.last.to_i
|
||||||
|
@ -499,6 +499,8 @@ class FeedManager
|
||||||
# @param [List] list
|
# @param [List] list
|
||||||
# @return [Boolean]
|
# @return [Boolean]
|
||||||
def filter_from_list?(status, list)
|
def filter_from_list?(status, list)
|
||||||
|
return true if list.public_list? && !status.distributable?
|
||||||
|
|
||||||
if status.reply? && status.in_reply_to_account_id != status.account_id # Status is a reply to account other than status account
|
if status.reply? && status.in_reply_to_account_id != status.account_id # Status is a reply to account other than status account
|
||||||
should_filter = status.in_reply_to_account_id != list.account_id # Status replies to account id other than list account
|
should_filter = status.in_reply_to_account_id != list.account_id # Status replies to account id other than list account
|
||||||
should_filter &&= !list.show_followed? # List show_followed? is false
|
should_filter &&= !list.show_followed? # List show_followed? is false
|
||||||
|
|
|
@ -10,12 +10,6 @@ module DatabaseViewRecord
|
||||||
concurrently: true,
|
concurrently: true,
|
||||||
cascade: false
|
cascade: false
|
||||||
)
|
)
|
||||||
rescue ActiveRecord::StatementInvalid
|
|
||||||
Scenic.database.refresh_materialized_view(
|
|
||||||
table_name,
|
|
||||||
concurrently: false,
|
|
||||||
cascade: false
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -5,20 +5,25 @@
|
||||||
# Table name: lists
|
# Table name: lists
|
||||||
#
|
#
|
||||||
# id :bigint(8) not null, primary key
|
# id :bigint(8) not null, primary key
|
||||||
# account_id :bigint(8) not null
|
# description :text default(""), not null
|
||||||
|
# exclusive :boolean default(FALSE), not null
|
||||||
|
# replies_policy :integer default("list"), not null
|
||||||
# title :string default(""), not null
|
# title :string default(""), not null
|
||||||
|
# type :integer default("private_list"), not null
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# replies_policy :integer default("list"), not null
|
# account_id :bigint(8) not null
|
||||||
# exclusive :boolean default(FALSE), not null
|
|
||||||
#
|
#
|
||||||
|
|
||||||
class List < ApplicationRecord
|
class List < ApplicationRecord
|
||||||
|
self.inheritance_column = nil
|
||||||
|
|
||||||
include Paginable
|
include Paginable
|
||||||
|
|
||||||
PER_ACCOUNT_LIMIT = 50
|
PER_ACCOUNT_LIMIT = 50
|
||||||
|
|
||||||
enum :replies_policy, { list: 0, followed: 1, none: 2 }, prefix: :show, validate: true
|
enum :replies_policy, { list: 0, followed: 1, none: 2 }, prefix: :show, validate: true
|
||||||
|
enum :type, { private_list: 0, public_list: 1 }
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
|
|
||||||
|
@ -26,12 +31,21 @@ class List < ApplicationRecord
|
||||||
has_many :accounts, through: :list_accounts
|
has_many :accounts, through: :list_accounts
|
||||||
has_many :active_accounts, -> { merge(ListAccount.active) }, through: :list_accounts, source: :account
|
has_many :active_accounts, -> { merge(ListAccount.active) }, through: :list_accounts, source: :account
|
||||||
|
|
||||||
validates :title, presence: true
|
validates :title, presence: true, length: { maximum: 30 }
|
||||||
|
validates :description, length: { maximum: 160 }
|
||||||
|
|
||||||
validate :validate_account_lists_limit, on: :create
|
validate :validate_account_lists_limit, on: :create
|
||||||
|
|
||||||
before_destroy :clean_feed_manager
|
before_destroy :clean_feed_manager
|
||||||
|
|
||||||
|
def slug
|
||||||
|
title.parameterize
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_url_param
|
||||||
|
{ id:, slug: }
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def validate_account_lists_limit
|
def validate_account_lists_limit
|
||||||
|
|
|
@ -139,6 +139,9 @@ class Status < ApplicationRecord
|
||||||
scope :tagged_with_none, lambda { |tag_ids|
|
scope :tagged_with_none, lambda { |tag_ids|
|
||||||
where('NOT EXISTS (SELECT * FROM statuses_tags forbidden WHERE forbidden.status_id = statuses.id AND forbidden.tag_id IN (?))', tag_ids)
|
where('NOT EXISTS (SELECT * FROM statuses_tags forbidden WHERE forbidden.status_id = statuses.id AND forbidden.tag_id IN (?))', tag_ids)
|
||||||
}
|
}
|
||||||
|
scope :distributable_visibility, -> { where(visibility: %i(public unlisted)) }
|
||||||
|
scope :list_eligible_visibility, ->(list = nil) { where(visibility: list&.public_list? ? %i(public unlisted) : %i(public unlisted private)) }
|
||||||
|
scope :not_direct_visibility, -> { where.not(visibility: :direct) }
|
||||||
|
|
||||||
after_create_commit :trigger_create_webhooks
|
after_create_commit :trigger_create_webhooks
|
||||||
after_update_commit :trigger_update_webhooks
|
after_update_commit :trigger_update_webhooks
|
||||||
|
|
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,
|
attributes :meta, :compose, :accounts,
|
||||||
:media_attachments, :settings,
|
:media_attachments, :settings,
|
||||||
:languages
|
:languages, :features
|
||||||
|
|
||||||
attribute :critical_updates_pending, if: -> { object&.role&.can?(:view_devops) && SoftwareUpdate.check_enabled? }
|
attribute :critical_updates_pending, if: -> { object&.role&.can?(:view_devops) && SoftwareUpdate.check_enabled? }
|
||||||
|
|
||||||
|
@ -85,6 +85,10 @@ class InitialStateSerializer < ActiveModel::Serializer
|
||||||
LanguagesHelper::SUPPORTED_LOCALES.map { |(key, value)| [key, value[0], value[1]] }
|
LanguagesHelper::SUPPORTED_LOCALES.map { |(key, value)| [key, value[0], value[1]] }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def features
|
||||||
|
Mastodon::Feature.enabled_features
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def default_meta_store
|
def default_meta_store
|
||||||
|
|
|
@ -1,9 +1,20 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class REST::ListSerializer < ActiveModel::Serializer
|
class REST::ListSerializer < ActiveModel::Serializer
|
||||||
attributes :id, :title, :replies_policy, :exclusive
|
include RoutingHelper
|
||||||
|
|
||||||
|
attributes :id, :title, :description, :type, :replies_policy,
|
||||||
|
:exclusive, :created_at, :updated_at
|
||||||
|
|
||||||
|
attribute :slug, if: -> { object.public_list? }
|
||||||
|
attribute :url, if: -> { object.public_list? }
|
||||||
|
has_one :account, serializer: REST::AccountSerializer, if: -> { object.public_list? }
|
||||||
|
|
||||||
def id
|
def id
|
||||||
object.id.to_s
|
object.id.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def url
|
||||||
|
public_list_url(object.to_url_param)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -25,6 +25,6 @@
|
||||||
.rules-list__hint= translation.hint
|
.rules-list__hint= translation.hint
|
||||||
|
|
||||||
.stacked-actions
|
.stacked-actions
|
||||||
- accept_path = @invite_code.present? ? public_invite_url(invite_code: @invite_code, accept: @accept_token) : new_user_registration_path(accept: @accept_token)
|
- accept_path = @invite_code.present? ? public_invite_url(invite_code: @invite_code, accept: @accept_token) : new_user_registration_path(accept: @accept_token, list_id: @list&.id)
|
||||||
= link_to t('auth.rules.accept'), accept_path, class: 'button'
|
= link_to t('auth.rules.accept'), accept_path, class: 'button'
|
||||||
= link_to t('auth.rules.back'), root_path, class: 'button button-tertiary'
|
= link_to t('auth.rules.back'), root_path, class: 'button button-tertiary'
|
||||||
|
|
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
|
all: Totes
|
||||||
limited: Limitades
|
limited: Limitades
|
||||||
title: Moderació
|
title: Moderació
|
||||||
|
moderation_notes:
|
||||||
|
create: Afegeix una nota de moderació
|
||||||
|
created_msg: S'ha creat la nota de moderació d'instància.
|
||||||
|
destroyed_msg: S'ha esborrat la nota de moderació d'instància.
|
||||||
|
title: Notes de moderació
|
||||||
private_comment: Comentari privat
|
private_comment: Comentari privat
|
||||||
public_comment: Comentari públic
|
public_comment: Comentari públic
|
||||||
purge: Purga
|
purge: Purga
|
||||||
|
@ -1339,6 +1344,10 @@ ca:
|
||||||
basic_information: Informació bàsica
|
basic_information: Informació bàsica
|
||||||
hint_html: "<strong>Personalitza el que la gent veu en el teu perfil públic i a prop dels teus tuts..</strong> És més probable que altres persones et segueixin i interaccionin amb tu quan tens emplenat el teu perfil i amb la teva imatge."
|
hint_html: "<strong>Personalitza el que la gent veu en el teu perfil públic i a prop dels teus tuts..</strong> És més probable que altres persones et segueixin i interaccionin amb tu quan tens emplenat el teu perfil i amb la teva imatge."
|
||||||
other: Altres
|
other: Altres
|
||||||
|
emoji_styles:
|
||||||
|
auto: Automàtic
|
||||||
|
native: Nadiu
|
||||||
|
twemoji: Twemoji
|
||||||
errors:
|
errors:
|
||||||
'400': La sol·licitud que vas emetre no era vàlida o no era correcta.
|
'400': La sol·licitud que vas emetre no era vàlida o no era correcta.
|
||||||
'403': No tens permís per a veure aquesta pàgina.
|
'403': No tens permís per a veure aquesta pàgina.
|
||||||
|
|
|
@ -653,7 +653,7 @@ da:
|
||||||
mark_as_sensitive_description_html: Medierne i det anmeldte indlæg markeres som sensitive, og en advarsel (strike) registreres mhp. eskalering ved evt. fremtidige overtrædelser fra samme konto.
|
mark_as_sensitive_description_html: Medierne i det anmeldte indlæg markeres som sensitive, og en advarsel (strike) registreres mhp. eskalering ved evt. fremtidige overtrædelser fra samme konto.
|
||||||
other_description_html: Se flere muligheder for at kontrollere kontoens adfærd og tilpasse kommunikationen til den anmeldte konto.
|
other_description_html: Se flere muligheder for at kontrollere kontoens adfærd og tilpasse kommunikationen til den anmeldte konto.
|
||||||
resolve_description_html: Ingen foranstaltninger træffes mod den anmeldte konto, ingen advarsel (strike) registreres og anmeldelsen lukkes.
|
resolve_description_html: Ingen foranstaltninger træffes mod den anmeldte konto, ingen advarsel (strike) registreres og anmeldelsen lukkes.
|
||||||
silence_description_html: Kontoen vil kun være synlig for følgerene eller dem, som manuelt slå den op, hvilket markant begrænser dens udbredelse. Kan altid omgøres. Lukker alle indrapporteringer af kontoen.
|
silence_description_html: Kontoen vil kun være synlig for dem, der allerede følger den eller manuelt slår den op, hvilket alvorligt begrænser dens rækkevidde. Kan altid omgøres. Lukker alle indrapporteringer af denne konto.
|
||||||
suspend_description_html: Kontoen inkl. alt indhold utilgængeliggøres og interaktion umuliggøres, og den slettes på et tidspunkt. Kan omgøres inden for 30 dage. Lukker alle indrapporteringer af kontoen.
|
suspend_description_html: Kontoen inkl. alt indhold utilgængeliggøres og interaktion umuliggøres, og den slettes på et tidspunkt. Kan omgøres inden for 30 dage. Lukker alle indrapporteringer af kontoen.
|
||||||
actions_description_html: Afgør, hvilke foranstaltning, der skal træffes for at løse denne anmeldelse. Ved en straffende foranstaltning mod den anmeldte konto, fremsendes en e-mailnotifikation, undtagen når kategorien <strong>Spam</strong> er valgt.
|
actions_description_html: Afgør, hvilke foranstaltning, der skal træffes for at løse denne anmeldelse. Ved en straffende foranstaltning mod den anmeldte konto, fremsendes en e-mailnotifikation, undtagen når kategorien <strong>Spam</strong> er valgt.
|
||||||
actions_description_remote_html: Fastslå en nødvendig handling mhp. at løse denne anmeldelse. Dette vil kun påvirke <strong>din</strong> servers kommunikation med, og indholdshåndtering for, fjernkontoen.
|
actions_description_remote_html: Fastslå en nødvendig handling mhp. at løse denne anmeldelse. Dette vil kun påvirke <strong>din</strong> servers kommunikation med, og indholdshåndtering for, fjernkontoen.
|
||||||
|
@ -1266,8 +1266,8 @@ da:
|
||||||
user_privacy_agreement_html: Jeg accepterer <a href="%{privacy_policy_path}" target="_blank">fortrolighedspolitikken</a>
|
user_privacy_agreement_html: Jeg accepterer <a href="%{privacy_policy_path}" target="_blank">fortrolighedspolitikken</a>
|
||||||
author_attribution:
|
author_attribution:
|
||||||
example_title: Eksempeltekst
|
example_title: Eksempeltekst
|
||||||
hint_html: Skriver du nyheder eller blogartikler uden for Mastodon? Styr, hvordan man bliver krediteret, når disse deles på Mastodon.
|
hint_html: Skriver du nyheder eller blogartikler uden for Mastodon? Styr, hvordan du bliver krediteret, når de bliver delt på Mastodon.
|
||||||
instructions: 'Sørg for, at denne kode er i artikelens HTML:'
|
instructions: 'Sørg for, at denne kode er i din artikels HTML:'
|
||||||
more_from_html: Flere fra %{name}
|
more_from_html: Flere fra %{name}
|
||||||
s_blog: "%{name}s blog"
|
s_blog: "%{name}s blog"
|
||||||
then_instructions: Tilføj dernæst publikationsdomænenavnet i feltet nedenfor.
|
then_instructions: Tilføj dernæst publikationsdomænenavnet i feltet nedenfor.
|
||||||
|
@ -1718,11 +1718,11 @@ da:
|
||||||
hint_html: "<strong>Tilpas hvordan din profil og dine indlæg kan findes.</strong> En række funktioner i Mastodon kan hjælpe dig med at nå ud til et bredere publikum, hvis du aktiverer dem. Tjek indstillingerne herunder for at sikre, at de passer til dit brugsscenarie."
|
hint_html: "<strong>Tilpas hvordan din profil og dine indlæg kan findes.</strong> En række funktioner i Mastodon kan hjælpe dig med at nå ud til et bredere publikum, hvis du aktiverer dem. Tjek indstillingerne herunder for at sikre, at de passer til dit brugsscenarie."
|
||||||
privacy: Privatliv
|
privacy: Privatliv
|
||||||
privacy_hint_html: Styr, hvor meget der ønskes synliggjort til gavn for andre. Folk finder interessante profiler og apps ved at tjekke andres følgere ud, samt se hvilke apps de sender fra, men dine præferencer ønskes muligvis ikke synliggjort.
|
privacy_hint_html: Styr, hvor meget der ønskes synliggjort til gavn for andre. Folk finder interessante profiler og apps ved at tjekke andres følgere ud, samt se hvilke apps de sender fra, men dine præferencer ønskes muligvis ikke synliggjort.
|
||||||
reach: Udbredelse
|
reach: Rækkevidde
|
||||||
reach_hint_html: Indstil om du vil blive opdaget og fulgt af nye mennesker. Ønsker du, at dine indlæg skal vises på Udforsk-siden? Ønsker du, at andre skal se dig i deres følg-anbefalinger? Ønsker du at acceptere alle nye følgere automatisk, eller vil du have detaljeret kontrol over hver og en?
|
reach_hint_html: Indstil om du vil blive opdaget og fulgt af nye mennesker. Ønsker du, at dine indlæg skal vises på Udforsk-siden? Ønsker du, at andre skal se dig i deres følg-anbefalinger? Ønsker du at acceptere alle nye følgere automatisk, eller vil du have detaljeret kontrol over hver og en?
|
||||||
search: Søg
|
search: Søgning
|
||||||
search_hint_html: Indstil hvordan du vil findes. Ønsker du, at folk skal finde dig gennem hvad du har skrevet offentligt? Vil du have folk udenfor Mastodon til at finde din profil, når de søger på nettet? Vær opmærksom på, at det ikke kan garanteres at dine offentlige indlæg er udelukket fra alle søgemaskiner.
|
search_hint_html: Indstil hvordan du vil findes. Ønsker du, at folk skal finde dig gennem hvad du har skrevet offentligt? Vil du have folk udenfor Mastodon til at finde din profil, når de søger på nettet? Vær opmærksom på, at det ikke kan garanteres at dine offentlige indlæg er udelukket fra alle søgemaskiner.
|
||||||
title: Fortrolighed og udbredelse
|
title: Fortrolighed og rækkevidde
|
||||||
privacy_policy:
|
privacy_policy:
|
||||||
title: Privatlivspolitik
|
title: Privatlivspolitik
|
||||||
reactions:
|
reactions:
|
||||||
|
@ -1923,7 +1923,7 @@ da:
|
||||||
'7889238': 3 måneder
|
'7889238': 3 måneder
|
||||||
min_age_label: Alderstærskel
|
min_age_label: Alderstærskel
|
||||||
min_favs: Behold indlæg favoritmarkeret mindst
|
min_favs: Behold indlæg favoritmarkeret mindst
|
||||||
min_favs_hint: Sletter ingen dine egne indlæg, som har modtaget minimum dette antal favoritmarkeringer. Lad stå tomt for at slette indlæg uanset antal favoritmarkeringer
|
min_favs_hint: Sletter ingen af dine egne indlæg, som har modtaget minimum dette antal favoritmarkeringer. Lad stå tom for at slette indlæg uanset antal favoritmarkeringer
|
||||||
min_reblogs: Behold indlæg fremhævet mindst
|
min_reblogs: Behold indlæg fremhævet mindst
|
||||||
min_reblogs_hint: Sletter ingen af dine egne indlæg, som er fremhævet flere end dette antal gange. Lad stå tom for at slette indlæg uanset antallet af fremhævelser
|
min_reblogs_hint: Sletter ingen af dine egne indlæg, som er fremhævet flere end dette antal gange. Lad stå tom for at slette indlæg uanset antallet af fremhævelser
|
||||||
stream_entries:
|
stream_entries:
|
||||||
|
@ -2095,7 +2095,7 @@ da:
|
||||||
verification:
|
verification:
|
||||||
extra_instructions_html: <strong>Tip:</strong> Linket på din hjemmeside kan være usynligt. Den vigtige del er <code>rel="me"</code> , som forhindrer impersonation på websteder med brugergenereret indhold. Du kan endda bruge et <code>link</code> tag i overskriften på siden i stedet for <code>a</code>, men HTML skal være tilgængelig uden at udføre JavaScript.
|
extra_instructions_html: <strong>Tip:</strong> Linket på din hjemmeside kan være usynligt. Den vigtige del er <code>rel="me"</code> , som forhindrer impersonation på websteder med brugergenereret indhold. Du kan endda bruge et <code>link</code> tag i overskriften på siden i stedet for <code>a</code>, men HTML skal være tilgængelig uden at udføre JavaScript.
|
||||||
here_is_how: Sådan gør du
|
here_is_how: Sådan gør du
|
||||||
hint_html: "<strong>Bekræftelse af din identitet på Mastodon er for alle.</strong> Baseret på åbne webstandarder, nu og for evigt gratis. Alt du behøver er en personlig hjemmeside, som folk genkende dig ved. Når du linker til denne hjemmeside fra din profil, vi vil kontrollere, at hjemmesiden linker tilbage til din profil og vise en visuel indikator på det."
|
hint_html: "<strong>Verificering af din identitet på Mastodon er for alle.</strong> Baseret på åbne webstandarder, nu og for altid gratis. Alt, hvad du behøver, er en personlig hjemmeside, som folk kender dig fra. Når du linker til denne hjemmeside fra din profil, kontrollerer vi, at hjemmesiden linker tilbage til din profil, og viser en visuel indikator på den."
|
||||||
instructions_html: Kopier og indsæt koden nedenfor i HTML på din hjemmeside. Tilføj derefter adressen på din hjemmeside i et af de ekstra felter på din profil på fanen "Redigér profil" og gem ændringer.
|
instructions_html: Kopier og indsæt koden nedenfor i HTML på din hjemmeside. Tilføj derefter adressen på din hjemmeside i et af de ekstra felter på din profil på fanen "Redigér profil" og gem ændringer.
|
||||||
verification: Bekræftelse
|
verification: Bekræftelse
|
||||||
verified_links: Dine bekræftede links
|
verified_links: Dine bekræftede links
|
||||||
|
|
|
@ -1349,6 +1349,10 @@ hu:
|
||||||
basic_information: Általános információk
|
basic_information: Általános információk
|
||||||
hint_html: "<strong>Tedd egyedivé, mi látnak mások a profilodon és a bejegyzéseid mellett.</strong> Mások nagyobb eséllyel követnek vissza és lépnek veled kapcsolatba, ha van kitöltött profilod és profilképed."
|
hint_html: "<strong>Tedd egyedivé, mi látnak mások a profilodon és a bejegyzéseid mellett.</strong> Mások nagyobb eséllyel követnek vissza és lépnek veled kapcsolatba, ha van kitöltött profilod és profilképed."
|
||||||
other: Egyéb
|
other: Egyéb
|
||||||
|
emoji_styles:
|
||||||
|
auto: Automatikus
|
||||||
|
native: Natív
|
||||||
|
twemoji: Twemoji
|
||||||
errors:
|
errors:
|
||||||
'400': A küldött kérés érvénytelen vagy hibás volt.
|
'400': A küldött kérés érvénytelen vagy hibás volt.
|
||||||
'403': Nincs jogosultságod az oldal megtekintéséhez.
|
'403': Nincs jogosultságod az oldal megtekintéséhez.
|
||||||
|
|
|
@ -61,6 +61,7 @@ ca:
|
||||||
setting_display_media_default: Amaga el contingut gràfic marcat com a sensible
|
setting_display_media_default: Amaga el contingut gràfic marcat com a sensible
|
||||||
setting_display_media_hide_all: Oculta sempre tot el contingut multimèdia
|
setting_display_media_hide_all: Oculta sempre tot el contingut multimèdia
|
||||||
setting_display_media_show_all: Mostra sempre el contingut gràfic
|
setting_display_media_show_all: Mostra sempre el contingut gràfic
|
||||||
|
setting_emoji_style: Com mostrar els emojis. "Automàtic" provarà de fer servir els emojis nadius, però revertirà a twemojis en els navegadors antics.
|
||||||
setting_system_scrollbars_ui: S'aplica només als navegadors d'escriptori basats en Safari i Chrome
|
setting_system_scrollbars_ui: S'aplica només als navegadors d'escriptori basats en Safari i Chrome
|
||||||
setting_use_blurhash: Els degradats es basen en els colors de les imatges ocultes, però n'enfosqueixen els detalls
|
setting_use_blurhash: Els degradats es basen en els colors de les imatges ocultes, però n'enfosqueixen els detalls
|
||||||
setting_use_pending_items: Amaga les actualitzacions de la línia de temps després de fer un clic, en lloc de desplaçar-les automàticament
|
setting_use_pending_items: Amaga les actualitzacions de la línia de temps després de fer un clic, en lloc de desplaçar-les automàticament
|
||||||
|
@ -240,6 +241,7 @@ ca:
|
||||||
setting_display_media_default: Per defecte
|
setting_display_media_default: Per defecte
|
||||||
setting_display_media_hide_all: Amaga-ho tot
|
setting_display_media_hide_all: Amaga-ho tot
|
||||||
setting_display_media_show_all: Mostra-ho tot
|
setting_display_media_show_all: Mostra-ho tot
|
||||||
|
setting_emoji_style: Estil d'emojis
|
||||||
setting_expand_spoilers: Desplega sempre els tuts marcats amb advertències de contingut
|
setting_expand_spoilers: Desplega sempre els tuts marcats amb advertències de contingut
|
||||||
setting_hide_network: Amaga la teva xarxa
|
setting_hide_network: Amaga la teva xarxa
|
||||||
setting_missing_alt_text_modal: Mostra un diàleg de confirmació abans de publicar contingut sense text alternatiu
|
setting_missing_alt_text_modal: Mostra un diàleg de confirmació abans de publicar contingut sense text alternatiu
|
||||||
|
|
|
@ -61,6 +61,7 @@ hu:
|
||||||
setting_display_media_default: Kényes tartalomnak jelölt média elrejtése
|
setting_display_media_default: Kényes tartalomnak jelölt média elrejtése
|
||||||
setting_display_media_hide_all: Média elrejtése mindig
|
setting_display_media_hide_all: Média elrejtése mindig
|
||||||
setting_display_media_show_all: Média megjelenítése mindig
|
setting_display_media_show_all: Média megjelenítése mindig
|
||||||
|
setting_emoji_style: Az emodzsik megjelenítési módja. Az „Automatikus” megpróbálja a natív emodzsikat használni, de az örökölt böngészők esetén a Twemojira vált vissza.
|
||||||
setting_system_scrollbars_ui: Csak Chrome és Safari alapú asztali böngészőkre vonatkozik
|
setting_system_scrollbars_ui: Csak Chrome és Safari alapú asztali böngészőkre vonatkozik
|
||||||
setting_use_blurhash: A kihomályosítás az eredeti képből történik, de minden részletet elrejt
|
setting_use_blurhash: A kihomályosítás az eredeti képből történik, de minden részletet elrejt
|
||||||
setting_use_pending_items: Idővonal frissítése csak kattintásra automatikus görgetés helyett
|
setting_use_pending_items: Idővonal frissítése csak kattintásra automatikus görgetés helyett
|
||||||
|
@ -241,6 +242,7 @@ hu:
|
||||||
setting_display_media_default: Alapértelmezés
|
setting_display_media_default: Alapértelmezés
|
||||||
setting_display_media_hide_all: Mindent elrejt
|
setting_display_media_hide_all: Mindent elrejt
|
||||||
setting_display_media_show_all: Mindent mutat
|
setting_display_media_show_all: Mindent mutat
|
||||||
|
setting_emoji_style: Emodzsistílus
|
||||||
setting_expand_spoilers: Tartalmi figyelmeztetéssel ellátott bejegyzések automatikus kinyitása
|
setting_expand_spoilers: Tartalmi figyelmeztetéssel ellátott bejegyzések automatikus kinyitása
|
||||||
setting_hide_network: Hálózatod elrejtése
|
setting_hide_network: Hálózatod elrejtése
|
||||||
setting_missing_alt_text_modal: Megerősítési párbeszédablak megjelenítése a helyettesítő szöveg nélküli média közzététele előtt
|
setting_missing_alt_text_modal: Megerősítési párbeszédablak megjelenítése a helyettesítő szöveg nélküli média közzététele előtt
|
||||||
|
|
|
@ -188,6 +188,11 @@ Rails.application.routes.draw do
|
||||||
get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy, format: false
|
get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy, format: false
|
||||||
get '/backups/:id/download', to: 'backups#download', as: :download_backup, format: false
|
get '/backups/:id/download', to: 'backups#download', as: :download_backup, format: false
|
||||||
|
|
||||||
|
constraints(id: /[\d]+/) do
|
||||||
|
get '/starter-pack/:id(-:slug)', to: 'lists#show', as: :public_list
|
||||||
|
get '/starter-pack/:id(-:slug)/posts', to: 'lists#show', format: false
|
||||||
|
end
|
||||||
|
|
||||||
resource :authorize_interaction, only: [:show]
|
resource :authorize_interaction, only: [:show]
|
||||||
resource :share, only: [:show]
|
resource :share, only: [:show]
|
||||||
|
|
||||||
|
|
|
@ -230,6 +230,7 @@ namespace :api, format: false do
|
||||||
|
|
||||||
resources :lists, only: [:index, :create, :show, :update, :destroy] do
|
resources :lists, only: [:index, :create, :show, :update, :destroy] do
|
||||||
resource :accounts, only: [:show, :create, :destroy], module: :lists
|
resource :accounts, only: [:show, :create, :destroy], module: :lists
|
||||||
|
resource :follow, only: [:create], module: :lists
|
||||||
end
|
end
|
||||||
|
|
||||||
namespace :featured_tags do
|
namespace :featured_tags do
|
||||||
|
|
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.boolean "hide_collections"
|
||||||
t.integer "avatar_storage_schema_version"
|
t.integer "avatar_storage_schema_version"
|
||||||
t.integer "header_storage_schema_version"
|
t.integer "header_storage_schema_version"
|
||||||
t.datetime "sensitized_at", precision: nil
|
|
||||||
t.integer "suspension_origin"
|
t.integer "suspension_origin"
|
||||||
|
t.datetime "sensitized_at", precision: nil
|
||||||
t.boolean "trendable"
|
t.boolean "trendable"
|
||||||
t.datetime "reviewed_at", precision: nil
|
t.datetime "reviewed_at", precision: nil
|
||||||
t.datetime "requested_review_at", precision: nil
|
t.datetime "requested_review_at", precision: nil
|
||||||
|
@ -613,12 +613,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "ip_blocks", force: :cascade do |t|
|
create_table "ip_blocks", force: :cascade do |t|
|
||||||
t.inet "ip", default: "0.0.0.0", null: false
|
|
||||||
t.integer "severity", default: 0, null: false
|
|
||||||
t.datetime "expires_at", precision: nil
|
|
||||||
t.text "comment", default: "", null: false
|
|
||||||
t.datetime "created_at", precision: nil, null: false
|
t.datetime "created_at", precision: nil, null: false
|
||||||
t.datetime "updated_at", precision: nil, null: false
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
|
t.datetime "expires_at", precision: nil
|
||||||
|
t.inet "ip", default: "0.0.0.0", null: false
|
||||||
|
t.integer "severity", default: 0, null: false
|
||||||
|
t.text "comment", default: "", null: false
|
||||||
t.index ["ip"], name: "index_ip_blocks_on_ip", unique: true
|
t.index ["ip"], name: "index_ip_blocks_on_ip", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -640,6 +640,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
|
||||||
t.datetime "updated_at", precision: nil, null: false
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
t.integer "replies_policy", default: 0, null: false
|
t.integer "replies_policy", default: 0, null: false
|
||||||
t.boolean "exclusive", default: false, null: false
|
t.boolean "exclusive", default: false, null: false
|
||||||
|
t.integer "type", default: 0, null: false
|
||||||
|
t.text "description", default: "", null: false
|
||||||
t.index ["account_id"], name: "index_lists_on_account_id"
|
t.index ["account_id"], name: "index_lists_on_account_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -1498,9 +1500,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
|
||||||
add_index "instances", ["domain"], name: "index_instances_on_domain", unique: true
|
add_index "instances", ["domain"], name: "index_instances_on_domain", unique: true
|
||||||
|
|
||||||
create_view "user_ips", sql_definition: <<-SQL
|
create_view "user_ips", sql_definition: <<-SQL
|
||||||
SELECT user_id,
|
SELECT t0.user_id,
|
||||||
ip,
|
t0.ip,
|
||||||
max(used_at) AS used_at
|
max(t0.used_at) AS used_at
|
||||||
FROM ( SELECT users.id AS user_id,
|
FROM ( SELECT users.id AS user_id,
|
||||||
users.sign_up_ip AS ip,
|
users.sign_up_ip AS ip,
|
||||||
users.created_at AS used_at
|
users.created_at AS used_at
|
||||||
|
@ -1517,7 +1519,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
|
||||||
login_activities.created_at
|
login_activities.created_at
|
||||||
FROM login_activities
|
FROM login_activities
|
||||||
WHERE (login_activities.success = true)) t0
|
WHERE (login_activities.success = true)) t0
|
||||||
GROUP BY user_id, ip;
|
GROUP BY t0.user_id, t0.ip;
|
||||||
SQL
|
SQL
|
||||||
create_view "account_summaries", materialized: true, sql_definition: <<-SQL
|
create_view "account_summaries", materialized: true, sql_definition: <<-SQL
|
||||||
SELECT accounts.id AS account_id,
|
SELECT accounts.id AS account_id,
|
||||||
|
@ -1538,9 +1540,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
|
||||||
add_index "account_summaries", ["account_id"], name: "index_account_summaries_on_account_id", unique: true
|
add_index "account_summaries", ["account_id"], name: "index_account_summaries_on_account_id", unique: true
|
||||||
|
|
||||||
create_view "global_follow_recommendations", materialized: true, sql_definition: <<-SQL
|
create_view "global_follow_recommendations", materialized: true, sql_definition: <<-SQL
|
||||||
SELECT account_id,
|
SELECT t0.account_id,
|
||||||
sum(rank) AS rank,
|
sum(t0.rank) AS rank,
|
||||||
array_agg(reason) AS reason
|
array_agg(t0.reason) AS reason
|
||||||
FROM ( SELECT account_summaries.account_id,
|
FROM ( SELECT account_summaries.account_id,
|
||||||
((count(follows.id))::numeric / (1.0 + (count(follows.id))::numeric)) AS rank,
|
((count(follows.id))::numeric / (1.0 + (count(follows.id))::numeric)) AS rank,
|
||||||
'most_followed'::text AS reason
|
'most_followed'::text AS reason
|
||||||
|
@ -1564,8 +1566,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
|
||||||
WHERE (follow_recommendation_suppressions.account_id = statuses.account_id)))))
|
WHERE (follow_recommendation_suppressions.account_id = statuses.account_id)))))
|
||||||
GROUP BY account_summaries.account_id
|
GROUP BY account_summaries.account_id
|
||||||
HAVING (sum((status_stats.reblogs_count + status_stats.favourites_count)) >= (5)::numeric)) t0
|
HAVING (sum((status_stats.reblogs_count + status_stats.favourites_count)) >= (5)::numeric)) t0
|
||||||
GROUP BY account_id
|
GROUP BY t0.account_id
|
||||||
ORDER BY (sum(rank)) DESC;
|
ORDER BY (sum(t0.rank)) DESC;
|
||||||
SQL
|
SQL
|
||||||
add_index "global_follow_recommendations", ["account_id"], name: "index_global_follow_recommendations_on_account_id", unique: true
|
add_index "global_follow_recommendations", ["account_id"], name: "index_global_follow_recommendations_on_account_id", unique: true
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user