Compare commits

...

5 Commits

Author SHA1 Message Date
Claire
54580fb33d Automatically dismiss the privacy settings notice when visiting the privacy settings page 2023-09-13 12:23:53 +02:00
Claire
3f361c2bf2 Add support for notices in WebUI 2023-09-12 13:50:40 +02:00
Claire
c6710769ca Add notice for the privacy settings 2023-09-12 12:20:19 +02:00
Claire
84f6050817 Add GET /api/v1/notices and DELETE /api/v1/notices/:id 2023-09-12 12:20:18 +02:00
Claire
898800198a Add seen_notices to users 2023-09-12 12:20:18 +02:00
15 changed files with 300 additions and 4 deletions

View File

@@ -0,0 +1,27 @@
# frozen_string_literal: true
class Api::V1::NoticesController < Api::BaseController
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: :destroy
before_action :require_user!
before_action :set_notices, only: :index
before_action :set_notice, except: :index
def index
render json: @notices, each_serializer: REST::NoticeSerializer
end
def destroy
@notice.dismiss_for_user!(current_user)
render_empty
end
private
def set_notices
@notices = [Notice.first_unseen(current_user)].compact
end
def set_notice
@notice = Notice.find(params[:id])
end
end

View File

@@ -3,9 +3,13 @@
class Settings::PrivacyController < Settings::BaseController
before_action :set_account
def show; end
def show
Notice.find(:mastodon_privacy_4_2).dismiss_for_user!(@account.user) # rubocop:disable Naming/VariableNumber
end
def update
Notice.find(:mastodon_privacy_4_2).dismiss_for_user!(@account.user) # rubocop:disable Naming/VariableNumber
if UpdateAccountService.new.call(@account, account_params.except(:settings))
current_user.update!(settings_attributes: account_params[:settings])
ActivityPub::UpdateDistributionWorker.perform_async(@account.id)

View File

@@ -0,0 +1,36 @@
import { createAppAsyncThunk } from 'mastodon/store/typed_functions';
import api from '../api';
export interface ApiNoticeActionJSON {
label: string;
url: string;
}
export interface ApiNoticeJSON {
id: string;
title: string;
message: string;
icon?: string;
actions: ApiNoticeActionJSON[];
}
export const fetchNotices = createAppAsyncThunk(
'notices/fetch',
async (_, { getState }) => {
const response = await api(getState).get<ApiNoticeJSON[]>(
'/api/v1/notices',
);
return { notices: response.data };
},
);
export const dismissNotice = createAppAsyncThunk(
'notices/dismiss',
async (args: { id: string }, { getState }) => {
await api(getState).delete<unknown>(`/api/v1/notices/${args.id}`);
return {};
},
);

View File

@@ -0,0 +1,61 @@
import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import background from 'mastodon/../images/friends-cropped.png';
import type { ApiNoticeJSON } from 'mastodon/actions/notices';
import { dismissNotice } from 'mastodon/actions/notices';
import { IconButton } from 'mastodon/components/icon_button';
import { useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' },
});
interface Props {
notice: ApiNoticeJSON;
}
export const Notice: React.FC<Props> = ({ notice }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const handleDismiss = useCallback(() => {
void dispatch(dismissNotice({ id: notice.id }));
}, [dispatch, notice.id]);
return (
<div className='dismissable-banner'>
<div className='dismissable-banner__message'>
<img
src={notice.icon ?? background}
alt=''
className='dismissable-banner__background-image'
/>
<h1>{notice.title}</h1>
<p>{notice.message}</p>
<div className='dismissable-banner__message__wrapper'>
<div className='dismissable-banner__message__actions'>
{notice.actions.map((action, i) => (
<a key={`action-${i}`} href={action.url} className='button'>
{action.label}
</a>
))}
</div>
</div>
</div>
<div className='dismissable-banner__action'>
<IconButton
icon='times'
title={intl.formatMessage(messages.dismiss)}
onClick={handleDismiss}
/>
</div>
</div>
);
};

View File

