Compare commits
5 Commits
main
...
features/b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54580fb33d | ||
|
|
3f361c2bf2 | ||
|
|
c6710769ca | ||
|
|
84f6050817 | ||
|
|
898800198a |
27
app/controllers/api/v1/notices_controller.rb
Normal file
27
app/controllers/api/v1/notices_controller.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
36
app/javascript/mastodon/actions/notices.ts
Normal file
36
app/javascript/mastodon/actions/notices.ts
Normal 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 {};
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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' />);
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
15
app/javascript/mastodon/reducers/notices.ts
Normal file
15
app/javascript/mastodon/reducers/notices.ts
Normal 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
52
app/models/notice.rb
Normal 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
|
||||
11
app/serializers/rest/notice_serializer.rb
Normal file
11
app/serializers/rest/notice_serializer.rb
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -61,6 +61,8 @@ namespace :api, format: false do
|
||||
end
|
||||
end
|
||||
|
||||
resources :notices, only: [:index, :destroy]
|
||||
|
||||
# namespace :crypto do
|
||||
# resources :deliveries, only: :create
|
||||
|
||||
|
||||
7
db/migrate/20230911094812_add_seen_notices_to_users.rb
Normal file
7
db/migrate/20230911094812_add_seen_notices_to_users.rb
Normal 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
|
||||
@@ -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)"
|
||||
|
||||
66
spec/requests/api/v1/notices_spec.rb
Normal file
66
spec/requests/api/v1/notices_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user