WIP: Add starter packs

This commit is contained in:
Eugen Rochko 2024-11-27 01:30:02 +01:00
parent 58c40caeb4
commit eae6b98ace
39 changed files with 808 additions and 96 deletions

View File

@ -1,25 +1,30 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::Lists::AccountsController < Api::BaseController class Api::V1::Lists::AccountsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show] include Authorization
before_action -> { authorize_if_got_token! :read, :'read:lists' }, only: [:show]
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show] before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show]
before_action :require_user! before_action :require_user!, except: [:show]
before_action :set_list before_action :set_list
after_action :insert_pagination_headers, only: :show after_action :insert_pagination_headers, only: :show
def show def show
authorize @list, :show?
@accounts = load_accounts @accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer render json: @accounts, each_serializer: REST::AccountSerializer
end end
def create def create
authorize @list, :update?
AddAccountsToListService.new.call(@list, Account.find(account_ids)) AddAccountsToListService.new.call(@list, Account.find(account_ids))
render_empty render_empty
end end
def destroy def destroy
authorize @list, :update?
RemoveAccountsFromListService.new.call(@list, Account.where(id: account_ids)) RemoveAccountsFromListService.new.call(@list, Account.where(id: account_ids))
render_empty render_empty
end end
@ -27,7 +32,7 @@ class Api::V1::Lists::AccountsController < Api::BaseController
private private
def set_list def set_list
@list = List.where(account: current_account).find(params[:list_id]) @list = List.find(params[:list_id])
end end
def load_accounts def load_accounts

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class Api::V1::Lists::FollowsController < Api::BaseController
before_action -> { doorkeeper_authorize! :follow, :write, :'write:follows' }
before_action :require_user!
before_action :set_list
def create
FollowFromPublicListWorker.perform_async(current_account.id, @list.id)
render json: {}, status: 202
end
private
def set_list
@list = List.where(type: :public_list).find(params[:list_id])
end
end

View File

@ -1,10 +1,13 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::ListsController < Api::BaseController class Api::V1::ListsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index, :show] include Authorization
before_action -> { authorize_if_got_token! :read, :'read:lists' }, only: [:show]
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index]
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show] before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show]
before_action :require_user! before_action :require_user!, except: [:show]
before_action :set_list, except: [:index, :create] before_action :set_list, except: [:index, :create]
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

View File

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

View File

@ -7,6 +7,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
layout :determine_layout layout :determine_layout
before_action :set_invite, only: [:new, :create] before_action :set_invite, only: [:new, :create]
before_action :set_list, only: [:new, :create]
before_action :check_enabled_registrations, only: [:new, :create] before_action :check_enabled_registrations, only: [:new, :create]
before_action :configure_sign_up_params, only: [:create] before_action :configure_sign_up_params, only: [:create]
before_action :set_sessions, only: [:edit, :update] before_action :set_sessions, only: [:edit, :update]
@ -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

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class ListsController < ApplicationController
include WebAppControllerConcern
before_action :set_list
def show; end
private
def set_list
@list = List.where(type: :public_list).find(params[:id])
end
end

View File

@ -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>;
} }

View File

