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

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
class Api::V1::ListsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index, :show]
include Authorization
before_action -> { authorize_if_got_token! :read, :'read:lists' }, only: [:show]
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index]
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show]
before_action :require_user!
before_action :require_user!, except: [:show]
before_action :set_list, except: [:index, :create]
rescue_from ArgumentError do |e|
@ -17,6 +20,7 @@ class Api::V1::ListsController < Api::BaseController
end
def show
authorize @list, :show?
render json: @list, serializer: REST::ListSerializer
end
@ -26,11 +30,13 @@ class Api::V1::ListsController < Api::BaseController
end
def update
authorize @list, :update?
@list.update!(list_params)
render json: @list, serializer: REST::ListSerializer
end
def destroy
authorize @list, :destroy?
@list.destroy!
render_empty
end
@ -38,10 +44,10 @@ class Api::V1::ListsController < Api::BaseController
private
def set_list
@list = List.where(account: current_account).find(params[:id])
@list = List.find(params[:id])
end
def list_params
params.permit(:title, :replies_policy, :exclusive)
params.permit(:title, :description, :type, :replies_policy, :exclusive)
end
end

View File

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

View File

@ -7,6 +7,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
layout :determine_layout
before_action :set_invite, only: [:new, :create]
before_action :set_list, only: [:new, :create]
before_action :check_enabled_registrations, only: [:new, :create]
before_action :configure_sign_up_params, only: [:create]
before_action :set_sessions, only: [:edit, :update]
@ -110,6 +111,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end
end
def set_list
@list = List.where(type: :public_list).find_by(id: params[:list_id])
end
def determine_layout
%w(edit update).include?(action_name) ? 'admin' : 'auth'
end

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';
interface Alert {
title: string | MessageDescriptor;
title?: string | MessageDescriptor;
message: string | MessageDescriptor;
values?: Record<string, string | number | Date>;
}

View File

@ -30,3 +30,6 @@ export const apiRemoveAccountFromList = (listId: string, accountId: string) =>
apiRequestDelete(`v1/lists/${listId}/accounts`, {
account_ids: [accountId],
});
export const apiFollowList = (listId: string) =>
apiRequestPost(`v1/lists/${listId}/follow`);

View File

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

View File

