mirror of
https://github.com/mastodon/mastodon.git
synced 2024-12-28 05:55:04 +01:00
WIP: Add starter packs
This commit is contained in:
parent
58c40caeb4
commit
eae6b98ace
@ -1,25 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Lists::AccountsController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show]
|
||||
include Authorization
|
||||
|
||||
before_action -> { authorize_if_got_token! :read, :'read:lists' }, only: [:show]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show]
|
||||
|
||||
before_action :require_user!
|
||||
before_action :require_user!, except: [:show]
|
||||
before_action :set_list
|
||||
|
||||
after_action :insert_pagination_headers, only: :show
|
||||
|
||||
def show
|
||||
authorize @list, :show?
|
||||
@accounts = load_accounts
|
||||
render json: @accounts, each_serializer: REST::AccountSerializer
|
||||
end
|
||||
|
||||
def create
|
||||
authorize @list, :update?
|
||||
AddAccountsToListService.new.call(@list, Account.find(account_ids))
|
||||
render_empty
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @list, :update?
|
||||
RemoveAccountsFromListService.new.call(@list, Account.where(id: account_ids))
|
||||
render_empty
|
||||
end
|
||||
@ -27,7 +32,7 @@ class Api::V1::Lists::AccountsController < Api::BaseController
|
||||
private
|
||||
|
||||
def set_list
|
||||
@list = List.where(account: current_account).find(params[:list_id])
|
||||
@list = List.find(params[:list_id])
|
||||
end
|
||||
|
||||
def load_accounts
|
||||
|
18
app/controllers/api/v1/lists/follows_controller.rb
Normal file
18
app/controllers/api/v1/lists/follows_controller.rb
Normal file
@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Lists::FollowsController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :follow, :write, :'write:follows' }
|
||||
before_action :require_user!
|
||||
before_action :set_list
|
||||
|
||||
def create
|
||||
FollowFromPublicListWorker.perform_async(current_account.id, @list.id)
|
||||
render json: {}, status: 202
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_list
|
||||
@list = List.where(type: :public_list).find(params[:list_id])
|
||||
end
|
||||
end
|
@ -1,10 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::ListsController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index, :show]
|
||||
include Authorization
|
||||
|
||||
before_action -> { authorize_if_got_token! :read, :'read:lists' }, only: [:show]
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show]
|
||||
|
||||
before_action :require_user!
|
||||
before_action :require_user!, except: [:show]
|
||||
before_action :set_list, except: [:index, :create]
|
||||
|
||||
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
|
||||
|
@ -1,23 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Timelines::ListController < Api::V1::Timelines::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:lists' }
|
||||
before_action :require_user!
|
||||
include Authorization
|
||||
|
||||
before_action -> { authorize_if_got_token! :read, :'read:lists' }
|
||||
before_action :set_list
|
||||
before_action :set_statuses
|
||||
|
||||
PERMITTED_PARAMS = %i(limit).freeze
|
||||
|
||||
def show
|
||||
authorize @list, :show?
|
||||
render json: @statuses,
|
||||
each_serializer: REST::StatusSerializer,
|
||||
relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id)
|
||||
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_list
|
||||
@list = List.where(account: current_account).find(params[:id])
|
||||
@list = List.find(params[:id])
|
||||
end
|
||||
|
||||
def set_statuses
|
||||
|
@ -7,6 +7,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||
layout :determine_layout
|
||||
|
||||
before_action :set_invite, only: [:new, :create]
|
||||
before_action :set_list, only: [:new, :create]
|
||||
before_action :check_enabled_registrations, only: [:new, :create]
|
||||
before_action :configure_sign_up_params, only: [:create]
|
||||
before_action :set_sessions, only: [:edit, :update]
|
||||
@ -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
|
||||
|
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';
|
||||
|
||||
interface Alert {
|
||||
title: string | MessageDescriptor;
|
||||
title?: string | MessageDescriptor;
|
||||
message: string | MessageDescriptor;
|
||||
values?: Record<string, string | number | Date>;
|
||||
}
|
||||
|
@ -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`);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
@ -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 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>
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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'>
|
||||
|
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,
|
||||
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 />
|
||||
|
@ -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');
|
||||
}
|
||||
|
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';
|
||||
|
||||
type ListShape = Required<ApiListJSON>; // no changes from server shape
|
||||
interface ListShape extends Required<Omit<ApiListJSON, 'account'>> {
|
||||
account_id?: string;
|
||||
}
|
||||
|
||||
export type List = RecordOf<ListShape>;
|
||||
|
||||
const ListFactory = Record<ListShape>({
|
||||
id: '',
|
||||
url: '',
|
||||
title: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
type: 'private_list',
|
||||
exclusive: false,
|
||||
replies_policy: 'list',
|
||||
account_id: undefined,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
});
|
||||
|
||||
export function createList(attributes: Partial<ListShape>) {
|
||||
return ListFactory(attributes);
|
||||
}
|
||||
export const createList = (serverJSON: ApiListJSON): List => {
|
||||
const { account, ...listJSON } = serverJSON;
|
||||
|
||||
return ListFactory({
|
||||
...listJSON,
|
||||
account_id: account?.id,
|
||||
});
|
||||
};
|
||||
|
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M440-91v-366L120-642v321q0 22 10.5 40t29.5 29L440-91Zm80 0 280-161q19-11 29.5-29t10.5-40v-321L520-457v366Zm159-550 118-69-277-159q-19-11-40-11t-40 11l-79 45 318 183ZM480-526l119-68-317-184-120 69 318 183Z"/></svg>
|
After Width: | Height: | Size: 310 B |
1
app/javascript/material-icons/400-24px/package_2.svg
Normal file
1
app/javascript/material-icons/400-24px/package_2.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M440-183v-274L200-596v274l240 139Zm80 0 240-139v-274L520-457v274Zm-80 92L160-252q-19-11-29.5-29T120-321v-318q0-22 10.5-40t29.5-29l280-161q19-11 40-11t40 11l280 161q19 11 29.5 29t10.5 40v318q0 22-10.5 40T800-252L520-91q-19 11-40 11t-40-11Zm200-528 77-44-237-137-78 45 238 136Zm-160 93 78-45-237-137-78 45 237 137Z"/></svg>
|
After Width: | Height: | Size: 418 B |
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
21
app/policies/list_policy.rb
Normal file
21
app/policies/list_policy.rb
Normal file
@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ListPolicy < ApplicationPolicy
|
||||
def show?
|
||||
record.public_list? || owned?
|
||||
end
|
||||
|
||||
def update?
|
||||
owned?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
owned?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def owned?
|
||||
user_signed_in? && record.account_id == current_account.id
|
||||
end
|
||||
end
|
@ -1,9 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class REST::ListSerializer < ActiveModel::Serializer
|
||||
attributes :id, :title, :replies_policy, :exclusive
|
||||
include RoutingHelper
|
||||
|
||||
attributes :id, :title, :description, :type, :replies_policy,
|
||||
:exclusive, :created_at, :updated_at
|
||||
|
||||
attribute :slug, if: -> { object.public_list? }
|
||||
attribute :url, if: -> { object.public_list? }
|
||||
has_one :account, serializer: REST::AccountSerializer, if: -> { object.public_list? }
|
||||
|
||||
def id
|
||||
object.id.to_s
|
||||
end
|
||||
|
||||
def url
|
||||
public_list_url(object.to_url_param)
|
||||
end
|
||||
end
|
||||
|
@ -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'
|
||||
|
||||
|
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 '/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]
|
||||
|
||||
|
@ -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
|
||||
|
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.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
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user