@@ -25,6 +25,7 @@ import StatusListContainer from '../ui/containers/status_list_container';
import { ColumnSettings } from './components/column_settings';
import { CriticalUpdateBanner } from './components/critical_update_banner';
import { ExplorePrompt } from './components/explore_prompt';
import { Notice } from './components/notice';
const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' },
@@ -66,6 +67,7 @@ const mapStateToProps = state => ({
unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
showAnnouncements: state.getIn(['announcements', 'show']),
tooSlow: homeTooSlow(state),
notices: state.get('notices'),
});
class HomeTimeline extends PureComponent {
@@ -85,6 +87,7 @@ class HomeTimeline extends PureComponent {
unreadAnnouncements: PropTypes.number,
showAnnouncements: PropTypes.bool,
tooSlow: PropTypes.bool,
notices: PropTypes.arrayOf(PropTypes.object),
};
handlePin = () => {
@@ -154,7 +157,7 @@ class HomeTimeline extends PureComponent {
};
render () {
const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements, notices } = this.props;
const pinned = !!columnId;
const { signedIn } = this.context.identity;
const banners = [];
@@ -179,7 +182,9 @@ class HomeTimeline extends PureComponent {
banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
}
if (tooSlow) {
if (notices.length) {
banners.push(<Notice key='notice' notice={notices[0]} />);
} else if (tooSlow) {
banners.push(<ExplorePrompt key='explore-prompt' />);
}

View File

@@ -13,6 +13,7 @@ import { HotKeys } from 'react-hotkeys';
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
import { fetchNotices } from 'mastodon/actions/notices';
import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
import PictureInPicture from 'mastodon/features/picture_in_picture';
import { layoutFromWindow } from 'mastodon/is_mobile';
@@ -401,6 +402,7 @@ class UI extends PureComponent {
}
if (signedIn) {
this.props.dispatch(fetchNotices());
this.props.dispatch(fetchMarkers());
this.props.dispatch(expandHomeTimeline());
this.props.dispatch(expandNotifications());

View File

@@ -28,6 +28,7 @@ import media_attachments from './media_attachments';
import meta from './meta';
import { modalReducer } from './modal';
import mutes from './mutes';
import { noticesReducer } from './notices';
import notifications from './notifications';
import picture_in_picture from './picture_in_picture';
import polls from './polls';
@@ -86,6 +87,7 @@ const reducers = {
history,
tags,
followed_tags,
notices: noticesReducer,
};
// We want the root state to be an ImmutableRecord, which is an object with a defined list of keys,

View File

@@ -0,0 +1,15 @@
import { createReducer } from '@reduxjs/toolkit';
import type { ApiNoticeJSON } from '../actions/notices';
import { fetchNotices, dismissNotice } from '../actions/notices';
const initialState: ApiNoticeJSON[] = [];
export const noticesReducer = createReducer(initialState, (builder) => {
builder
.addCase(
fetchNotices.fulfilled,
(_state, { payload: { notices } }) => notices,
)
.addCase(dismissNotice.fulfilled, () => []);
});

52
app/models/notice.rb Normal file
View File

@@ -0,0 +1,52 @@
# frozen_string_literal: true
class Notice < ActiveModelSerializers::Model
attributes :id, :icon, :title, :message, :actions
# Notices a user has seen are stored as a bitmap in
# `users.seen_notifications`.
NOTICE_BIT_MAP = {
mastodon_privacy_4_2: 1, # rubocop:disable Naming/VariableNumber
}.freeze
def dismiss_for_user!(user)
user.update!(seen_notices: (user.seen_notices || 0) | NOTICE_BIT_MAP[id])
end
class Action < ActiveModelSerializers::Model
attributes :label, :url
end
class << self
include RoutingHelper
def first_unseen(user)
notice_key = NOTICE_BIT_MAP.find { |_, bit| ((user.seen_notices || 0) & bit).zero? }&.first
send("#{notice_key}_notice") if notice_key.present?
end
def find(key)
throw ActiveRecord::RecordNotFound unless NOTICE_BIT_MAP.key?(key.to_sym)
send("#{key}_notice")
end
private
def mastodon_privacy_4_2_notice
new(
id: :mastodon_privacy_4_2, # rubocop:disable Naming/VariableNumber
icon: nil,
title: I18n.t('notices.mastodon_privacy_4_2.title'),
message: I18n.t('notices.mastodon_privacy_4_2.message'),
actions: [
Action.new(
label: I18n.t('notices.mastodon_privacy_4_2.review'),
url: settings_privacy_url
),
]
)
end
end
end

View File

@@ -0,0 +1,11 @@
# frozen_string_literal: true
class REST::NoticeSerializer < ActiveModel::Serializer
class ActionSerializer < ActiveModel::Serializer
attributes :label, :url
end
attributes :id, :icon, :title, :message
has_many :actions, serializer: ActionSerializer
end

View File

@@ -1441,6 +1441,11 @@ en:
copy_account_note_text: 'This user moved from %{acct}, here were your previous notes about them:'
navigation:
toggle_menu: Toggle menu
notices:
mastodon_privacy_4_2:
message: Mastodon's privacy settings have been moved to a new page, and now include a new setting related to search! Give it a look if you want to allow other people to search for your posts, or if you just want to make sure everything is set up like you want!
review: Go to privacy settings
title: Search and privacy settings
notification_mailer:
admin:
report:

View File

@@ -61,6 +61,8 @@ namespace :api, format: false do
end
end
resources :notices, only: [:index, :destroy]
# namespace :crypto do
# resources :deliveries, only: :create

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddSeenNoticesToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :seen_notices, :bigint
end
end

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_09_07_150100) do
ActiveRecord::Schema[7.0].define(version: 2023_09_11_094812) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -1103,6 +1103,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_07_150100) do
t.bigint "role_id"
t.text "settings"
t.string "time_zone"
t.bigint "seen_notices"
t.index ["account_id"], name: "index_users_on_account_id"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id", where: "(created_by_application_id IS NOT NULL)"

View File

@@ -0,0 +1,66 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'notices' do
let(:user) { Fabricate(:user) }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:scopes) { 'write:accounts' }
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
describe 'DELETE /api/v1/notices/:id' do
subject do
delete '/api/v1/notices/mastodon_privacy_4_2', headers: headers
end
it_behaves_like 'forbidden for wrong scope', 'read'
it 'retruns http success' do
subject
expect(response).to have_http_status(200)
end
it 'marks the notice as seen' do
expect { subject }.to change { Notice.first_unseen(user.reload)&.id }.from(:mastodon_privacy_4_2) # rubocop:disable Naming/VariableNumber
end
end
describe 'GET /api/v1/notices' do
subject do
get '/api/v1/notices', headers: headers
end
context 'when the user has seen all notices' do
before do
Notice.find(:mastodon_privacy_4_2).dismiss_for_user!(user) # rubocop:disable Naming/VariableNumber
end
it 'returns an empty list', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(body_as_json).to eq []
end
end
context 'when the user has unseen notices' do
it 'returns exactly one notice', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(body_as_json.size).to eq 1
end
end
context 'without the authorization header' do
let(:headers) { {} }
it 'returns http unprocessable content' do
subject
expect(response).to have_http_status(422)
end
end
end
end