@ -1,29 +1,36 @@
import PropTypes from 'prop-types';
import { useState, useCallback } from 'react';
import { defineMessages } from 'react-intl';
import classNames from 'classnames';
import { useDispatch } from 'react-redux';
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
import { showAlert } from 'mastodon/actions/alerts';
import { IconButton } from 'mastodon/components/icon_button';
import { useAppDispatch } from 'mastodon/store';
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 dispatch = useDispatch();
const dispatch = useAppDispatch();
const handleClick = useCallback(() => {
navigator.clipboard.writeText(value);
void navigator.clipboard.writeText(value);
setCopied(true);
dispatch(showAlert({ message: messages.copied }));
setTimeout(() => setCopied(false), 700);
setTimeout(() => {
setCopied(false);
}, 700);
}, [setCopied, value, dispatch]);
return (
@ -31,13 +38,8 @@ export const CopyIconButton = ({ title, value, className }) => {
className={classNames(className, copied ? 'copied' : 'copyable')}
title={title}
onClick={handleClick}
icon=''
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 ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import PackageIcon from '@/material-icons/400-24px/package_2.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchLists } from 'mastodon/actions/lists';
import { openModal } from 'mastodon/actions/modal';
import type { List } from 'mastodon/models/list';
import type { MenuItems } from 'mastodon/models/dropdown_menu';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
@ -25,12 +28,12 @@ const messages = defineMessages({
edit: { id: 'lists.edit', defaultMessage: 'Edit list' },
delete: { id: 'lists.delete', defaultMessage: 'Delete list' },
more: { id: 'status.more', defaultMessage: 'More' },
copyLink: { id: '', defaultMessage: 'Copy link' },
});
const ListItem: React.FC<{
id: string;
title: string;
}> = ({ id, title }) => {
list: List;
}> = ({ list }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
@ -39,25 +42,54 @@ const ListItem: React.FC<{
openModal({
modalType: 'CONFIRM_DELETE_LIST',
modalProps: {
listId: id,
listId: list.id,
},
}),
);
}, [dispatch, id]);
}, [dispatch, list]);
const menu = useMemo(
() => [
{ text: intl.formatMessage(messages.edit), to: `/lists/${id}/edit` },
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
],
[intl, id, handleDeleteClick],
);
const handleCopyClick = useCallback(() => {
void navigator.clipboard.writeText(list.url);
}, [list]);
const menu = useMemo(() => {
const tmp: 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 (
<div className='lists__item'>
<Link to={`/lists/${id}`} className='lists__item__title'>
<Icon id='list-ul' icon={ListAltIcon} />
<span>{title}</span>
<Link
to={
list.type === 'public_list'
? `/starter-pack/${list.id}-${list.slug}`
: `/lists/${list.id}`
}
className='lists__item__title'
>
<Icon
id={list.type === 'public_list' ? 'package' : 'list-ul'}
icon={list.type === 'public_list' ? PackageIcon : ListAltIcon}
/>
<span>{list.title}</span>
</Link>
<DropdownMenuContainer
@ -129,7 +161,7 @@ const Lists: React.FC<{
bindToDocument={!multiColumn}
>
{lists.map((list) => (
<ListItem key={list.id} id={list.id} title={list.title} />
<ListItem key={list.id} list={list} />
))}
</ScrollableList>

View File

@ -158,6 +158,7 @@ const ListMembers: React.FC<{
const { id } = useParams<{ id: string }>();
const intl = useIntl();
const list = useAppSelector((state) => state.lists.get(id));
const [searching, setSearching] = useState(false);
const [accountIds, setAccountIds] = useState<string[]>([]);
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
@ -285,7 +286,14 @@ const ListMembers: React.FC<{
{displayedAccountIds.length > 0 && <div className='spacer' />}
<div className='column-footer'>
<Link to={`/lists/${id}`} className='button button--block'>
<Link
to={
list?.type === 'public_list'
? `/starter-pack/${id}-${list.slug}`
: `/lists/${id}`
}
className='button button--block'
>
<FormattedMessage id='lists.done' defaultMessage='Done' />
</Link>
</div>

View File

@ -79,7 +79,9 @@ const NewList: React.FC<{
id ? state.lists.get(id) : undefined,
);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [exclusive, setExclusive] = useState(false);
const [isPublic, setIsPublic] = useState(false);
const [repliesPolicy, setRepliesPolicy] = useState<RepliesPolicyType>('list');
const [submitting, setSubmitting] = useState(false);
@ -104,6 +106,13 @@ const NewList: React.FC<{
[setTitle],
);
const handleDescriptionChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLTextAreaElement>) => {
setDescription(value);
},
[setDescription],
);
const handleExclusiveChange = useCallback(
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
setExclusive(checked);
@ -111,6 +120,13 @@ const NewList: React.FC<{
[setExclusive],
);
const handleIsPublicChange = useCallback(
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
setIsPublic(checked);
},
[setIsPublic],
);
const handleRepliesPolicyChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLSelectElement>) => {
setRepliesPolicy(value as RepliesPolicyType);
@ -126,8 +142,10 @@ const NewList: React.FC<{
updateList({
id,
title,
description,
exclusive,
replies_policy: repliesPolicy,
type: isPublic ? 'public_list' : 'private_list',
}),
).then(() => {
setSubmitting(false);
@ -137,8 +155,10 @@ const NewList: React.FC<{
void dispatch(
createList({
title,
description,
exclusive,
replies_policy: repliesPolicy,
type: isPublic ? 'public_list' : 'private_list',
}),
).then((result) => {
setSubmitting(false);
@ -151,7 +171,17 @@ const NewList: React.FC<{
return '';
});
}
}, [history, dispatch, setSubmitting, id, title, exclusive, repliesPolicy]);
}, [
history,
dispatch,
setSubmitting,
id,
title,
description,
exclusive,
isPublic,
repliesPolicy,
]);
return (
<Column
@ -193,6 +223,28 @@ const NewList: React.FC<{
</div>
</div>
<div className='fields-group'>
<div className='input with_label'>
<div className='label_input'>
<label htmlFor='list_title'>
<FormattedMessage
id='lists.list_description'
defaultMessage='Description'
/>
</label>
<div className='label_input__wrapper'>
<textarea
id='list_description'
value={description}
onChange={handleDescriptionChange}
maxLength={120}
/>
</div>
</div>
</div>
</div>
<div className='fields-group'>
<div className='input with_label'>
<div className='label_input'>
@ -239,6 +291,32 @@ const NewList: React.FC<{
</div>
)}
<div className='fields-group'>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label className='app-form__toggle'>
<div className='app-form__toggle__label'>
<strong>
<FormattedMessage
id='lists.make_public'
defaultMessage='Make public'
/>
</strong>
<span className='hint'>
<FormattedMessage
id='lists.make_public_hint'
defaultMessage='When you make a list public, anyone with a link can see it.'
/>
</span>
</div>
<div className='app-form__toggle__toggle'>
<div>
<Toggle checked={isPublic} onChange={handleIsPublicChange} />
</div>
</div>
</label>
</div>
<div className='fields-group'>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label className='app-form__toggle'>

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,
ListEdit,
ListMembers,
PublicList,
Blocks,
DomainBlocks,
Mutes,
@ -212,6 +213,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/lists/:id/edit' component={ListEdit} content={children} />
<WrappedRoute path='/lists/:id/members' component={ListMembers} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
<WrappedRoute path={['/starter-pack/:id(\\d+)', '/starter-pack/:id(\\d+)-:slug']} component={PublicList} content={children} />
<WrappedRoute path='/notifications' component={Notifications} content={children} exact />
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact />

View File

@ -38,6 +38,10 @@ export function ListTimeline () {
return import(/* webpackChunkName: "features/list_timeline" */'../../list_timeline');
}
export function PublicList () {
return import(/* webpackChunkName: "features/public_list" */'../../public_list');
}
export function 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';
type ListShape = Required<ApiListJSON>; // no changes from server shape
interface ListShape extends Required<Omit<ApiListJSON, 'account'>> {
account_id?: string;
}
export type List = RecordOf<ListShape>;
const ListFactory = Record<ListShape>({
id: '',
url: '',
title: '',
slug: '',
description: '',
type: 'private_list',
exclusive: false,
replies_policy: 'list',
account_id: undefined,
created_at: '',
updated_at: '',
});
export function createList(attributes: Partial<ListShape>) {
return ListFactory(attributes);
}
export const createList = (serverJSON: ApiListJSON): List => {
const { account, ...listJSON } = serverJSON;
return ListFactory({
...listJSON,
account_id: account?.id,
});
};

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 {
height: 48px;
display: flex;
}
@ -4323,6 +4322,16 @@ a.status-card {
color: $dark-text-color;
cursor: default;
}
&.icon-button {
background: transparent;
}
&.copied {
color: $valid-value-color;
transition: none;
background-color: rgba($valid-value-color, 0.15);
}
}
.no-reduce-motion .column-header__button .icon-sliders {
@ -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 {
display: flex;
align-items: center;

View File

@ -149,7 +149,7 @@ class FeedManager
timeline_key = key(:list, list.id)
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
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
# @return [Boolean]
def filter_from_list?(status, list)
return true if list.public_list? && !status.distributable?
if status.reply? && status.in_reply_to_account_id != status.account_id # Status is a reply to account other than status account
should_filter = status.in_reply_to_account_id != list.account_id # Status replies to account id other than list account
should_filter &&= !list.show_followed? # List show_followed? is false

View File

@ -5,32 +5,46 @@
# Table name: lists
#
# id :bigint(8) not null, primary key
# account_id :bigint(8) not null
# description :text default(""), not null
# exclusive :boolean default(FALSE), not null
# replies_policy :integer default("list"), not null
# title :string default(""), not null
# type :integer default("private_list"), not null
# created_at :datetime not null
# updated_at :datetime not null
# replies_policy :integer default("list"), not null
# exclusive :boolean default(FALSE), not null
# account_id :bigint(8) not null
#
class List < ApplicationRecord
self.inheritance_column = nil
include Paginable
PER_ACCOUNT_LIMIT = 50
enum :replies_policy, { list: 0, followed: 1, none: 2 }, prefix: :show
enum :type, { private_list: 0, public_list: 1 }
belongs_to :account
has_many :list_accounts, inverse_of: :list, dependent: :destroy
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
before_destroy :clean_feed_manager
def slug
title.parameterize
end
def to_url_param
{ id:, slug: }
end
private
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)
}
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) }
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
class REST::ListSerializer < ActiveModel::Serializer
attributes :id, :title, :replies_policy, :exclusive
include RoutingHelper
attributes :id, :title, :description, :type, :replies_policy,
:exclusive, :created_at, :updated_at
attribute :slug, if: -> { object.public_list? }
attribute :url, if: -> { object.public_list? }
has_one :account, serializer: REST::AccountSerializer, if: -> { object.public_list? }
def id
object.id.to_s
end
def url
public_list_url(object.to_url_param)
end
end

View File

@ -24,7 +24,7 @@
.rules-list__hint= rule.hint
.stacked-actions
- accept_path = @invite_code.present? ? public_invite_url(invite_code: @invite_code, accept: @accept_token) : new_user_registration_path(accept: @accept_token)
- accept_path = @invite_code.present? ? public_invite_url(invite_code: @invite_code, accept: @accept_token) : new_user_registration_path(accept: @accept_token, list_id: @list&.id)
= link_to t('auth.rules.accept'), accept_path, class: 'button'
= link_to t('auth.rules.back'), root_path, class: 'button button-tertiary'

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 '/backups/:id/download', to: 'backups#download', as: :download_backup, format: false
constraints(id: /[\d]+/) do
get '/starter-pack/:id(-:slug)', to: 'lists#show', as: :public_list
get '/starter-pack/:id(-:slug)/posts', to: 'lists#show', format: false
end
resource :authorize_interaction, only: [:show]
resource :share, only: [:show]

View File

@ -217,6 +217,7 @@ namespace :api, format: false do
resources :lists, only: [:index, :create, :show, :update, :destroy] do
resource :accounts, only: [:show, :create, :destroy], module: :lists
resource :follow, only: [:create], module: :lists
end
namespace :featured_tags do

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.integer "avatar_storage_schema_version"
t.integer "header_storage_schema_version"
t.datetime "sensitized_at", precision: nil
t.integer "suspension_origin"
t.datetime "sensitized_at", precision: nil
t.boolean "trendable"
t.datetime "reviewed_at", precision: nil
t.datetime "requested_review_at", precision: nil
@ -556,12 +556,12 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_05_163118) do
end
create_table "ip_blocks", force: :cascade do |t|
t.inet "ip", default: "0.0.0.0", null: false
t.integer "severity", default: 0, null: false
t.datetime "expires_at", precision: nil
t.text "comment", default: "", null: false
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.datetime "expires_at", precision: nil
t.inet "ip", default: "0.0.0.0", null: false
t.integer "severity", default: 0, null: false
t.text "comment", default: "", null: false
t.index ["ip"], name: "index_ip_blocks_on_ip", unique: true
end
@ -583,6 +583,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_05_163118) do
t.datetime "updated_at", precision: nil, null: false
t.integer "replies_policy", default: 0, null: false
t.boolean "exclusive", default: false, null: false
t.integer "type", default: 0, null: false
t.text "description", default: "", null: false
t.index ["account_id"], name: "index_lists_on_account_id"
end
@ -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
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|
t.bigint "account_id"
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
create_view "user_ips", sql_definition: <<-SQL
SELECT user_id,
ip,
max(used_at) AS used_at
SELECT t0.user_id,
t0.ip,
max(t0.used_at) AS used_at
FROM ( SELECT users.id AS user_id,
users.sign_up_ip AS ip,
users.created_at AS used_at
@ -1407,7 +1418,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_05_163118) do
login_activities.created_at
FROM login_activities
WHERE (login_activities.success = true)) t0
GROUP BY user_id, ip;
GROUP BY t0.user_id, t0.ip;
SQL
create_view "account_summaries", materialized: true, sql_definition: <<-SQL
SELECT accounts.id AS account_id,
@ -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
create_view "global_follow_recommendations", materialized: true, sql_definition: <<-SQL
SELECT account_id,
sum(rank) AS rank,
array_agg(reason) AS reason
SELECT t0.account_id,
sum(t0.rank) AS rank,
array_agg(t0.reason) AS reason
FROM ( SELECT account_summaries.account_id,
((count(follows.id))::numeric / (1.0 + (count(follows.id))::numeric)) AS rank,
'most_followed'::text AS reason
@ -1454,8 +1465,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_05_163118) do
WHERE (follow_recommendation_suppressions.account_id = statuses.account_id)))))
GROUP BY account_summaries.account_id
HAVING (sum((status_stats.reblogs_count + status_stats.favourites_count)) >= (5)::numeric)) t0
GROUP BY account_id
ORDER BY (sum(rank)) DESC;
GROUP BY t0.account_id
ORDER BY (sum(t0.rank)) DESC;
SQL
add_index "global_follow_recommendations", ["account_id"], name: "index_global_follow_recommendations_on_account_id", unique: true