mirror of
https://github.com/mastodon/mastodon.git
synced 2024-12-28 22:15:06 +01:00
WIP: Add starter packs
This commit is contained in:
parent
58c40caeb4
commit
eae6b98ace
@ -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]
|
||||||
|
|
||||||
rescue_from ArgumentError do |e|
|
rescue_from ArgumentError do |e|
|
||||||
@ -17,6 +20,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
|
||||||
|
|
||||||
@ -26,11 +30,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
|
||||||
@ -38,10 +44,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]
|
||||||
@ -110,6 +111,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
|
@ -5,7 +5,7 @@ import { AxiosError } from 'axios';
|
|||||||
import type { AxiosResponse } from 'axios';
|
import type { AxiosResponse } from 'axios';
|
||||||
|
|
||||||
interface Alert {
|
interface Alert {
|
||||||
title: string | MessageDescriptor;
|
title?: string | MessageDescriptor;
|
||||||
message: string | MessageDescriptor;
|
message: string | MessageDescriptor;
|
||||||
values?: Record<string, string | number | Date>;
|
values?: Record<string, string | number | Date>;
|
||||||
}
|
}
|
||||||
|
@ -30,3 +30,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;
|
||||||
}
|
}
|
||||||
|
@ -1,29 +1,36 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
import { defineMessages } from 'react-intl';
|
import { defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { useDispatch } from 'react-redux';
|
|
||||||
|
|
||||||
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
||||||
import { showAlert } from 'mastodon/actions/alerts';
|
import { showAlert } from 'mastodon/actions/alerts';
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
copied: { id: 'copy_icon_button.copied', defaultMessage: 'Copied to clipboard' },
|
copied: {
|
||||||
|
id: 'copy_icon_button.copied',
|
||||||
|
defaultMessage: 'Copied to clipboard',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CopyIconButton = ({ title, value, className }) => {
|
export const CopyIconButton: React.FC<{
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
className?: string;
|
||||||
|
}> = ({ title, value, className }) => {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
navigator.clipboard.writeText(value);
|
void navigator.clipboard.writeText(value);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
dispatch(showAlert({ message: messages.copied }));
|
dispatch(showAlert({ message: messages.copied }));
|
||||||
setTimeout(() => setCopied(false), 700);
|
setTimeout(() => {
|
||||||
|
setCopied(false);
|
||||||
|
}, 700);
|
||||||
}, [setCopied, value, dispatch]);
|
}, [setCopied, value, dispatch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -31,13 +38,8 @@ export const CopyIconButton = ({ title, value, className }) => {
|
|||||||
className={classNames(className, copied ? 'copied' : 'copyable')}
|
className={classNames(className, copied ? 'copied' : 'copyable')}
|
||||||
title={title}
|
title={title}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
icon=''
|
||||||
iconComponent={ContentCopyIcon}
|
iconComponent={ContentCopyIcon}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
CopyIconButton.propTypes = {
|
|
||||||
title: PropTypes.string,
|
|
||||||
value: PropTypes.string,
|
|
||||||
className: PropTypes.string,
|
|
||||||
};
|
|
@ -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,9 +8,12 @@ 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';
|
||||||
|
import type { List } from 'mastodon/models/list';
|
||||||
|
import type { MenuItems } from 'mastodon/models/dropdown_menu';
|
||||||
import { Column } from 'mastodon/components/column';
|
import { Column } from 'mastodon/components/column';
|
||||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
@ -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: MenuItems = [
|
||||||
);
|
{ 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]);
|
||||||
|
|
||||||
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>
|
||||||
|
|
||||||
<DropdownMenuContainer
|
<DropdownMenuContainer
|
||||||
@ -129,7 +161,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>
|
||||||
|
|
||||||
|
@ -158,6 +158,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[]>([]);
|
||||||
@ -285,7 +286,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>
|
||||||
|
@ -79,7 +79,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);
|
||||||
|
|
||||||
@ -104,6 +106,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);
|
||||||
@ -111,6 +120,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);
|
||||||
@ -126,8 +142,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);
|
||||||
@ -137,8 +155,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);
|
||||||
@ -151,7 +171,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
|
||||||
@ -193,6 +223,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'>
|
||||||
@ -239,6 +291,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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -61,6 +61,7 @@ import {
|
|||||||
Lists,
|
Lists,
|
||||||
ListEdit,
|
ListEdit,
|
||||||
ListMembers,
|
ListMembers,
|
||||||
|
PublicList,
|
||||||
Blocks,
|
Blocks,
|
||||||
DomainBlocks,
|
DomainBlocks,
|
||||||
Mutes,
|
Mutes,
|
||||||
@ -212,6 +213,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(/* webpackChunkName: "features/list_timeline" */'../../list_timeline');
|
return import(/* webpackChunkName: "features/list_timeline" */'../../list_timeline');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PublicList () {
|
||||||
|
return import(/* webpackChunkName: "features/public_list" */'../../public_list');
|
||||||
|
}
|
||||||
|
|
||||||
export function Lists () {
|
export function Lists () {
|
||||||
return import(/* webpackChunkName: "features/lists" */'../../lists');
|
return import(/* webpackChunkName: "features/lists" */'../../lists');
|
||||||
}
|
}
|
||||||
|
8
app/javascript/mastodon/models/dropdown_menu.ts
Normal file
8
app/javascript/mastodon/models/dropdown_menu.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
interface MenuItem {
|
||||||
|
text: string;
|
||||||
|
action?: () => void;
|
||||||
|
to?: string;
|
||||||
|
dangerous?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MenuItems = (MenuItem | null)[];
|
@ -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 |
@ -4272,7 +4272,6 @@ a.status-card {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.column-header__buttons {
|
.column-header__buttons {
|
||||||
height: 48px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4323,6 +4322,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 {
|
||||||
@ -10765,6 +10774,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;
|
||||||
|
@ -149,7 +149,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(:preloadable_poll, :media_attachments, reblog: :account).limit(FeedManager::MAX_ITEMS / 4)
|
query = from_account.statuses.list_eligible_visibility(list).includes(:preloadable_poll, :media_attachments, 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
|
||||||
@ -440,6 +440,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
|
||||||
|
@ -5,32 +5,46 @@
|
|||||||
# 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
|
enum :replies_policy, { list: 0, followed: 1, none: 2 }, prefix: :show
|
||||||
|
enum :type, { private_list: 0, public_list: 1 }
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
|
|
||||||
has_many :list_accounts, inverse_of: :list, dependent: :destroy
|
has_many :list_accounts, inverse_of: :list, dependent: :destroy
|
||||||
has_many :accounts, through: :list_accounts
|
has_many :accounts, through: :list_accounts
|
||||||
|
|
||||||
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
|
||||||
|
@ -126,7 +126,7 @@ class Status < ApplicationRecord
|
|||||||
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 :distributable_visibility, -> { where(visibility: %i(public unlisted)) }
|
||||||
scope :list_eligible_visibility, -> { where(visibility: %i(public unlisted private)) }
|
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) }
|
scope :not_direct_visibility, -> { where.not(visibility: :direct) }
|
||||||
|
|
||||||
after_create_commit :trigger_create_webhooks
|
after_create_commit :trigger_create_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
|
# 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
|
||||||
|
@ -24,7 +24,7 @@
|
|||||||
.rules-list__hint= rule.hint
|
.rules-list__hint= rule.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
|
@ -186,6 +186,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]
|
||||||
|
|
||||||
|
@ -217,6 +217,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
|
39
db/schema.rb
39
db/schema.rb
@ -191,8 +191,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_05_163118) 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
|
||||||
@ -556,12 +556,12 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_05_163118) 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
|
||||||
|
|
||||||
@ -583,6 +583,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_05_163118) 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
|
||||||
|
|
||||||
@ -1103,6 +1105,15 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_05_163118) do
|
|||||||
t.index "lower((name)::text) text_pattern_ops", name: "index_tags_on_name_lower_btree", unique: true
|
t.index "lower((name)::text) text_pattern_ops", name: "index_tags_on_name_lower_btree", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "terms_of_services", force: :cascade do |t|
|
||||||
|
t.text "text", default: "", null: false
|
||||||
|
t.text "changelog", default: "", null: false
|
||||||
|
t.datetime "published_at"
|
||||||
|
t.datetime "notification_sent_at"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
end
|
||||||
|
|
||||||
create_table "tombstones", force: :cascade do |t|
|
create_table "tombstones", force: :cascade do |t|
|
||||||
t.bigint "account_id"
|
t.bigint "account_id"
|
||||||
t.string "uri", null: false
|
t.string "uri", null: false
|
||||||
@ -1388,9 +1399,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_05_163118) 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
|
||||||
@ -1407,7 +1418,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_05_163118) 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,
|
||||||
@ -1428,9 +1439,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_05_163118) 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
|
||||||
@ -1454,8 +1465,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_05_163118) 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