@ -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`);

View File

@ -1,10 +1,21 @@
// See app/serializers/rest/list_serializer.rb // See app/serializers/rest/list_serializer.rb
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
export type RepliesPolicyType = 'list' | 'followed' | 'none'; export type RepliesPolicyType = 'list' | 'followed' | 'none';
export type ListType = 'private_list' | 'public_list';
export interface ApiListJSON { export interface ApiListJSON {
id: string; id: string;
url?: string;
title: string; title: string;
slug?: string;
type: ListType;
description: string;
created_at: string;
updated_at: string;
exclusive: boolean; exclusive: boolean;
replies_policy: RepliesPolicyType; replies_policy: RepliesPolicyType;
account?: ApiAccountJSON;
} }

View File

@ -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,
};

View File

@ -1,25 +0,0 @@
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { Avatar } from 'mastodon/components/avatar';
import { useAppSelector } from 'mastodon/store';
export const AuthorLink = ({ accountId }) => {
const account = useAppSelector(state => state.getIn(['accounts', accountId]));
if (!account) {
return null;
}
return (
<Link to={`/@${account.get('acct')}`} className='story__details__shared__author-link' data-hover-card-account={accountId}>
<Avatar account={account} size={16} />
<bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
</Link>
);
};
AuthorLink.propTypes = {
accountId: PropTypes.string.isRequired,
};

View File

@ -0,0 +1,25 @@
import { Link } from 'react-router-dom';
import { Avatar } from 'mastodon/components/avatar';
import { useAppSelector } from 'mastodon/store';
export const AuthorLink: React.FC<{
accountId: string;
}> = ({ accountId }) => {
const account = useAppSelector((state) => state.accounts.get(accountId));
if (!account) {
return null;
}
return (
<Link
to={`/@${account.acct}`}
className='story__details__shared__author-link'
data-hover-card-account={accountId}
>
<Avatar account={account} size={16} />
<bdi dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
</Link>
);
};

View File

@ -8,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>

View File

@ -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>

View File

@ -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'>

View File

@ -0,0 +1,120 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { openModal } from 'mastodon/actions/modal';
import { apiFollowList } from 'mastodon/api/lists';
import { Button } from 'mastodon/components/button';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { AuthorLink } from 'mastodon/features/explore/components/author_link';
import { useIdentity } from 'mastodon/identity_context';
import { registrationsOpen, sso_redirect, me } from 'mastodon/initial_state';
import type { List } from 'mastodon/models/list';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
export const Hero: React.FC<{
list: List;
}> = ({ list }) => {
const { signedIn } = useIdentity();
const dispatch = useAppDispatch();
const signupUrl = useAppSelector(
(state) =>
state.server.getIn(['server', 'registrations', 'url'], null) ??
'/auth/sign_up',
) as string;
const handleClosedRegistrationsClick = useCallback(() => {
dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS', modalProps: {} }));
}, [dispatch]);
const handleFollowAll = useCallback(() => {
apiFollowList(list.id)
.then(() => {
// TODO
return '';
})
.catch(() => {
// TODO
});
}, [list]);
let signUpButton;
if (sso_redirect) {
signUpButton = (
<a href={sso_redirect} data-method='post' className='button'>
<FormattedMessage id='' defaultMessage='Create account' />
</a>
);
} else if (registrationsOpen) {
signUpButton = (
<a href={`${signupUrl}?list_id=${list.id}`} className='button'>
<FormattedMessage id='' defaultMessage='Create account' />
</a>
);
} else {
signUpButton = (
<Button onClick={handleClosedRegistrationsClick}>
<FormattedMessage id='' defaultMessage='Create account' />
</Button>
);
}
return (
<div className='lists__hero'>
<div className='lists__hero__title'>
<h1>{list.title}</h1>
<p>
{list.description.length > 0 ? (
list.description
) : (
<FormattedMessage id='' defaultMessage='No description given.' />
)}
</p>
</div>
<div className='lists__hero__meta'>
<FormattedMessage
id=''
defaultMessage='Public list by {name}'
values={{
name: list.account_id && <AuthorLink accountId={list.account_id} />,
}}
>
{(chunks) => (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>{chunks}</>
)}
</FormattedMessage>
<span aria-hidden>{' · '}</span>
<FormattedMessage
id=''
defaultMessage='Created {timeAgo}'
values={{
timeAgo: (
<RelativeTimestamp timestamp={list.created_at} short={false} />
),
}}
/>
</div>
<div className='lists__hero__actions'>
{!signedIn && signUpButton}
{me !== list.account_id && (
<Button onClick={handleFollowAll} secondary={!signedIn}>
<FormattedMessage id='' defaultMessage='Follow all' />
</Button>
)}
{me === list.account_id && (
<Link className='button' to={`/lists/${list.id}/edit`}>
<FormattedMessage id='' defaultMessage='Edit list' />
</Link>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,136 @@
import { useEffect, useCallback } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { Helmet } from 'react-helmet';
import { NavLink, useParams, Route, Switch } from 'react-router-dom';
import PackageIcon from '@/material-icons/400-24px/package_2.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import { fetchList } from 'mastodon/actions/lists';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { CopyIconButton } from 'mastodon/components/copy_icon_button';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import type { List } from 'mastodon/models/list';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { Hero } from './components/hero';
import { Members } from './members';
import { Statuses } from './statuses';
interface PublicListParams {
id: string;
slug?: string;
}
const messages = defineMessages({
copyLink: { id: '', defaultMessage: 'Copy link' },
shareLink: { id: '', defaultMessage: 'Share link' },
});
const CopyLinkButton: React.FC<{
list: List;
}> = ({ list }) => {
const intl = useIntl();
const handleClick = useCallback(() => {
void navigator.share({
url: list.url,
});
}, [list]);
if ('share' in navigator) {
return (
<button
className='column-header__button'
onClick={handleClick}
title={intl.formatMessage(messages.shareLink)}
aria-label={intl.formatMessage(messages.shareLink)}
>
<Icon id='' icon={ShareIcon} />
</button>
);
}
return (
<CopyIconButton
className='column-header__button'
title={intl.formatMessage(messages.copyLink)}
value={list.url}
/>
);
};
const PublicList: React.FC<{
multiColumn: boolean;
}> = ({ multiColumn }) => {
const { id } = useParams<PublicListParams>();
const dispatch = useAppDispatch();
const list = useAppSelector((state) => state.lists.get(id));
const accountId = list?.account_id;
const slug = list?.slug ? `${list.id}-${list.slug}` : list?.id;
useEffect(() => {
dispatch(fetchList(id));
}, [dispatch, id]);
if (typeof list === 'undefined') {
return (
<Column>
<LoadingIndicator />
</Column>
);
} else if (list === null || !accountId) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
return (
<Column>
<ColumnHeader
icon='package'
iconComponent={PackageIcon}
title={list.title}
multiColumn={multiColumn}
extraButton={<CopyLinkButton list={list} />}
/>
<Hero list={list} />
<div className='account__section-headline'>
<NavLink exact to={`/starter-pack/${slug}`}>
<FormattedMessage tagName='div' id='' defaultMessage='Members' />
</NavLink>
<NavLink exact to={`/starter-pack/${slug}/posts`}>
<FormattedMessage tagName='div' id='' defaultMessage='Posts' />
</NavLink>
</div>
<Switch>
<Route
path={['/starter-pack/:id(\\d+)', '/starter-pack/:id(\\d+)-:slug']}
exact
component={Members}
/>
<Route
path={[
'/starter-pack/:id(\\d+)/posts',
'/starter-pack/:id(\\d+)-:slug/posts',
]}
component={Statuses}
/>
</Switch>
<Helmet>
<title>{list.title}</title>
<meta name='robots' content='all' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default PublicList;

View File

@ -0,0 +1,48 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { importFetchedAccounts } from 'mastodon/actions/importer';
import { apiGetAccounts } from 'mastodon/api/lists';
import { Account } from 'mastodon/components/account';
import ScrollableList from 'mastodon/components/scrollable_list';
import { useAppDispatch } from 'mastodon/store';
export const Members: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const { id }: { id: string } = useParams();
const [accountIds, setAccountIds] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const dispatch = useAppDispatch();
useEffect(() => {
setLoading(true);
apiGetAccounts(id)
.then((data) => {
dispatch(importFetchedAccounts(data));
setAccountIds(data.map((a) => a.id));
setLoading(false);
return '';
})
.catch(() => {
setLoading(false);
});
}, [dispatch, id]);
return (
<ScrollableList
scrollKey={`public_list/${id}/members`}
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
isLoading={loading}
showLoading={loading && accountIds.length === 0}
hasMore={false}
>
{accountIds.map((accountId) => (
<Account key={accountId} id={accountId} withBio={false} />
))}
</ScrollableList>
);
};

View File

@ -0,0 +1,35 @@
import { useCallback, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { expandListTimeline } from 'mastodon/actions/timelines';
import StatusList from 'mastodon/features/ui/containers/status_list_container';
import { useAppDispatch } from 'mastodon/store';
export const Statuses: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const { id }: { id: string } = useParams();
const dispatch = useAppDispatch();
const handleLoadMore = useCallback(
(maxId: string) => {
void dispatch(expandListTimeline(id, { maxId }));
},
[dispatch, id],
);
useEffect(() => {
void dispatch(expandListTimeline(id));
}, [dispatch, id]);
return (
<StatusList
scrollKey={`public_list/${id}/statuses`}
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
timelineId={`list:${id}`}
onLoadMore={handleLoadMore}
/>
);
};

View File

@ -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 />

View File

@ -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');
} }

View File

@ -0,0 +1,8 @@
interface MenuItem {
text: string;
action?: () => void;
to?: string;
dangerous?: boolean;
}
export type MenuItems = (MenuItem | null)[];

View File

@ -3,16 +3,31 @@ import { Record } from 'immutable';
import type { ApiListJSON } from 'mastodon/api_types/lists'; import type { ApiListJSON } from 'mastodon/api_types/lists';
type ListShape = Required<ApiListJSON>; // no changes from server shape interface ListShape extends Required<Omit<ApiListJSON, 'account'>> {
account_id?: string;
}
export type List = RecordOf<ListShape>; export type List = RecordOf<ListShape>;
const ListFactory = Record<ListShape>({ const ListFactory = Record<ListShape>({
id: '', id: '',
url: '',
title: '', title: '',
slug: '',
description: '',
type: 'private_list',
exclusive: false, exclusive: false,
replies_policy: 'list', replies_policy: 'list',
account_id: undefined,
created_at: '',
updated_at: '',
}); });
export function createList(attributes: Partial<ListShape>) { export const createList = (serverJSON: ApiListJSON): List => {
return ListFactory(attributes); const { account, ...listJSON } = serverJSON;
}
return ListFactory({
...listJSON,
account_id: account?.id,
});
};

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M440-91v-366L120-642v321q0 22 10.5 40t29.5 29L440-91Zm80 0 280-161q19-11 29.5-29t10.5-40v-321L520-457v366Zm159-550 118-69-277-159q-19-11-40-11t-40 11l-79 45 318 183ZM480-526l119-68-317-184-120 69 318 183Z"/></svg>

After

Width:  |  Height:  |  Size: 310 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M440-183v-274L200-596v274l240 139Zm80 0 240-139v-274L520-457v274Zm-80 92L160-252q-19-11-29.5-29T120-321v-318q0-22 10.5-40t29.5-29l280-161q19-11 40-11t40 11l280 161q19 11 29.5 29t10.5 40v318q0 22-10.5 40T800-252L520-91q-19 11-40 11t-40-11Zm200-528 77-44-237-137-78 45 238 136Zm-160 93 78-45-237-137-78 45 237 137Z"/></svg>

After

Width:  |  Height:  |  Size: 418 B

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class ListPolicy < ApplicationPolicy
def show?
record.public_list? || owned?
end
def update?
owned?
end
def destroy?
owned?
end
private
def owned?
user_signed_in? && record.account_id == current_account.id
end
end

View File

@ -1,9 +1,20 @@
# frozen_string_literal: true # frozen_string_literal: true
class REST::ListSerializer < ActiveModel::Serializer class REST::ListSerializer < ActiveModel::Serializer
attributes :id, :title, :replies_policy, :exclusive include RoutingHelper
attributes :id, :title, :description, :type, :replies_policy,
:exclusive, :created_at, :updated_at
attribute :slug, if: -> { object.public_list? }
attribute :url, if: -> { object.public_list? }
has_one :account, serializer: REST::AccountSerializer, if: -> { object.public_list? }
def id def id
object.id.to_s object.id.to_s
end end
def url
public_list_url(object.to_url_param)
end
end end

View File

@ -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'

View File

@ -0,0 +1,7 @@
%meta{ name: 'description', content: list.description }/
= opengraph 'og:url', public_list_url(list.to_url_param)
= opengraph 'og:site_name', site_title
= opengraph 'og:title', yield(:page_title).strip
= opengraph 'og:description', list.description
= opengraph 'twitter:card', 'summary'

View File

@ -0,0 +1,6 @@
- content_for :page_title, @list.title
- content_for :header_tags do
= render 'og', list: @list
= render partial: 'shared/web_app'

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class FollowFromPublicListWorker
include Sidekiq::Worker
def perform(into_account_id, list_id)
list = List.where(type: :public_list).find(list_id)
into_account = Account.find(into_account_id)
list.accounts.find_each do |target_account|
FollowService.new.call(into_account, target_account)
rescue
# Skip past disallowed follows
end
rescue ActiveRecord::RecordNotFound
true
end
end

View File

@ -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]

View File

@ -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

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class AddTypeToLists < ActiveRecord::Migration[7.2]
def change
add_column :lists, :type, :integer, default: 0, null: false
add_column :lists, :description, :text, default: '', null: false
end
end

View File

@ -191,8 +191,8 @@ ActiveRecord::Schema[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