Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
a7b905d573
498 changed files with 7515 additions and 4371 deletions
|
|
@ -1,340 +0,0 @@
|
|||
// This file will be loaded on admin pages, regardless of theme.
|
||||
|
||||
import 'packs/public-path';
|
||||
|
||||
import Rails from '@rails/ujs';
|
||||
|
||||
import ready from '../mastodon/ready';
|
||||
|
||||
const setAnnouncementEndsAttributes = (target: HTMLInputElement) => {
|
||||
const valid = target.value && target.validity.valid;
|
||||
const element = document.querySelector<HTMLInputElement>(
|
||||
'input[type="datetime-local"]#announcement_ends_at',
|
||||
);
|
||||
|
||||
if (!element) return;
|
||||
|
||||
if (valid) {
|
||||
element.classList.remove('optional');
|
||||
element.required = true;
|
||||
element.min = target.value;
|
||||
} else {
|
||||
element.classList.add('optional');
|
||||
element.removeAttribute('required');
|
||||
element.removeAttribute('min');
|
||||
}
|
||||
};
|
||||
|
||||
Rails.delegate(
|
||||
document,
|
||||
'input[type="datetime-local"]#announcement_starts_at',
|
||||
'change',
|
||||
({ target }) => {
|
||||
if (target instanceof HTMLInputElement)
|
||||
setAnnouncementEndsAttributes(target);
|
||||
},
|
||||
);
|
||||
|
||||
const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]';
|
||||
|
||||
const showSelectAll = () => {
|
||||
const selectAllMatchingElement = document.querySelector(
|
||||
'.batch-table__select-all',
|
||||
);
|
||||
selectAllMatchingElement?.classList.add('active');
|
||||
};
|
||||
|
||||
const hideSelectAll = () => {
|
||||
const selectAllMatchingElement = document.querySelector(
|
||||
'.batch-table__select-all',
|
||||
);
|
||||
const hiddenField = document.querySelector<HTMLInputElement>(
|
||||
'input#select_all_matching',
|
||||
);
|
||||
const selectedMsg = document.querySelector(
|
||||
'.batch-table__select-all .selected',
|
||||
);
|
||||
const notSelectedMsg = document.querySelector(
|
||||
'.batch-table__select-all .not-selected',
|
||||
);
|
||||
|
||||
selectAllMatchingElement?.classList.remove('active');
|
||||
selectedMsg?.classList.remove('active');
|
||||
notSelectedMsg?.classList.add('active');
|
||||
if (hiddenField) hiddenField.value = '0';
|
||||
};
|
||||
|
||||
Rails.delegate(document, '#batch_checkbox_all', 'change', ({ target }) => {
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
|
||||
const selectAllMatchingElement = document.querySelector(
|
||||
'.batch-table__select-all',
|
||||
);
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLInputElement>(batchCheckboxClassName)
|
||||
.forEach((content) => {
|
||||
content.checked = target.checked;
|
||||
});
|
||||
|
||||
if (selectAllMatchingElement) {
|
||||
if (target.checked) {
|
||||
showSelectAll();
|
||||
} else {
|
||||
hideSelectAll();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Rails.delegate(document, '.batch-table__select-all button', 'click', () => {
|
||||
const hiddenField = document.querySelector<HTMLInputElement>(
|
||||
'#select_all_matching',
|
||||
);
|
||||
|
||||
if (!hiddenField) return;
|
||||
|
||||
const active = hiddenField.value === '1';
|
||||
const selectedMsg = document.querySelector(
|
||||
'.batch-table__select-all .selected',
|
||||
);
|
||||
const notSelectedMsg = document.querySelector(
|
||||
'.batch-table__select-all .not-selected',
|
||||
);
|
||||
|
||||
if (!selectedMsg || !notSelectedMsg) return;
|
||||
|
||||
if (active) {
|
||||
hiddenField.value = '0';
|
||||
selectedMsg.classList.remove('active');
|
||||
notSelectedMsg.classList.add('active');
|
||||
} else {
|
||||
hiddenField.value = '1';
|
||||
notSelectedMsg.classList.remove('active');
|
||||
selectedMsg.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
Rails.delegate(document, batchCheckboxClassName, 'change', () => {
|
||||
const checkAllElement = document.querySelector<HTMLInputElement>(
|
||||
'input#batch_checkbox_all',
|
||||
);
|
||||
const selectAllMatchingElement = document.querySelector(
|
||||
'.batch-table__select-all',
|
||||
);
|
||||
|
||||
if (checkAllElement) {
|
||||
const allCheckboxes = Array.from(
|
||||
document.querySelectorAll<HTMLInputElement>(batchCheckboxClassName),
|
||||
);
|
||||
checkAllElement.checked = allCheckboxes.every((content) => content.checked);
|
||||
checkAllElement.indeterminate =
|
||||
!checkAllElement.checked &&
|
||||
allCheckboxes.some((content) => content.checked);
|
||||
|
||||
if (selectAllMatchingElement) {
|
||||
if (checkAllElement.checked) {
|
||||
showSelectAll();
|
||||
} else {
|
||||
hideSelectAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Rails.delegate(
|
||||
document,
|
||||
'.filter-subset--with-select select',
|
||||
'change',
|
||||
({ target }) => {
|
||||
if (target instanceof HTMLSelectElement) target.form?.submit();
|
||||
},
|
||||
);
|
||||
|
||||
const onDomainBlockSeverityChange = (target: HTMLSelectElement) => {
|
||||
const rejectMediaDiv = document.querySelector(
|
||||
'.input.with_label.domain_block_reject_media',
|
||||
);
|
||||
const rejectReportsDiv = document.querySelector(
|
||||
'.input.with_label.domain_block_reject_reports',
|
||||
);
|
||||
|
||||
if (rejectMediaDiv && rejectMediaDiv instanceof HTMLElement) {
|
||||
rejectMediaDiv.style.display =
|
||||
target.value === 'suspend' ? 'none' : 'block';
|
||||
}
|
||||
|
||||
if (rejectReportsDiv && rejectReportsDiv instanceof HTMLElement) {
|
||||
rejectReportsDiv.style.display =
|
||||
target.value === 'suspend' ? 'none' : 'block';
|
||||
}
|
||||
};
|
||||
|
||||
Rails.delegate(document, '#domain_block_severity', 'change', ({ target }) => {
|
||||
if (target instanceof HTMLSelectElement) onDomainBlockSeverityChange(target);
|
||||
});
|
||||
|
||||
const onEnableBootstrapTimelineAccountsChange = (target: HTMLInputElement) => {
|
||||
const bootstrapTimelineAccountsField =
|
||||
document.querySelector<HTMLInputElement>(
|
||||
'#form_admin_settings_bootstrap_timeline_accounts',
|
||||
);
|
||||
|
||||
if (bootstrapTimelineAccountsField) {
|
||||
bootstrapTimelineAccountsField.disabled = !target.checked;
|
||||
if (target.checked) {
|
||||
bootstrapTimelineAccountsField.parentElement?.classList.remove(
|
||||
'disabled',
|
||||
);
|
||||
bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.remove(
|
||||
'disabled',
|
||||
);
|
||||
} else {
|
||||
bootstrapTimelineAccountsField.parentElement?.classList.add('disabled');
|
||||
bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.add(
|
||||
'disabled',
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Rails.delegate(
|
||||
document,
|
||||
'#form_admin_settings_enable_bootstrap_timeline_accounts',
|
||||
'change',
|
||||
({ target }) => {
|
||||
if (target instanceof HTMLInputElement)
|
||||
onEnableBootstrapTimelineAccountsChange(target);
|
||||
},
|
||||
);
|
||||
|
||||
const onChangeRegistrationMode = (target: HTMLSelectElement) => {
|
||||
const enabled = target.value === 'approved';
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLElement>(
|
||||
'.form_admin_settings_registrations_mode .warning-hint',
|
||||
)
|
||||
.forEach((warning_hint) => {
|
||||
warning_hint.style.display = target.value === 'open' ? 'inline' : 'none';
|
||||
});
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLInputElement>(
|
||||
'input#form_admin_settings_require_invite_text',
|
||||
)
|
||||
.forEach((input) => {
|
||||
input.disabled = !enabled;
|
||||
if (enabled) {
|
||||
let element: HTMLElement | null = input;
|
||||
do {
|
||||
element.classList.remove('disabled');
|
||||
element = element.parentElement;
|
||||
} while (element && !element.classList.contains('fields-group'));
|
||||
} else {
|
||||
let element: HTMLElement | null = input;
|
||||
do {
|
||||
element.classList.add('disabled');
|
||||
element = element.parentElement;
|
||||
} while (element && !element.classList.contains('fields-group'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const convertUTCDateTimeToLocal = (value: string) => {
|
||||
const date = new Date(value + 'Z');
|
||||
const twoChars = (x: number) => x.toString().padStart(2, '0');
|
||||
return `${date.getFullYear()}-${twoChars(date.getMonth() + 1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`;
|
||||
};
|
||||
|
||||
function convertLocalDatetimeToUTC(value: string) {
|
||||
const date = new Date(value);
|
||||
const fullISO8601 = date.toISOString();
|
||||
return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6);
|
||||
}
|
||||
|
||||
Rails.delegate(
|
||||
document,
|
||||
'#form_admin_settings_registrations_mode',
|
||||
'change',
|
||||
({ target }) => {
|
||||
if (target instanceof HTMLSelectElement) onChangeRegistrationMode(target);
|
||||
},
|
||||
);
|
||||
|
||||
ready(() => {
|
||||
const domainBlockSeveritySelect = document.querySelector<HTMLSelectElement>(
|
||||
'select#domain_block_severity',
|
||||
);
|
||||
if (domainBlockSeveritySelect)
|
||||
onDomainBlockSeverityChange(domainBlockSeveritySelect);
|
||||
|
||||
const enableBootstrapTimelineAccounts =
|
||||
document.querySelector<HTMLInputElement>(
|
||||
'input#form_admin_settings_enable_bootstrap_timeline_accounts',
|
||||
);
|
||||
if (enableBootstrapTimelineAccounts)
|
||||
onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts);
|
||||
|
||||
const registrationMode = document.querySelector<HTMLSelectElement>(
|
||||
'select#form_admin_settings_registrations_mode',
|
||||
);
|
||||
if (registrationMode) onChangeRegistrationMode(registrationMode);
|
||||
|
||||
const checkAllElement = document.querySelector<HTMLInputElement>(
|
||||
'input#batch_checkbox_all',
|
||||
);
|
||||
if (checkAllElement) {
|
||||
const allCheckboxes = Array.from(
|
||||
document.querySelectorAll<HTMLInputElement>(batchCheckboxClassName),
|
||||
);
|
||||
checkAllElement.checked = allCheckboxes.every((content) => content.checked);
|
||||
checkAllElement.indeterminate =
|
||||
!checkAllElement.checked &&
|
||||
allCheckboxes.some((content) => content.checked);
|
||||
}
|
||||
|
||||
document
|
||||
.querySelector('a#add-instance-button')
|
||||
?.addEventListener('click', (e) => {
|
||||
const domain = document.querySelector<HTMLInputElement>(
|
||||
'input[type="text"]#by_domain',
|
||||
)?.value;
|
||||
|
||||
if (domain && e.target instanceof HTMLAnchorElement) {
|
||||
const url = new URL(e.target.href);
|
||||
url.searchParams.set('_domain', domain);
|
||||
e.target.href = url.toString();
|
||||
}
|
||||
});
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLInputElement>('input[type="datetime-local"]')
|
||||
.forEach((element) => {
|
||||
if (element.value) {
|
||||
element.value = convertUTCDateTimeToLocal(element.value);
|
||||
}
|
||||
if (element.placeholder) {
|
||||
element.placeholder = convertUTCDateTimeToLocal(element.placeholder);
|
||||
}
|
||||
});
|
||||
|
||||
Rails.delegate(document, 'form', 'submit', ({ target }) => {
|
||||
if (target instanceof HTMLFormElement)
|
||||
target
|
||||
.querySelectorAll<HTMLInputElement>('input[type="datetime-local"]')
|
||||
.forEach((element) => {
|
||||
if (element.value && element.validity.valid) {
|
||||
element.value = convertLocalDatetimeToUTC(element.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const announcementStartsAt = document.querySelector<HTMLInputElement>(
|
||||
'input[type="datetime-local"]#announcement_starts_at',
|
||||
);
|
||||
if (announcementStartsAt) {
|
||||
setAnnouncementEndsAttributes(announcementStartsAt);
|
||||
}
|
||||
}).catch((reason) => {
|
||||
throw reason;
|
||||
});
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import 'packs/public-path';
|
||||
import './settings';
|
||||
import './two_factor_authentication';
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
// This file will be loaded on all pages, regardless of theme.
|
||||
|
||||
import 'packs/public-path';
|
||||
import 'font-awesome/css/font-awesome.css';
|
||||
|
||||
require.context('../images/', true);
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
// This file will be loaded on embed pages, regardless of theme.
|
||||
|
||||
import 'packs/public-path';
|
||||
import ready from '../mastodon/ready';
|
||||
|
||||
interface SetHeightMessage {
|
||||
type: 'setHeight';
|
||||
id: string;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function isSetHeightMessage(data: unknown): data is SetHeightMessage {
|
||||
if (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
'type' in data &&
|
||||
data.type === 'setHeight'
|
||||
)
|
||||
return true;
|
||||
else return false;
|
||||
}
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases
|
||||
if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return;
|
||||
|
||||
const data = e.data;
|
||||
|
||||
ready(() => {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'setHeight',
|
||||
id: data.id,
|
||||
height: document.getElementsByTagName('html')[0].scrollHeight,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}).catch((e) => {
|
||||
console.error('Error in setHeightMessage postMessage', e);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
// This file will be loaded on settings pages, regardless of theme.
|
||||
|
||||
import 'packs/public-path';
|
||||
import Rails from '@rails/ujs';
|
||||
|
||||
Rails.delegate(
|
||||
document,
|
||||
'#edit_profile input[type=file]',
|
||||
'change',
|
||||
({ target }) => {
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
|
||||
const avatar = document.querySelector<HTMLImageElement>(
|
||||
`img#${target.id}-preview`,
|
||||
);
|
||||
|
||||
if (!avatar) return;
|
||||
|
||||
let file: File | undefined;
|
||||
if (target.files) file = target.files[0];
|
||||
|
||||
const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc;
|
||||
|
||||
if (url) avatar.src = url;
|
||||
},
|
||||
);
|
||||
|
||||
Rails.delegate(document, '.input-copy input', 'click', ({ target }) => {
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
|
||||
target.focus();
|
||||
target.select();
|
||||
target.setSelectionRange(0, target.value.length);
|
||||
});
|
||||
|
||||
Rails.delegate(document, '.input-copy button', 'click', ({ target }) => {
|
||||
if (!(target instanceof HTMLButtonElement)) return;
|
||||
|
||||
const input = target.parentNode?.querySelector<HTMLInputElement>(
|
||||
'.input-copy__wrapper input',
|
||||
);
|
||||
|
||||
if (!input) return;
|
||||
|
||||
const oldReadOnly = input.readOnly;
|
||||
|
||||
input.readOnly = false;
|
||||
input.focus();
|
||||
input.select();
|
||||
input.setSelectionRange(0, input.value.length);
|
||||
|
||||
try {
|
||||
if (document.execCommand('copy')) {
|
||||
input.blur();
|
||||
|
||||
const parent = target.parentElement;
|
||||
|
||||
if (!parent) return;
|
||||
parent.classList.add('copied');
|
||||
|
||||
setTimeout(() => {
|
||||
parent.classList.remove('copied');
|
||||
}, 700);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
input.readOnly = oldReadOnly;
|
||||
});
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
# These packs will be loaded on every appropriate page, regardless of
|
||||
# theme.
|
||||
pack:
|
||||
about:
|
||||
admin: admin.ts
|
||||
auth: auth.js
|
||||
common:
|
||||
filename: common.js
|
||||
stylesheet: true
|
||||
embed: embed.ts
|
||||
error:
|
||||
home:
|
||||
inert:
|
||||
filename: inert.js
|
||||
stylesheet: true
|
||||
mailer:
|
||||
filename: mailer.js
|
||||
stylesheet: true
|
||||
modal:
|
||||
public:
|
||||
settings: settings.ts
|
||||
sign_up:
|
||||
share:
|
||||
remote_interaction_helper: remote_interaction_helper.ts
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { openModal } from './modal';
|
||||
|
||||
export const BOOSTS_INIT_MODAL = 'BOOSTS_INIT_MODAL';
|
||||
export const BOOSTS_CHANGE_PRIVACY = 'BOOSTS_CHANGE_PRIVACY';
|
||||
|
||||
export function initBoostModal(props) {
|
||||
return (dispatch, getState) => {
|
||||
const default_privacy = getState().getIn(['compose', 'default_privacy']);
|
||||
|
||||
const privacy = props.status.get('visibility') === 'private' ? 'private' : default_privacy;
|
||||
|
||||
dispatch({
|
||||
type: BOOSTS_INIT_MODAL,
|
||||
privacy,
|
||||
});
|
||||
|
||||
dispatch(openModal({
|
||||
modalType: 'BOOST',
|
||||
modalProps: props,
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function changeBoostPrivacy(privacy) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: BOOSTS_CHANGE_PRIVACY,
|
||||
privacy,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import api from '../api';
|
||||
import { compareId } from '../compare_id';
|
||||
|
||||
export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST';
|
||||
export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS';
|
||||
export const MARKERS_FETCH_FAIL = 'MARKERS_FETCH_FAIL';
|
||||
export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS';
|
||||
|
||||
export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
|
||||
const accessToken = getState().getIn(['meta', 'access_token'], '');
|
||||
const params = _buildParams(getState());
|
||||
|
||||
if (Object.keys(params).length === 0 || accessToken === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// The Fetch API allows us to perform requests that will be carried out
|
||||
// after the page closes. But that only works if the `keepalive` attribute
|
||||
// is supported.
|
||||
if (window.fetch && 'keepalive' in new Request('')) {
|
||||
fetch('/api/v1/markers', {
|
||||
keepalive: true,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
return;
|
||||
} else if (navigator && navigator.sendBeacon) {
|
||||
// Failing that, we can use sendBeacon, but we have to encode the data as
|
||||
// FormData for DoorKeeper to recognize the token.
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('bearer_token', accessToken);
|
||||
|
||||
for (const [id, value] of Object.entries(params)) {
|
||||
formData.append(`${id}[last_read_id]`, value.last_read_id);
|
||||
}
|
||||
|
||||
if (navigator.sendBeacon('/api/v1/markers', formData)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If neither Fetch nor sendBeacon worked, try to perform a synchronous
|
||||
// request.
|
||||
try {
|
||||
const client = new XMLHttpRequest();
|
||||
|
||||
client.open('POST', '/api/v1/markers', false);
|
||||
client.setRequestHeader('Content-Type', 'application/json');
|
||||
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
|
||||
client.send(JSON.stringify(params));
|
||||
} catch (e) {
|
||||
// Do not make the BeforeUnload handler error out
|
||||
}
|
||||
};
|
||||
|
||||
const _buildParams = (state) => {
|
||||
const params = {};
|
||||
|
||||
const lastHomeId = state.getIn(['timelines', 'home', 'items'], ImmutableList()).find(item => item !== null);
|
||||
const lastNotificationId = state.getIn(['notifications', 'lastReadId']);
|
||||
|
||||
if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) {
|
||||
params.home = {
|
||||
last_read_id: lastHomeId,
|
||||
};
|
||||
}
|
||||
|
||||
if (lastNotificationId && lastNotificationId !== '0' && compareId(lastNotificationId, state.getIn(['markers', 'notifications'])) > 0) {
|
||||
params.notifications = {
|
||||
last_read_id: lastNotificationId,
|
||||
};
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
const debouncedSubmitMarkers = debounce((dispatch, getState) => {
|
||||
const accessToken = getState().getIn(['meta', 'access_token'], '');
|
||||
const params = _buildParams(getState());
|
||||
|
||||
if (Object.keys(params).length === 0 || accessToken === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
api(getState).post('/api/v1/markers', params).then(() => {
|
||||
dispatch(submitMarkersSuccess(params));
|
||||
}).catch(() => {});
|
||||
}, 300000, { leading: true, trailing: true });
|
||||
|
||||
export function submitMarkersSuccess({ home, notifications }) {
|
||||
return {
|
||||
type: MARKERS_SUBMIT_SUCCESS,
|
||||
home: (home || {}).last_read_id,
|
||||
notifications: (notifications || {}).last_read_id,
|
||||
};
|
||||
}
|
||||
|
||||
export function submitMarkers(params = {}) {
|
||||
const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
|
||||
|
||||
if (params.immediate === true) {
|
||||
debouncedSubmitMarkers.flush();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export const fetchMarkers = () => (dispatch, getState) => {
|
||||
const params = { timeline: ['notifications'] };
|
||||
|
||||
dispatch(fetchMarkersRequest());
|
||||
|
||||
api(getState).get('/api/v1/markers', { params }).then(response => {
|
||||
dispatch(fetchMarkersSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchMarkersFail(error));
|
||||
});
|
||||
};
|
||||
|
||||
export function fetchMarkersRequest() {
|
||||
return {
|
||||
type: MARKERS_FETCH_REQUEST,
|
||||
skipLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchMarkersSuccess(markers) {
|
||||
return {
|
||||
type: MARKERS_FETCH_SUCCESS,
|
||||
markers,
|
||||
skipLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchMarkersFail(error) {
|
||||
return {
|
||||
type: MARKERS_FETCH_FAIL,
|
||||
error,
|
||||
skipLoading: true,
|
||||
skipAlert: true,
|
||||
};
|
||||
}
|
||||
166
app/javascript/flavours/glitch/actions/markers.ts
Normal file
166
app/javascript/flavours/glitch/actions/markers.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import type { MarkerJSON } from 'flavours/glitch/api_types/markers';
|
||||
import type { RootState } from 'flavours/glitch/store';
|
||||
import { createAppAsyncThunk } from 'flavours/glitch/store/typed_functions';
|
||||
|
||||
import api, { authorizationTokenFromState } from '../api';
|
||||
import { compareId } from '../compare_id';
|
||||
|
||||
export const synchronouslySubmitMarkers = createAppAsyncThunk(
|
||||
'markers/submit',
|
||||
async (_args, { getState }) => {
|
||||
const accessToken = authorizationTokenFromState(getState);
|
||||
const params = buildPostMarkersParams(getState());
|
||||
|
||||
if (Object.keys(params).length === 0 || !accessToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The Fetch API allows us to perform requests that will be carried out
|
||||
// after the page closes. But that only works if the `keepalive` attribute
|
||||
// is supported.
|
||||
if ('fetch' in window && 'keepalive' in new Request('')) {
|
||||
await fetch('/api/v1/markers', {
|
||||
keepalive: true,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
} else if ('navigator' && 'sendBeacon' in navigator) {
|
||||
// Failing that, we can use sendBeacon, but we have to encode the data as
|
||||
// FormData for DoorKeeper to recognize the token.
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('bearer_token', accessToken);
|
||||
|
||||
for (const [id, value] of Object.entries(params)) {
|
||||
if (value.last_read_id)
|
||||
formData.append(`${id}[last_read_id]`, value.last_read_id);
|
||||
}
|
||||
|
||||
if (navigator.sendBeacon('/api/v1/markers', formData)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If neither Fetch nor sendBeacon worked, try to perform a synchronous
|
||||
// request.
|
||||
try {
|
||||
const client = new XMLHttpRequest();
|
||||
|
||||
client.open('POST', '/api/v1/markers', false);
|
||||
client.setRequestHeader('Content-Type', 'application/json');
|
||||
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
|
||||
client.send(JSON.stringify(params));
|
||||
} catch (e) {
|
||||
// Do not make the BeforeUnload handler error out
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
interface MarkerParam {
|
||||
last_read_id?: string;
|
||||
}
|
||||
|
||||
function getLastHomeId(state: RootState): string | undefined {
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
||||
return (
|
||||
state
|
||||
// @ts-expect-error state.timelines is not yet typed
|
||||
.getIn(['timelines', 'home', 'items'], ImmutableList())
|
||||
// @ts-expect-error state.timelines is not yet typed
|
||||
.find((item) => item !== null)
|
||||
);
|
||||
}
|
||||
|
||||
function getLastNotificationId(state: RootState): string | undefined {
|
||||
// @ts-expect-error state.notifications is not yet typed
|
||||
return state.getIn(['notifications', 'lastReadId']);
|
||||
}
|
||||
|
||||
const buildPostMarkersParams = (state: RootState) => {
|
||||
const params = {} as { home?: MarkerParam; notifications?: MarkerParam };
|
||||
|
||||
const lastHomeId = getLastHomeId(state);
|
||||
const lastNotificationId = getLastNotificationId(state);
|
||||
|
||||
if (lastHomeId && compareId(lastHomeId, state.markers.home) > 0) {
|
||||
params.home = {
|
||||
last_read_id: lastHomeId,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lastNotificationId &&
|
||||
lastNotificationId !== '0' &&
|
||||
compareId(lastNotificationId, state.markers.notifications) > 0
|
||||
) {
|
||||
params.notifications = {
|
||||
last_read_id: lastNotificationId,
|
||||
};
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
export const submitMarkersAction = createAppAsyncThunk<{
|
||||
home: string | undefined;
|
||||
notifications: string | undefined;
|
||||
}>('markers/submitAction', async (_args, { getState }) => {
|
||||
const accessToken = authorizationTokenFromState(getState);
|
||||
const params = buildPostMarkersParams(getState());
|
||||
|
||||
if (Object.keys(params).length === 0 || accessToken === '') {
|
||||
return { home: undefined, notifications: undefined };
|
||||
}
|
||||
|
||||
await api(getState).post<MarkerJSON>('/api/v1/markers', params);
|
||||
|
||||
return {
|
||||
home: params.home?.last_read_id,
|
||||
notifications: params.notifications?.last_read_id,
|
||||
};
|
||||
});
|
||||
|
||||
const debouncedSubmitMarkers = debounce(
|
||||
(dispatch) => {
|
||||
dispatch(submitMarkersAction());
|
||||
},
|
||||
300000,
|
||||
{
|
||||
leading: true,
|
||||
trailing: true,
|
||||
},
|
||||
);
|
||||
|
||||
export const submitMarkers = createAppAsyncThunk(
|
||||
'markers/submit',
|
||||
(params: { immediate?: boolean }, { dispatch }) => {
|
||||
debouncedSubmitMarkers(dispatch);
|
||||
|
||||
if (params.immediate) {
|
||||
debouncedSubmitMarkers.flush();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchMarkers = createAppAsyncThunk(
|
||||
'markers/fetch',
|
||||
async (_args, { getState }) => {
|
||||
const response = await api(getState).get<Record<string, MarkerJSON>>(
|
||||
`/api/v1/markers`,
|
||||
{ params: { timeline: ['notifications'] } },
|
||||
);
|
||||
|
||||
return { markers: response.data };
|
||||
},
|
||||
);
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY';
|
||||
export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE';
|
||||
|
||||
/**
|
||||
* @typedef MediaProps
|
||||
* @property {string} src
|
||||
* @property {boolean} muted
|
||||
* @property {number} volume
|
||||
* @property {number} currentTime
|
||||
* @property {string} poster
|
||||
* @property {string} backgroundColor
|
||||
* @property {string} foregroundColor
|
||||
* @property {string} accentColor
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} statusId
|
||||
* @param {string} accountId
|
||||
* @param {string} playerType
|
||||
* @param {MediaProps} props
|
||||
* @returns {object}
|
||||
*/
|
||||
export const deployPictureInPicture = (statusId, accountId, playerType, props) => {
|
||||
// @ts-expect-error
|
||||
return (dispatch, getState) => {
|
||||
// Do not open a player for a toot that does not exist
|
||||
if (getState().hasIn(['statuses', statusId])) {
|
||||
dispatch({
|
||||
type: PICTURE_IN_PICTURE_DEPLOY,
|
||||
statusId,
|
||||
accountId,
|
||||
playerType,
|
||||
props,
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
* @return {object}
|
||||
*/
|
||||
export const removePictureInPicture = () => ({
|
||||
type: PICTURE_IN_PICTURE_REMOVE,
|
||||
});
|
||||
31
app/javascript/flavours/glitch/actions/picture_in_picture.ts
Normal file
31
app/javascript/flavours/glitch/actions/picture_in_picture.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type { PIPMediaProps } from 'flavours/glitch/reducers/picture_in_picture';
|
||||
import { createAppAsyncThunk } from 'flavours/glitch/store/typed_functions';
|
||||
|
||||
interface DeployParams {
|
||||
statusId: string;
|
||||
accountId: string;
|
||||
playerType: 'audio' | 'video';
|
||||
props: PIPMediaProps;
|
||||
}
|
||||
|
||||
export const removePictureInPicture = createAction('pip/remove');
|
||||
|
||||
export const deployPictureInPictureAction =
|
||||
createAction<DeployParams>('pip/deploy');
|
||||
|
||||
export const deployPictureInPicture = createAppAsyncThunk(
|
||||
'pip/deploy',
|
||||
(args: DeployParams, { dispatch, getState }) => {
|
||||
const { statusId } = args;
|
||||
|
||||
// Do not open a player for a toot that does not exist
|
||||
|
||||
// @ts-expect-error state.statuses is not yet typed
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
if (getState().hasIn(['statuses', statusId])) {
|
||||
dispatch(deployPictureInPictureAction(args));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -29,9 +29,14 @@ const setCSRFHeader = () => {
|
|||
|
||||
void ready(setCSRFHeader);
|
||||
|
||||
export const authorizationTokenFromState = (getState?: GetState) => {
|
||||
return (
|
||||
getState && (getState().meta.get('access_token', '') as string | false)
|
||||
);
|
||||
};
|
||||
|
||||
const authorizationHeaderFromState = (getState?: GetState) => {
|
||||
const accessToken =
|
||||
getState && (getState().meta.get('access_token', '') as string);
|
||||
const accessToken = authorizationTokenFromState(getState);
|
||||
|
||||
if (!accessToken) {
|
||||
return {};
|
||||
|
|
|
|||
7
app/javascript/flavours/glitch/api_types/markers.ts
Normal file
7
app/javascript/flavours/glitch/api_types/markers.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// See app/serializers/rest/account_serializer.rb
|
||||
|
||||
export interface MarkerJSON {
|
||||
last_read_id: string;
|
||||
version: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
// See app/serializers/rest/media_attachment_serializer.rb
|
||||
|
||||
export type MediaAttachmentType =
|
||||
| 'image'
|
||||
| 'gifv'
|
||||
| 'video'
|
||||
| 'unknown'
|
||||
| 'audio';
|
||||
|
||||
export interface ApiMediaAttachmentJSON {
|
||||
id: string;
|
||||
type: MediaAttachmentType;
|
||||
url: string;
|
||||
preview_url: string;
|
||||
remoteUrl: string;
|
||||
preview_remote_url: string;
|
||||
text_url: string;
|
||||
// TODO: how to define this?
|
||||
meta: unknown;
|
||||
description?: string;
|
||||
blurhash: string;
|
||||
}
|
||||
23
app/javascript/flavours/glitch/api_types/polls.ts
Normal file
23
app/javascript/flavours/glitch/api_types/polls.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { ApiCustomEmojiJSON } from './custom_emoji';
|
||||
|
||||
// See app/serializers/rest/poll_serializer.rb
|
||||
|
||||
export interface ApiPollOptionJSON {
|
||||
title: string;
|
||||
votes_count: number;
|
||||
}
|
||||
|
||||
export interface ApiPollJSON {
|
||||
id: string;
|
||||
expires_at: string;
|
||||
expired: boolean;
|
||||
multiple: boolean;
|
||||
votes_count: number;
|
||||
voters_count: number;
|
||||
|
||||
options: ApiPollOptionJSON[];
|
||||
emojis: ApiCustomEmojiJSON[];
|
||||
|
||||
voted: boolean;
|
||||
own_votes: number[];
|
||||
}
|
||||
95
app/javascript/flavours/glitch/api_types/statuses.ts
Normal file
95
app/javascript/flavours/glitch/api_types/statuses.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// See app/serializers/rest/status_serializer.rb
|
||||
|
||||
import type { ApiAccountJSON } from './accounts';
|
||||
import type { ApiCustomEmojiJSON } from './custom_emoji';
|
||||
import type { ApiMediaAttachmentJSON } from './media_attachments';
|
||||
import type { ApiPollJSON } from './polls';
|
||||
|
||||
// See app/modals/status.rb
|
||||
export type StatusVisibility =
|
||||
| 'public'
|
||||
| 'unlisted'
|
||||
| 'private'
|
||||
// | 'limited' // This is never exposed to the API (they become `private`)
|
||||
| 'direct';
|
||||
|
||||
export interface ApiStatusApplicationJSON {
|
||||
name: string;
|
||||
website: string;
|
||||
}
|
||||
|
||||
export interface ApiTagJSON {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ApiMentionJSON {
|
||||
id: string;
|
||||
username: string;
|
||||
url: string;
|
||||
acct: string;
|
||||
}
|
||||
|
||||
export interface ApiPreviewCardJSON {
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
language: string;
|
||||
type: string;
|
||||
author_name: string;
|
||||
author_url: string;
|
||||
provider_name: string;
|
||||
provider_url: string;
|
||||
html: string;
|
||||
width: number;
|
||||
height: number;
|
||||
image: string;
|
||||
image_description: string;
|
||||
embed_url: string;
|
||||
blurhash: string;
|
||||
published_at: string;
|
||||
}
|
||||
|
||||
export interface ApiStatusJSON {
|
||||
id: string;
|
||||
created_at: string;
|
||||
in_reply_to_id?: string;
|
||||
in_reply_to_account_id?: string;
|
||||
sensitive: boolean;
|
||||
spoiler_text?: string;
|
||||
visibility: StatusVisibility;
|
||||
language: string;
|
||||
uri: string;
|
||||
url: string;
|
||||
replies_count: number;
|
||||
reblogs_count: number;
|
||||
favorites_count: number;
|
||||
edited_at?: string;
|
||||
|
||||
favorited?: boolean;
|
||||
reblogged?: boolean;
|
||||
muted?: boolean;
|
||||
bookmarked?: boolean;
|
||||
pinned?: boolean;
|
||||
|
||||
// filtered: FilterResult[]
|
||||
filtered: unknown; // TODO
|
||||
content?: string;
|
||||
text?: string;
|
||||
|
||||
reblog?: ApiStatusJSON;
|
||||
application?: ApiStatusApplicationJSON;
|
||||
account: ApiAccountJSON;
|
||||
media_attachments: ApiMediaAttachmentJSON[];
|
||||
mentions: ApiMentionJSON[];
|
||||
|
||||
tags: ApiTagJSON[];
|
||||
emojis: ApiCustomEmojiJSON[];
|
||||
|
||||
card?: ApiPreviewCardJSON;
|
||||
poll?: ApiPollJSON;
|
||||
|
||||
// glitch-soc additions
|
||||
local_only?: boolean;
|
||||
content_type?: string;
|
||||
}
|
||||
12
app/javascript/flavours/glitch/common.js
Normal file
12
app/javascript/flavours/glitch/common.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import Rails from '@rails/ujs';
|
||||
import 'font-awesome/css/font-awesome.css';
|
||||
|
||||
export function start() {
|
||||
require.context('@/images/', true, /\.(jpg|png|svg)$/);
|
||||
|
||||
try {
|
||||
Rails.start();
|
||||
} catch (e) {
|
||||
// If called twice
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +1,26 @@
|
|||
import type { PropsWithChildren } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface BaseProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
interface BaseProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||
block?: boolean;
|
||||
secondary?: boolean;
|
||||
text?: JSX.Element;
|
||||
}
|
||||
|
||||
interface PropsWithChildren extends BaseProps {
|
||||
text?: never;
|
||||
interface PropsChildren extends PropsWithChildren<BaseProps> {
|
||||
text?: undefined;
|
||||
}
|
||||
|
||||
interface PropsWithText extends BaseProps {
|
||||
text: JSX.Element;
|
||||
children: never;
|
||||
text: JSX.Element | string;
|
||||
children?: undefined;
|
||||
}
|
||||
|
||||
type Props = PropsWithText | PropsWithChildren;
|
||||
type Props = PropsWithText | PropsChildren;
|
||||
|
||||
export const Button: React.FC<Props> = ({
|
||||
text,
|
||||
type = 'button',
|
||||
onClick,
|
||||
disabled,
|
||||
|
|
@ -28,6 +28,7 @@ export const Button: React.FC<Props> = ({
|
|||
secondary,
|
||||
className,
|
||||
title,
|
||||
text,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export type StatusLike = Record<{
|
|||
|
||||
function normalizeHashtag(hashtag: string) {
|
||||
return (
|
||||
hashtag && hashtag.startsWith('#') ? hashtag.slice(1) : hashtag
|
||||
!!hashtag && hashtag.startsWith('#') ? hashtag.slice(1) : hashtag
|
||||
).normalize('NFKC');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ const timeRemainingString = (
|
|||
interface Props {
|
||||
intl: IntlShape;
|
||||
timestamp: string;
|
||||
year: number;
|
||||
year?: number;
|
||||
futureDate?: boolean;
|
||||
short?: boolean;
|
||||
}
|
||||
|
|
@ -203,11 +203,6 @@ class RelativeTimestamp extends Component<Props, States> {
|
|||
now: Date.now(),
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
year: new Date().getFullYear(),
|
||||
short: true,
|
||||
};
|
||||
|
||||
_timer: number | undefined;
|
||||
|
||||
shouldComponentUpdate(nextProps: Props, nextState: States) {
|
||||
|
|
@ -257,7 +252,13 @@ class RelativeTimestamp extends Component<Props, States> {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { timestamp, intl, year, futureDate, short } = this.props;
|
||||
const {
|
||||
timestamp,
|
||||
intl,
|
||||
futureDate,
|
||||
year = new Date().getFullYear(),
|
||||
short = true,
|
||||
} = this.props;
|
||||
|
||||
const timeGiven = timestamp.includes('T');
|
||||
const date = new Date(timestamp);
|
||||
|
|
|
|||
|
|
@ -4,11 +4,10 @@ import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
|||
import MailIcon from '@/material-icons/400-24px/mail.svg?react';
|
||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
||||
import type { StatusVisibility } from 'flavours/glitch/models/status';
|
||||
|
||||
import { Icon } from './icon';
|
||||
|
||||
type Visibility = 'public' | 'unlisted' | 'private' | 'direct';
|
||||
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
unlisted_short: {
|
||||
|
|
@ -25,7 +24,7 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
export const VisibilityIcon: React.FC<{ visibility: Visibility }> = ({
|
||||
export const VisibilityIcon: React.FC<{ visibility: StatusVisibility }> = ({
|
||||
visibility,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { defineMessages, injectIntl } from 'react-intl';
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { initBlockModal } from 'flavours/glitch/actions/blocks';
|
||||
import { initBoostModal } from 'flavours/glitch/actions/boosts';
|
||||
import {
|
||||
replyCompose,
|
||||
mentionCompose,
|
||||
|
|
@ -128,11 +127,11 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
|
|||
dispatch((_, getState) => {
|
||||
let state = getState();
|
||||
if (state.getIn(['local_settings', 'confirm_boost_missing_media_description']) && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) {
|
||||
dispatch(initBoostModal({ status, onReblog: this.onModalReblog, missingMediaDescription: true }));
|
||||
dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: this.onModalReblog, missingMediaDescription: true } }));
|
||||
} else if (e.shiftKey || !boostModal) {
|
||||
this.onModalReblog(status);
|
||||
} else {
|
||||
dispatch(initBoostModal({ status, onReblog: this.onModalReblog }));
|
||||
dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: this.onModalReblog } }));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
|
@ -292,7 +291,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
|
|||
deployPictureInPicture (status, type, mediaProps) {
|
||||
dispatch((_, getState) => {
|
||||
if (getState().getIn(['local_settings', 'media', 'pop_in_player'])) {
|
||||
dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
|
||||
dispatch(deployPictureInPicture({statusId: status.get('id'), accountId: status.getIn(['account', 'id']), playerType: type, props: mediaProps}));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -17,8 +17,13 @@ const emojiFilenames = (emojis) => {
|
|||
const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺', '📱', '📲', '🚲', '🪮', '🐦⬛']);
|
||||
const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️', '🪽', '🪿']);
|
||||
|
||||
const emojiFilename = (filename) => {
|
||||
const borderedEmoji = (document.body && document.body.classList.contains('skin-mastodon-light')) ? lightEmoji : darkEmoji;
|
||||
/**
|
||||
* @param {string} filename
|
||||
* @param {"light" | "dark" } colorScheme
|
||||
* @returns {string}
|
||||
*/
|
||||
const emojiFilename = (filename, colorScheme) => {
|
||||
const borderedEmoji = colorScheme === "light" ? lightEmoji : darkEmoji;
|
||||
return borderedEmoji.includes(filename) ? (filename + '_border') : filename;
|
||||
};
|
||||
|
||||
|
|
@ -92,12 +97,30 @@ const emojifyTextNode = (node, customEmojis) => {
|
|||
const { filename, shortCode } = unicodeMapping[unicode_emoji];
|
||||
const title = shortCode ? `:${shortCode}:` : '';
|
||||
|
||||
replacement = document.createElement('img');
|
||||
replacement.setAttribute('draggable', 'false');
|
||||
replacement.setAttribute('class', 'emojione');
|
||||
replacement.setAttribute('alt', unicode_emoji);
|
||||
replacement.setAttribute('title', title);
|
||||
replacement.setAttribute('src', `${assetHost}/emoji/${emojiFilename(filename)}.svg`);
|
||||
replacement = document.createElement('picture');
|
||||
|
||||
const isSystemTheme = !!document.body?.classList.contains('theme-system');
|
||||
|
||||
if(isSystemTheme) {
|
||||
let source = document.createElement('source');
|
||||
source.setAttribute('media', '(prefers-color-scheme: dark)');
|
||||
source.setAttribute('srcset', `${assetHost}/emoji/${emojiFilename(filename, "dark")}.svg`);
|
||||
replacement.appendChild(source);
|
||||
}
|
||||
|
||||
let img = document.createElement('img');
|
||||
img.setAttribute('draggable', 'false');
|
||||
img.setAttribute('class', 'emojione');
|
||||
img.setAttribute('alt', unicode_emoji);
|
||||
img.setAttribute('title', title);
|
||||
|
||||
let theme = "light";
|
||||
|
||||
if(!isSystemTheme && !document.body?.classList.contains('skin-mastodon-light'))
|
||||
theme = "dark";
|
||||
|
||||
img.setAttribute('src', `${assetHost}/emoji/${emojiFilename(filename, theme)}.svg`);
|
||||
replacement.appendChild(img);
|
||||
}
|
||||
|
||||
// Add the processed-up-to-now string and the emoji replacement
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ class GettingStarted extends ImmutablePureComponent {
|
|||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
myAccount: ImmutablePropTypes.map,
|
||||
myAccount: ImmutablePropTypes.record,
|
||||
columns: ImmutablePropTypes.list,
|
||||
multiColumn: PropTypes.bool,
|
||||
fetchFollowRequests: PropTypes.func.isRequired,
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
|||
<td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>s</kbd></td>
|
||||
<td><kbd>s</kbd>, <kbd>/</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.search' defaultMessage='to focus search' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export const FilteredNotificationsBanner = () => {
|
|||
};
|
||||
}, [dispatch]);
|
||||
|
||||
if (policy === null || policy.getIn(['summary', 'pending_notifications_count']) * 1 === 0) {
|
||||
if (policy === null || policy.getIn(['summary', 'pending_notifications_count']) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ export const FilteredNotificationsBanner = () => {
|
|||
|
||||
<div className='filtered-notifications-banner__badge'>
|
||||
<div className='filtered-notifications-banner__badge__badge'>{toCappedNumber(policy.getIn(['summary', 'pending_notifications_count']))}</div>
|
||||
<FormattedMessage id='filtered_notifications_banner.private_mentions' defaultMessage='{count, plural, one {private mention} other {private mentions}}' values={{ count: policy.getIn(['summary', 'pending_notifications_count']) }} />
|
||||
<FormattedMessage id='filtered_notifications_banner.mentions' defaultMessage='{count, plural, one {mention} other {mentions}}' values={{ count: policy.getIn(['summary', 'pending_notifications_count']) }} />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { initBoostModal } from '../../../actions/boosts';
|
||||
import { mentionCompose } from '../../../actions/compose';
|
||||
import {
|
||||
reblog,
|
||||
|
|
@ -8,6 +7,7 @@ import {
|
|||
unreblog,
|
||||
unfavourite,
|
||||
} from '../../../actions/interactions';
|
||||
import { openModal } from '../../../actions/modal';
|
||||
import { boostModal } from '../../../initial_state';
|
||||
import { makeGetNotification, makeGetStatus, makeGetReport } from '../../../selectors';
|
||||
import Notification from '../components/notification';
|
||||
|
|
@ -46,7 +46,7 @@ const mapDispatchToProps = dispatch => ({
|
|||
if (e.shiftKey || !boostModal) {
|
||||
this.onModalReblog(status);
|
||||
} else {
|
||||
dispatch(initBoostModal({ status, onReblog: this.onModalReblog }));
|
||||
dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: this.onModalReblog } }));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
|||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import { initBoostModal } from 'flavours/glitch/actions/boosts';
|
||||
import { replyCompose } from 'flavours/glitch/actions/compose';
|
||||
import { reblog, favourite, unreblog, unfavourite } from 'flavours/glitch/actions/interactions';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
|
|
@ -142,7 +141,7 @@ class Footer extends ImmutablePureComponent {
|
|||
} else if ((e && e.shiftKey) || !boostModal) {
|
||||
this._performReblog(status);
|
||||
} else {
|
||||
dispatch(initBoostModal({ status, onReblog: this._performReblog }));
|
||||
dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: this._performReblog } }));
|
||||
}
|
||||
} else {
|
||||
dispatch(openModal({
|
||||
|
|
@ -237,4 +236,4 @@ class Footer extends ImmutablePureComponent {
|
|||
|
||||
}
|
||||
|
||||
export default withRouter(connect(makeMapStateToProps)(injectIntl(Footer)));
|
||||
export default connect(makeMapStateToProps)(withRouter(injectIntl(Footer)));
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, { accountId }) => ({
|
||||
account: state.getIn(['accounts', accountId]),
|
||||
});
|
||||
|
||||
class Header extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
accountId: PropTypes.string.isRequired,
|
||||
statusId: PropTypes.string.isRequired,
|
||||
account: ImmutablePropTypes.record.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { account, statusId, onClose, intl } = this.props;
|
||||
|
||||
return (
|
||||
<div className='picture-in-picture__header'>
|
||||
<Link to={`/@${account.get('acct')}/${statusId}`} className='picture-in-picture__header__account'>
|
||||
<Avatar account={account} size={36} />
|
||||
<DisplayName account={account} />
|
||||
</Link>
|
||||
|
||||
<IconButton icon='times' iconComponent={CloseIcon} onClick={onClose} title={intl.formatMessage(messages.close)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(Header));
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
interface Props {
|
||||
accountId: string;
|
||||
statusId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<Props> = ({ accountId, statusId, onClose }) => {
|
||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
return (
|
||||
<div className='picture-in-picture__header'>
|
||||
<Link
|
||||
to={`/@${account.get('acct')}/${statusId}`}
|
||||
className='picture-in-picture__header__account'
|
||||
>
|
||||
<Avatar account={account} size={36} />
|
||||
<DisplayName account={account} />
|
||||
</Link>
|
||||
|
||||
<IconButton
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={onClose}
|
||||
title={intl.formatMessage(messages.close)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { Component } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture';
|
||||
import Audio from 'flavours/glitch/features/audio';
|
||||
import Video from 'flavours/glitch/features/video';
|
||||
|
||||
import Footer from './components/footer';
|
||||
import Header from './components/header';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
...state.get('picture_in_picture'),
|
||||
left: state.getIn(['local_settings', 'media', 'pop_in_position']) === 'left',
|
||||
});
|
||||
|
||||
class PictureInPicture extends Component {
|
||||
|
||||
static propTypes = {
|
||||
statusId: PropTypes.string,
|
||||
accountId: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
src: PropTypes.string,
|
||||
muted: PropTypes.bool,
|
||||
volume: PropTypes.number,
|
||||
currentTime: PropTypes.number,
|
||||
poster: PropTypes.string,
|
||||
backgroundColor: PropTypes.string,
|
||||
foregroundColor: PropTypes.string,
|
||||
accentColor: PropTypes.string,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
left: PropTypes.bool,
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(removePictureInPicture());
|
||||
};
|
||||
|
||||
render () {
|
||||
const { type, src, currentTime, accountId, statusId, left } = this.props;
|
||||
|
||||
if (!currentTime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let player;
|
||||
|
||||
if (type === 'video') {
|
||||
player = (
|
||||
<Video
|
||||
src={src}
|
||||
currentTime={this.props.currentTime}
|
||||
volume={this.props.volume}
|
||||
muted={this.props.muted}
|
||||
autoPlay
|
||||
inline
|
||||
alwaysVisible
|
||||
/>
|
||||
);
|
||||
} else if (type === 'audio') {
|
||||
player = (
|
||||
<Audio
|
||||
src={src}
|
||||
currentTime={this.props.currentTime}
|
||||
volume={this.props.volume}
|
||||
muted={this.props.muted}
|
||||
poster={this.props.poster}
|
||||
backgroundColor={this.props.backgroundColor}
|
||||
foregroundColor={this.props.foregroundColor}
|
||||
accentColor={this.props.accentColor}
|
||||
autoPlay
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('picture-in-picture', { left })}>
|
||||
<Header accountId={accountId} statusId={statusId} onClose={this.handleClose} />
|
||||
|
||||
{player}
|
||||
|
||||
<Footer statusId={statusId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(PictureInPicture);
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture';
|
||||
import Audio from 'flavours/glitch/features/audio';
|
||||
import Video from 'flavours/glitch/features/video';
|
||||
import {
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
} from 'flavours/glitch/store/typed_functions';
|
||||
|
||||
import Footer from './components/footer';
|
||||
import { Header } from './components/header';
|
||||
|
||||
export const PictureInPicture: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
dispatch(removePictureInPicture());
|
||||
}, [dispatch]);
|
||||
|
||||
const pipState = useAppSelector((s) => s.picture_in_picture);
|
||||
|
||||
const left = useAppSelector(
|
||||
// @ts-expect-error - `local_settings` is not yet typed
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
(s) => s.getIn(['local_settings', 'media', 'pop_in_position']) === 'left',
|
||||
);
|
||||
|
||||
if (pipState.type === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
type,
|
||||
src,
|
||||
currentTime,
|
||||
accountId,
|
||||
statusId,
|
||||
volume,
|
||||
muted,
|
||||
poster,
|
||||
backgroundColor,
|
||||
foregroundColor,
|
||||
accentColor,
|
||||
} = pipState;
|
||||
|
||||
let player;
|
||||
|
||||
switch (type) {
|
||||
case 'video':
|
||||
player = (
|
||||
<Video
|
||||
src={src}
|
||||
currentTime={currentTime}
|
||||
volume={volume}
|
||||
muted={muted}
|
||||
autoPlay
|
||||
inline
|
||||
alwaysVisible
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'audio':
|
||||
player = (
|
||||
<Audio
|
||||
src={src}
|
||||
currentTime={currentTime}
|
||||
volume={volume}
|
||||
muted={muted}
|
||||
poster={poster}
|
||||
backgroundColor={backgroundColor}
|
||||
foregroundColor={foregroundColor}
|
||||
accentColor={accentColor}
|
||||
autoPlay
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('picture-in-picture', { left })}>
|
||||
<Header accountId={accountId} statusId={statusId} onClose={handleClose} />
|
||||
|
||||
{player}
|
||||
|
||||
<Footer statusId={statusId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -4,7 +4,6 @@ import { connect } from 'react-redux';
|
|||
|
||||
import { showAlertForError } from '../../../actions/alerts';
|
||||
import { initBlockModal } from '../../../actions/blocks';
|
||||
import { initBoostModal } from '../../../actions/boosts';
|
||||
import {
|
||||
replyCompose,
|
||||
mentionCompose,
|
||||
|
|
@ -82,7 +81,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
if (e.shiftKey || !boostModal) {
|
||||
this.onModalReblog(status);
|
||||
} else {
|
||||
dispatch(initBoostModal({ status, onReblog: this.onModalReblog }));
|
||||
dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: this.onModalReblog } }));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning';
|
|||
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
||||
|
||||
import { initBlockModal } from '../../actions/blocks';
|
||||
import { initBoostModal } from '../../actions/boosts';
|
||||
import {
|
||||
replyCompose,
|
||||
mentionCompose,
|
||||
|
|
@ -377,11 +376,11 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
if (signedIn) {
|
||||
if (settings.get('confirm_boost_missing_media_description') && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) {
|
||||
dispatch(initBoostModal({ status, onReblog: this.handleModalReblog, missingMediaDescription: true }));
|
||||
dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: this.handleModalReblog, missingMediaDescription: true } }));
|
||||
} else if ((e && e.shiftKey) || !boostModal) {
|
||||
this.handleModalReblog(status);
|
||||
} else {
|
||||
dispatch(initBoostModal({ status, onReblog: this.handleModalReblog }));
|
||||
dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: this.handleModalReblog } }));
|
||||
}
|
||||
} else {
|
||||
dispatch(openModal({
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import { changeBoostPrivacy } from 'flavours/glitch/actions/boosts';
|
||||
import AttachmentList from 'flavours/glitch/components/attachment_list';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon';
|
||||
import PrivacyDropdown from 'flavours/glitch/features/compose/components/privacy_dropdown';
|
||||
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
||||
|
||||
import { Avatar } from '../../../components/avatar';
|
||||
import { Button } from '../../../components/button';
|
||||
import { DisplayName } from '../../../components/display_name';
|
||||
import { RelativeTimestamp } from '../../../components/relative_timestamp';
|
||||
import StatusContent from '../../../components/status_content';
|
||||
|
||||
const messages = defineMessages({
|
||||
cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
privacy: state.getIn(['boosts', 'new', 'privacy']),
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
onChangeBoostPrivacy(value) {
|
||||
dispatch(changeBoostPrivacy(value));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
class BoostModal extends ImmutablePureComponent {
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
onReblog: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
missingMediaDescription: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
...WithRouterPropTypes,
|
||||
};
|
||||
|
||||
handleReblog = () => {
|
||||
this.props.onReblog(this.props.status, this.props.privacy);
|
||||
this.props.onClose();
|
||||
};
|
||||
|
||||
handleAccountClick = (e) => {
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.props.onClose();
|
||||
this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
|
||||
}
|
||||
};
|
||||
|
||||
_findContainer = () => {
|
||||
return document.getElementsByClassName('modal-root__container')[0];
|
||||
};
|
||||
|
||||
render () {
|
||||
const { status, missingMediaDescription, privacy, intl } = this.props;
|
||||
const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog;
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal boost-modal'>
|
||||
<div className='boost-modal__container'>
|
||||
<div className={classNames('status', `status-${status.get('visibility')}`, 'light')}>
|
||||
<div className='status__info'>
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
|
||||
<span className='status__visibility-icon'><VisibilityIcon visibility={status.get('visibility')} /></span>
|
||||
<RelativeTimestamp timestamp={status.get('created_at')} />
|
||||
</a>
|
||||
|
||||
<a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'>
|
||||
<div className='status__avatar'>
|
||||
<Avatar account={status.get('account')} size={48} />
|
||||
</div>
|
||||
|
||||
<DisplayName account={status.get('account')} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<StatusContent status={status} />
|
||||
|
||||
{status.get('media_attachments').size > 0 && (
|
||||
<AttachmentList
|
||||
compact
|
||||
media={status.get('media_attachments')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='boost-modal__action-bar'>
|
||||
<div>
|
||||
{ missingMediaDescription ?
|
||||
<FormattedMessage id='boost_modal.missing_description' defaultMessage='This toot contains some media without description' />
|
||||
:
|
||||
<FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='retweet' icon={RepeatIcon} /></span> }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
{status.get('visibility') !== 'private' && !status.get('reblogged') && (
|
||||
<PrivacyDropdown
|
||||
noDirect
|
||||
value={privacy}
|
||||
container={this._findContainer}
|
||||
onChange={this.props.onChangeBoostPrivacy}
|
||||
/>
|
||||
)}
|
||||
<Button text={intl.formatMessage(buttonText)} onClick={this.handleReblog} autoFocus />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(injectIntl(BoostModal)));
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import type { MouseEventHandler } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useHistory } from 'react-router';
|
||||
|
||||
import type Immutable from 'immutable';
|
||||
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import AttachmentList from 'flavours/glitch/components/attachment_list';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon';
|
||||
import PrivacyDropdown from 'flavours/glitch/features/compose/components/privacy_dropdown';
|
||||
import type { Account } from 'flavours/glitch/models/account';
|
||||
import type { Status, StatusVisibility } from 'flavours/glitch/models/status';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
import { Avatar } from '../../../components/avatar';
|
||||
import { Button } from '../../../components/button';
|
||||
import { DisplayName } from '../../../components/display_name';
|
||||
import { RelativeTimestamp } from '../../../components/relative_timestamp';
|
||||
import StatusContent from '../../../components/status_content';
|
||||
|
||||
const messages = defineMessages({
|
||||
cancel_reblog: {
|
||||
id: 'status.cancel_reblog_private',
|
||||
defaultMessage: 'Unboost',
|
||||
},
|
||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||
});
|
||||
|
||||
export const BoostModal: React.FC<{
|
||||
status: Status;
|
||||
onClose: () => void;
|
||||
onReblog: (status: Status, privacy: StatusVisibility) => void;
|
||||
missingMediaDescription?: boolean;
|
||||
}> = ({ status, onReblog, onClose, missingMediaDescription }) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
|
||||
const default_privacy = useAppSelector(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
(state) => state.compose.get('default_privacy') as StatusVisibility,
|
||||
);
|
||||
|
||||
const account = status.get('account') as Account;
|
||||
const statusVisibility = status.get('visibility') as StatusVisibility;
|
||||
|
||||
const [privacy, setPrivacy] = useState<StatusVisibility>(
|
||||
statusVisibility === 'private' ? 'private' : default_privacy,
|
||||
);
|
||||
|
||||
const onPrivacyChange = useCallback((value: StatusVisibility) => {
|
||||
setPrivacy(value);
|
||||
}, []);
|
||||
|
||||
const handleReblog = useCallback(() => {
|
||||
onReblog(status, privacy);
|
||||
onClose();
|
||||
}, [onClose, onReblog, status, privacy]);
|
||||
|
||||
const handleAccountClick = useCallback<MouseEventHandler>(
|
||||
(e) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
history.push(`/@${account.acct}`);
|
||||
}
|
||||
},
|
||||
[history, onClose, account],
|
||||
);
|
||||
|
||||
const buttonText = status.get('reblogged')
|
||||
? messages.cancel_reblog
|
||||
: messages.reblog;
|
||||
|
||||
const findContainer = useCallback(
|
||||
() => document.getElementsByClassName('modal-root__container')[0],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal boost-modal'>
|
||||
<div className='boost-modal__container'>
|
||||
<div
|
||||
className={classNames(
|
||||
'status',
|
||||
`status-${statusVisibility}`,
|
||||
'light',
|
||||
)}
|
||||
>
|
||||
<div className='status__info'>
|
||||
<a
|
||||
href={status.get('url') as string}
|
||||
className='status__relative-time'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<span className='status__visibility-icon'>
|
||||
<VisibilityIcon visibility={statusVisibility} />
|
||||
</span>
|
||||
<RelativeTimestamp
|
||||
timestamp={status.get('created_at') as string}
|
||||
/>
|
||||
</a>
|
||||
|
||||
<a
|
||||
onClick={handleAccountClick}
|
||||
href={account.url}
|
||||
className='status__display-name'
|
||||
>
|
||||
<div className='status__avatar'>
|
||||
<Avatar account={account} size={48} />
|
||||
</div>
|
||||
|
||||
<DisplayName account={account} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* @ts-expect-error Expected until StatusContent is typed */}
|
||||
<StatusContent status={status} />
|
||||
|
||||
{(status.get('media_attachments') as Immutable.List<unknown>).size >
|
||||
0 && (
|
||||
<AttachmentList compact media={status.get('media_attachments')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='boost-modal__action-bar'>
|
||||
<div>
|
||||
{missingMediaDescription ? (
|
||||
<FormattedMessage
|
||||
id='boost_modal.missing_description'
|
||||
defaultMessage='This toot contains some media without description'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='boost_modal.combo'
|
||||
defaultMessage='You can press {combo} to skip this next time'
|
||||
values={{
|
||||
combo: (
|
||||
<span>
|
||||
Shift + <Icon id='retweet' icon={RepeatIcon} />
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{statusVisibility !== 'private' && !status.get('reblogged') && (
|
||||
<PrivacyDropdown
|
||||
noDirect
|
||||
value={privacy}
|
||||
container={findContainer}
|
||||
onChange={onPrivacyChange}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
text={intl.formatMessage(buttonText)}
|
||||
onClick={handleReblog}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -26,7 +26,7 @@ import BundleContainer from '../containers/bundle_container';
|
|||
|
||||
import ActionsModal from './actions_modal';
|
||||
import AudioModal from './audio_modal';
|
||||
import BoostModal from './boost_modal';
|
||||
import { BoostModal } from './boost_modal';
|
||||
import BundleModalError from './bundle_modal_error';
|
||||
import ConfirmationModal from './confirmation_modal';
|
||||
import DeprecatedSettingsModal from './deprecated_settings_modal';
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { changeLayout } from 'flavours/glitch/actions/app';
|
|||
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
|
||||
import { INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
|
||||
import { Permalink } from 'flavours/glitch/components/permalink';
|
||||
import PictureInPicture from 'flavours/glitch/features/picture_in_picture';
|
||||
import { PictureInPicture } from 'flavours/glitch/features/picture_in_picture';
|
||||
import { layoutFromWindow } from 'flavours/glitch/is_mobile';
|
||||
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
||||
|
||||
|
|
@ -96,7 +96,7 @@ const mapStateToProps = state => ({
|
|||
const keyMap = {
|
||||
help: '?',
|
||||
new: 'n',
|
||||
search: 's',
|
||||
search: ['s', '/'],
|
||||
forceNew: 'option+n',
|
||||
toggleComposeSpoilers: 'option+x',
|
||||
focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
|
||||
|
|
|
|||
4
app/javascript/flavours/glitch/models/status.ts
Normal file
4
app/javascript/flavours/glitch/models/status.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export type { StatusVisibility } from 'flavours/glitch/api_types/statuses';
|
||||
|
||||
// Temporary until we type it correctly
|
||||
export type Status = Immutable.Map<string, unknown>;
|
||||
|
|
@ -1,8 +1,265 @@
|
|||
import 'packs/public-path';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import Rails from '@rails/ujs';
|
||||
|
||||
import ready from 'flavours/glitch/ready';
|
||||
|
||||
const setAnnouncementEndsAttributes = (target: HTMLInputElement) => {
|
||||
const valid = target.value && target.validity.valid;
|
||||
const element = document.querySelector<HTMLInputElement>(
|
||||
'input[type="datetime-local"]#announcement_ends_at',
|
||||
);
|
||||
|
||||
if (!element) return;
|
||||
|
||||
if (valid) {
|
||||
element.classList.remove('optional');
|
||||
element.required = true;
|
||||
element.min = target.value;
|
||||
} else {
|
||||
element.classList.add('optional');
|
||||
element.removeAttribute('required');
|
||||
element.removeAttribute('min');
|
||||
}
|
||||
};
|
||||
|
||||
Rails.delegate(
|
||||
document,
|
||||
'input[type="datetime-local"]#announcement_starts_at',
|
||||
'change',
|
||||
({ target }) => {
|
||||
if (target instanceof HTMLInputElement)
|
||||
setAnnouncementEndsAttributes(target);
|
||||
},
|
||||
);
|
||||
|
||||
const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]';
|
||||
|
||||
const showSelectAll = () => {
|
||||
const selectAllMatchingElement = document.querySelector(
|
||||
'.batch-table__select-all',
|
||||
);
|
||||
selectAllMatchingElement?.classList.add('active');
|
||||
};
|
||||
|
||||
const hideSelectAll = () => {
|
||||
const selectAllMatchingElement = document.querySelector(
|
||||
'.batch-table__select-all',
|
||||
);
|
||||
const hiddenField = document.querySelector<HTMLInputElement>(
|
||||
'input#select_all_matching',
|
||||
);
|
||||
const selectedMsg = document.querySelector(
|
||||
'.batch-table__select-all .selected',
|
||||
);
|
||||
const notSelectedMsg = document.querySelector(
|
||||
'.batch-table__select-all .not-selected',
|
||||
);
|
||||
|
||||
selectAllMatchingElement?.classList.remove('active');
|
||||
selectedMsg?.classList.remove('active');
|
||||
notSelectedMsg?.classList.add('active');
|
||||
if (hiddenField) hiddenField.value = '0';
|
||||
};
|
||||
|
||||
Rails.delegate(document, '#batch_checkbox_all', 'change', ({ target }) => {
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
|
||||
const selectAllMatchingElement = document.querySelector(
|
||||
'.batch-table__select-all',
|
||||
);
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLInputElement>(batchCheckboxClassName)
|
||||
.forEach((content) => {
|
||||
content.checked = target.checked;
|
||||
});
|
||||
|
||||
if (selectAllMatchingElement) {
|
||||
if (target.checked) {
|
||||
showSelectAll();
|
||||
} else {
|
||||
hideSelectAll();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Rails.delegate(document, '.batch-table__select-all button', 'click', () => {
|
||||
const hiddenField = document.querySelector<HTMLInputElement>(
|
||||
'#select_all_matching',
|
||||
);
|
||||
|
||||
if (!hiddenField) return;
|
||||
|
||||
const active = hiddenField.value === '1';
|
||||
const selectedMsg = document.querySelector(
|
||||
'.batch-table__select-all .selected',
|
||||
);
|
||||
const notSelectedMsg = document.querySelector(
|
||||
'.batch-table__select-all .not-selected',
|
||||
);
|
||||
|
||||
if (!selectedMsg || !notSelectedMsg) return;
|
||||
|
||||
if (active) {
|
||||
hiddenField.value = '0';
|
||||
selectedMsg.classList.remove('active');
|
||||
notSelectedMsg.classList.add('active');
|
||||
} else {
|
||||
hiddenField.value = '1';
|
||||
notSelectedMsg.classList.remove('active');
|
||||
selectedMsg.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
Rails.delegate(document, batchCheckboxClassName, 'change', () => {
|
||||
const checkAllElement = document.querySelector<HTMLInputElement>(
|
||||
'input#batch_checkbox_all',
|
||||
);
|
||||
const selectAllMatchingElement = document.querySelector(
|
||||
'.batch-table__select-all',
|
||||
);
|
||||
|
||||
if (checkAllElement) {
|
||||
const allCheckboxes = Array.from(
|
||||
document.querySelectorAll<HTMLInputElement>(batchCheckboxClassName),
|
||||
);
|
||||
checkAllElement.checked = allCheckboxes.every((content) => content.checked);
|
||||
checkAllElement.indeterminate =
|
||||
!checkAllElement.checked &&
|
||||
allCheckboxes.some((content) => content.checked);
|
||||
|
||||
if (selectAllMatchingElement) {
|
||||
if (checkAllElement.checked) {
|
||||
showSelectAll();
|
||||
} else {
|
||||
hideSelectAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Rails.delegate(
|
||||
document,
|
||||
'.filter-subset--with-select select',
|
||||
'change',
|
||||
({ target }) => {
|
||||
if (target instanceof HTMLSelectElement) target.form?.submit();
|
||||
},
|
||||
);
|
||||
|
||||
const onDomainBlockSeverityChange = (target: HTMLSelectElement) => {
|
||||
const rejectMediaDiv = document.querySelector(
|
||||
'.input.with_label.domain_block_reject_media',
|
||||
);
|
||||
const rejectReportsDiv = document.querySelector(
|
||||
'.input.with_label.domain_block_reject_reports',
|
||||
);
|
||||
|
||||
if (rejectMediaDiv && rejectMediaDiv instanceof HTMLElement) {
|
||||
rejectMediaDiv.style.display =
|
||||
target.value === 'suspend' ? 'none' : 'block';
|
||||
}
|
||||
|
||||
if (rejectReportsDiv && rejectReportsDiv instanceof HTMLElement) {
|
||||
rejectReportsDiv.style.display =
|
||||
target.value === 'suspend' ? 'none' : 'block';
|
||||
}
|
||||
};
|
||||
|
||||
Rails.delegate(document, '#domain_block_severity', 'change', ({ target }) => {
|
||||
if (target instanceof HTMLSelectElement) onDomainBlockSeverityChange(target);
|
||||
});
|
||||
|
||||
const onEnableBootstrapTimelineAccountsChange = (target: HTMLInputElement) => {
|
||||
const bootstrapTimelineAccountsField =
|
||||
document.querySelector<HTMLInputElement>(
|
||||
'#form_admin_settings_bootstrap_timeline_accounts',
|
||||
);
|
||||
|
||||
if (bootstrapTimelineAccountsField) {
|
||||
bootstrapTimelineAccountsField.disabled = !target.checked;
|
||||
if (target.checked) {
|
||||
bootstrapTimelineAccountsField.parentElement?.classList.remove(
|
||||
'disabled',
|
||||
);
|
||||
bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.remove(
|
||||
'disabled',
|
||||
);
|
||||
} else {
|
||||
bootstrapTimelineAccountsField.parentElement?.classList.add('disabled');
|
||||
bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.add(
|
||||
'disabled',
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Rails.delegate(
|
||||
document,
|
||||
'#form_admin_settings_enable_bootstrap_timeline_accounts',
|
||||
'change',
|
||||
({ target }) => {
|
||||
if (target instanceof HTMLInputElement)
|
||||
onEnableBootstrapTimelineAccountsChange(target);
|
||||
},
|
||||
);
|
||||
|
||||
const onChangeRegistrationMode = (target: HTMLSelectElement) => {
|
||||
const enabled = target.value === 'approved';
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLElement>(
|
||||
'.form_admin_settings_registrations_mode .warning-hint',
|
||||
)
|
||||
.forEach((warning_hint) => {
|
||||
warning_hint.style.display = target.value === 'open' ? 'inline' : 'none';
|
||||
});
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLInputElement>(
|
||||
'input#form_admin_settings_require_invite_text',
|
||||
)
|
||||
.forEach((input) => {
|
||||
input.disabled = !enabled;
|
||||
if (enabled) {
|
||||
let element: HTMLElement | null = input;
|
||||
do {
|
||||
element.classList.remove('disabled');
|
||||
element = element.parentElement;
|
||||
} while (element && !element.classList.contains('fields-group'));
|
||||
} else {
|
||||
let element: HTMLElement | null = input;
|
||||
do {
|
||||
element.classList.add('disabled');
|
||||
element = element.parentElement;
|
||||
} while (element && !element.classList.contains('fields-group'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const convertUTCDateTimeToLocal = (value: string) => {
|
||||
const date = new Date(value + 'Z');
|
||||
const twoChars = (x: number) => x.toString().padStart(2, '0');
|
||||
return `${date.getFullYear()}-${twoChars(date.getMonth() + 1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`;
|
||||
};
|
||||
|
||||
function convertLocalDatetimeToUTC(value: string) {
|
||||
const date = new Date(value);
|
||||
const fullISO8601 = date.toISOString();
|
||||
return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6);
|
||||
}
|
||||
|
||||
Rails.delegate(
|
||||
document,
|
||||
'#form_admin_settings_registrations_mode',
|
||||
'change',
|
||||
({ target }) => {
|
||||
if (target instanceof HTMLSelectElement) onChangeRegistrationMode(target);
|
||||
},
|
||||
);
|
||||
|
||||
async function mountReactComponent(element: Element) {
|
||||
const componentName = element.getAttribute('data-admin-component');
|
||||
const stringProps = element.getAttribute('data-props');
|
||||
|
|
@ -29,9 +286,83 @@ async function mountReactComponent(element: Element) {
|
|||
}
|
||||
|
||||
ready(() => {
|
||||
const domainBlockSeveritySelect = document.querySelector<HTMLSelectElement>(
|
||||
'select#domain_block_severity',
|
||||
);
|
||||
if (domainBlockSeveritySelect)
|
||||
onDomainBlockSeverityChange(domainBlockSeveritySelect);
|
||||
|
||||
const enableBootstrapTimelineAccounts =
|
||||
document.querySelector<HTMLInputElement>(
|
||||
'input#form_admin_settings_enable_bootstrap_timeline_accounts',
|
||||
);
|
||||
if (enableBootstrapTimelineAccounts)
|
||||
onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts);
|
||||
|
||||
const registrationMode = document.querySelector<HTMLSelectElement>(
|
||||
'select#form_admin_settings_registrations_mode',
|
||||
);
|
||||
if (registrationMode) onChangeRegistrationMode(registrationMode);
|
||||
|
||||
const checkAllElement = document.querySelector<HTMLInputElement>(
|
||||
'input#batch_checkbox_all',
|
||||
);
|
||||
if (checkAllElement) {
|
||||
const allCheckboxes = Array.from(
|
||||
document.querySelectorAll<HTMLInputElement>(batchCheckboxClassName),
|
||||
);
|
||||
checkAllElement.checked = allCheckboxes.every((content) => content.checked);
|
||||
checkAllElement.indeterminate =
|
||||
!checkAllElement.checked &&
|
||||
allCheckboxes.some((content) => content.checked);
|
||||
}
|
||||
|
||||
document
|
||||
.querySelector('a#add-instance-button')
|
||||
?.addEventListener('click', (e) => {
|
||||
const domain = document.querySelector<HTMLInputElement>(
|
||||
'input[type="text"]#by_domain',
|
||||
)?.value;
|
||||
|
||||
if (domain && e.target instanceof HTMLAnchorElement) {
|
||||
const url = new URL(e.target.href);
|
||||
url.searchParams.set('_domain', domain);
|
||||
e.target.href = url.toString();
|
||||
}
|
||||
});
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLInputElement>('input[type="datetime-local"]')
|
||||
.forEach((element) => {
|
||||
if (element.value) {
|
||||
element.value = convertUTCDateTimeToLocal(element.value);
|
||||
}
|
||||
if (element.placeholder) {
|
||||
element.placeholder = convertUTCDateTimeToLocal(element.placeholder);
|
||||
}
|
||||
});
|
||||
|
||||
Rails.delegate(document, 'form', 'submit', ({ target }) => {
|
||||
if (target instanceof HTMLFormElement)
|
||||
target
|
||||
.querySelectorAll<HTMLInputElement>('input[type="datetime-local"]')
|
||||
.forEach((element) => {
|
||||
if (element.value && element.validity.valid) {
|
||||
element.value = convertLocalDatetimeToUTC(element.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const announcementStartsAt = document.querySelector<HTMLInputElement>(
|
||||
'input[type="datetime-local"]#announcement_starts_at',
|
||||
);
|
||||
if (announcementStartsAt) {
|
||||
setAnnouncementEndsAttributes(announcementStartsAt);
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-admin-component]').forEach((element) => {
|
||||
void mountReactComponent(element);
|
||||
});
|
||||
}).catch((reason) => {
|
||||
}).catch((reason: unknown) => {
|
||||
throw reason;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import 'packs/public-path';
|
||||
|
||||
import { start } from 'flavours/glitch/common';
|
||||
import { loadLocale } from 'flavours/glitch/locales';
|
||||
import main from "flavours/glitch/main";
|
||||
import { loadPolyfills } from 'flavours/glitch/polyfills';
|
||||
|
||||
start();
|
||||
|
||||
loadPolyfills()
|
||||
.then(loadLocale)
|
||||
.then(main)
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
/* This file is a hack to have something more reliable than the upstream `common` tag
|
||||
that is implicitly generated as the common chunk through webpack's `splitChunks` config */
|
||||
|
||||
import 'packs/public-path';
|
||||
import Rails from '@rails/ujs';
|
||||
import 'flavours/glitch/styles/index.scss';
|
||||
import 'font-awesome/css/font-awesome.css';
|
||||
|
||||
Rails.start();
|
||||
|
||||
// This ensures that webpack compiles our images.
|
||||
// This is a hack to ensures that webpack compiles our images.
|
||||
require.context('../images', true);
|
||||
|
|
|
|||
4
app/javascript/flavours/glitch/packs/inert.js
Normal file
4
app/javascript/flavours/glitch/packs/inert.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/* Placeholder file to have `inert.scss` compiled by Webpack
|
||||
This is used by the `wicg-inert` polyfill */
|
||||
|
||||
import '@/styles/inert.scss';
|
||||
3
app/javascript/flavours/glitch/packs/mailer.js
Normal file
3
app/javascript/flavours/glitch/packs/mailer.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import '@/styles/mailer.scss';
|
||||
|
||||
require.context('@/icons');
|
||||
|
|
@ -10,6 +10,7 @@ import Rails from '@rails/ujs';
|
|||
import axios from 'axios';
|
||||
import { throttle } from 'lodash';
|
||||
|
||||
import { start } from 'flavours/glitch/common';
|
||||
import { timeAgoString } from 'flavours/glitch/components/relative_timestamp';
|
||||
import emojify from 'flavours/glitch/features/emoji/emoji';
|
||||
import loadKeyboardExtensions from 'flavours/glitch/load_keyboard_extensions';
|
||||
|
|
@ -19,6 +20,8 @@ import ready from 'flavours/glitch/ready';
|
|||
|
||||
import 'cocoon-js-vanilla';
|
||||
|
||||
start();
|
||||
|
||||
const messages = defineMessages({
|
||||
usernameTaken: {
|
||||
id: 'username.taken',
|
||||
|
|
@ -34,6 +37,43 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
interface SetHeightMessage {
|
||||
type: 'setHeight';
|
||||
id: string;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function isSetHeightMessage(data: unknown): data is SetHeightMessage {
|
||||
if (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
'type' in data &&
|
||||
data.type === 'setHeight'
|
||||
)
|
||||
return true;
|
||||
else return false;
|
||||
}
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases
|
||||
if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return;
|
||||
|
||||
const data = e.data;
|
||||
|
||||
ready(() => {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'setHeight',
|
||||
id: data.id,
|
||||
height: document.getElementsByTagName('html')[0].scrollHeight,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}).catch((e: unknown) => {
|
||||
console.error('Error in setHeightMessage postMessage', e);
|
||||
});
|
||||
});
|
||||
|
||||
function loaded() {
|
||||
const { messages: localeData } = getLocale();
|
||||
|
||||
|
|
@ -166,7 +206,7 @@ function loaded() {
|
|||
|
||||
return true;
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch((error: unknown) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
|
@ -285,6 +325,72 @@ function loaded() {
|
|||
});
|
||||
}
|
||||
|
||||
Rails.delegate(
|
||||
document,
|
||||
'#edit_profile input[type=file]',
|
||||
'change',
|
||||
({ target }) => {
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
|
||||
const avatar = document.querySelector<HTMLImageElement>(
|
||||
`img#${target.id}-preview`,
|
||||
);
|
||||
|
||||
if (!avatar) return;
|
||||
|
||||
let file: File | undefined;
|
||||
if (target.files) file = target.files[0];
|
||||
|
||||
const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc;
|
||||
|
||||
if (url) avatar.src = url;
|
||||
},
|
||||
);
|
||||
|
||||
Rails.delegate(document, '.input-copy input', 'click', ({ target }) => {
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
|
||||
target.focus();
|
||||
target.select();
|
||||
target.setSelectionRange(0, target.value.length);
|
||||
});
|
||||
|
||||
Rails.delegate(document, '.input-copy button', 'click', ({ target }) => {
|
||||
if (!(target instanceof HTMLButtonElement)) return;
|
||||
|
||||
const input = target.parentNode?.querySelector<HTMLInputElement>(
|
||||
'.input-copy__wrapper input',
|
||||
);
|
||||
|
||||
if (!input) return;
|
||||
|
||||
const oldReadOnly = input.readOnly;
|
||||
|
||||
input.readOnly = false;
|
||||
input.focus();
|
||||
input.select();
|
||||
input.setSelectionRange(0, input.value.length);
|
||||
|
||||
try {
|
||||
if (document.execCommand('copy')) {
|
||||
input.blur();
|
||||
|
||||
const parent = target.parentElement;
|
||||
|
||||
if (!parent) return;
|
||||
parent.classList.add('copied');
|
||||
|
||||
setTimeout(() => {
|
||||
parent.classList.remove('copied');
|
||||
}, 700);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
input.readOnly = oldReadOnly;
|
||||
});
|
||||
|
||||
const toggleSidebar = () => {
|
||||
const sidebar = document.querySelector<HTMLUListElement>('.sidebar ul');
|
||||
const toggleButton = document.querySelector<HTMLAnchorElement>(
|
||||
|
|
@ -342,7 +448,7 @@ Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
|
|||
});
|
||||
|
||||
function main() {
|
||||
ready(loaded).catch((error) => {
|
||||
ready(loaded).catch((error: unknown) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
|
@ -351,6 +457,6 @@ loadPolyfills()
|
|||
.then(loadLocale)
|
||||
.then(main)
|
||||
.then(loadKeyboardExtensions)
|
||||
.catch((error) => {
|
||||
.catch((error: unknown) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import 'packs/public-path';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { start } from 'flavours/glitch/common';
|
||||
import ComposeContainer from 'flavours/glitch/containers/compose_container';
|
||||
import { loadPolyfills } from 'flavours/glitch/polyfills';
|
||||
import ready from 'flavours/glitch/ready';
|
||||
|
||||
start();
|
||||
|
||||
function loaded() {
|
||||
const mountNode = document.getElementById('mastodon-compose');
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import * as WebAuthnJSON from '@github/webauthn-json';
|
||||
import axios from 'axios';
|
||||
|
||||
import ready from 'flavours/glitch/ready';
|
||||
import 'regenerator-runtime/runtime';
|
||||
|
||||
function getCSRFToken() {
|
||||
var CSRFSelector = document.querySelector('meta[name="csrf-token"]');
|
||||
if (CSRFSelector) {
|
||||
return CSRFSelector.getAttribute('content');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hideFlashMessages() {
|
||||
Array.from(document.getElementsByClassName('flash-message')).forEach(function(flashMessage) {
|
||||
flashMessage.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
function callback(url, body) {
|
||||
axios.post(url, JSON.stringify(body), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-Token': getCSRFToken(),
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
}).then(function(response) {
|
||||
window.location.replace(response.data.redirect_path);
|
||||
}).catch(function(error) {
|
||||
if (error.response.status === 422) {
|
||||
const errorMessage = document.getElementById('security-key-error-message');
|
||||
errorMessage.classList.remove('hidden');
|
||||
console.error(error.response.data.error);
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ready(() => {
|
||||
if (!WebAuthnJSON.supported()) {
|
||||
const unsupported_browser_message = document.getElementById('unsupported-browser-message');
|
||||
if (unsupported_browser_message) {
|
||||
unsupported_browser_message.classList.remove('hidden');
|
||||
document.querySelector('.btn.js-webauthn').disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const webAuthnCredentialRegistrationForm = document.getElementById('new_webauthn_credential');
|
||||
if (webAuthnCredentialRegistrationForm) {
|
||||
webAuthnCredentialRegistrationForm.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
var nickname = event.target.querySelector('input[name="new_webauthn_credential[nickname]"]');
|
||||
if (nickname.value) {
|
||||
axios.get('/settings/security_keys/options')
|
||||
.then((response) => {
|
||||
const credentialOptions = response.data;
|
||||
|
||||
WebAuthnJSON.create({ 'publicKey': credentialOptions }).then((credential) => {
|
||||
var params = { 'credential': credential, 'nickname': nickname.value };
|
||||
callback('/settings/security_keys', params);
|
||||
}).catch((error) => {
|
||||
const errorMessage = document.getElementById('security-key-error-message');
|
||||
errorMessage.classList.remove('hidden');
|
||||
console.error(error);
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error.response.data.error);
|
||||
});
|
||||
} else {
|
||||
nickname.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const webAuthnCredentialAuthenticationForm = document.getElementById('webauthn-form');
|
||||
if (webAuthnCredentialAuthenticationForm) {
|
||||
webAuthnCredentialAuthenticationForm.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
axios.get('sessions/security_key_options')
|
||||
.then((response) => {
|
||||
const credentialOptions = response.data;
|
||||
|
||||
WebAuthnJSON.get({ 'publicKey': credentialOptions }).then((credential) => {
|
||||
var params = { 'user': { 'credential': credential } };
|
||||
callback('sign_in', params);
|
||||
}).catch((error) => {
|
||||
const errorMessage = document.getElementById('security-key-error-message');
|
||||
errorMessage.classList.remove('hidden');
|
||||
console.error(error);
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error.response.data.error);
|
||||
});
|
||||
});
|
||||
|
||||
const otpAuthenticationForm = document.getElementById('otp-authentication-form');
|
||||
|
||||
const linkToOtp = document.getElementById('link-to-otp');
|
||||
linkToOtp.addEventListener('click', () => {
|
||||
webAuthnCredentialAuthenticationForm.classList.add('hidden');
|
||||
otpAuthenticationForm.classList.remove('hidden');
|
||||
hideFlashMessages();
|
||||
});
|
||||
|
||||
const linkToWebAuthn = document.getElementById('link-to-webauthn');
|
||||
linkToWebAuthn.addEventListener('click', () => {
|
||||
otpAuthenticationForm.classList.add('hidden');
|
||||
webAuthnCredentialAuthenticationForm.classList.remove('hidden');
|
||||
hideFlashMessages();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import Immutable from 'immutable';
|
||||
|
||||
import {
|
||||
BOOSTS_INIT_MODAL,
|
||||
BOOSTS_CHANGE_PRIVACY,
|
||||
} from 'flavours/glitch/actions/boosts';
|
||||
|
||||
const initialState = Immutable.Map({
|
||||
new: Immutable.Map({
|
||||
privacy: 'public',
|
||||
}),
|
||||
});
|
||||
|
||||
export default function mutes(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case BOOSTS_INIT_MODAL:
|
||||
return state.withMutations((state) => {
|
||||
state.setIn(['new', 'privacy'], action.privacy);
|
||||
});
|
||||
case BOOSTS_CHANGE_PRIVACY:
|
||||
return state.setIn(['new', 'privacy'], action.privacy);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ import { accountsReducer } from './accounts';
|
|||
import accounts_map from './accounts_map';
|
||||
import alerts from './alerts';
|
||||
import announcements from './announcements';
|
||||
import boosts from './boosts';
|
||||
import compose from './compose';
|
||||
import contexts from './contexts';
|
||||
import conversations from './conversations';
|
||||
|
|
@ -22,14 +21,14 @@ import listAdder from './list_adder';
|
|||
import listEditor from './list_editor';
|
||||
import lists from './lists';
|
||||
import local_settings from './local_settings';
|
||||
import markers from './markers';
|
||||
import { markersReducer } from './markers';
|
||||
import media_attachments from './media_attachments';
|
||||
import meta from './meta';
|
||||
import { modalReducer } from './modal';
|
||||
import { notificationPolicyReducer } from './notification_policy';
|
||||
import { notificationRequestsReducer } from './notification_requests';
|
||||
import notifications from './notifications';
|
||||
import picture_in_picture from './picture_in_picture';
|
||||
import { pictureInPictureReducer } from './picture_in_picture';
|
||||
import pinnedAccountsEditor from './pinned_accounts_editor';
|
||||
import polls from './polls';
|
||||
import push_notifications from './push_notifications';
|
||||
|
|
@ -63,7 +62,6 @@ const reducers = {
|
|||
settings,
|
||||
local_settings,
|
||||
push_notifications,
|
||||
boosts,
|
||||
server,
|
||||
contexts,
|
||||
compose,
|
||||
|
|
@ -81,8 +79,8 @@ const reducers = {
|
|||
pinnedAccountsEditor,
|
||||
polls,
|
||||
trends,
|
||||
markers,
|
||||
picture_in_picture,
|
||||
markers: markersReducer,
|
||||
picture_in_picture: pictureInPictureReducer,
|
||||
history,
|
||||
tags,
|
||||
followed_tags,
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import {
|
||||
MARKERS_SUBMIT_SUCCESS,
|
||||
} from '../actions/markers';
|
||||
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
home: '0',
|
||||
notifications: '0',
|
||||
});
|
||||
|
||||
export default function markers(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case MARKERS_SUBMIT_SUCCESS:
|
||||
if (action.home) {
|
||||
state = state.set('home', action.home);
|
||||
}
|
||||
if (action.notifications) {
|
||||
state = state.set('notifications', action.notifications);
|
||||
}
|
||||
return state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
18
app/javascript/flavours/glitch/reducers/markers.ts
Normal file
18
app/javascript/flavours/glitch/reducers/markers.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { createReducer } from '@reduxjs/toolkit';
|
||||
|
||||
import { submitMarkersAction } from 'flavours/glitch/actions/markers';
|
||||
|
||||
const initialState = {
|
||||
home: '0',
|
||||
notifications: '0',
|
||||
};
|
||||
|
||||
export const markersReducer = createReducer(initialState, (builder) => {
|
||||
builder.addCase(
|
||||
submitMarkersAction.fulfilled,
|
||||
(state, { payload: { home, notifications } }) => {
|
||||
if (home) state.home = home;
|
||||
if (notifications) state.notifications = notifications;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
rejectFollowRequestSuccess,
|
||||
} from '../actions/accounts';
|
||||
import {
|
||||
MARKERS_FETCH_SUCCESS,
|
||||
fetchMarkers,
|
||||
} from '../actions/markers';
|
||||
import {
|
||||
NOTIFICATIONS_MOUNT,
|
||||
|
|
@ -297,8 +297,8 @@ export default function notifications(state = initialState, action) {
|
|||
let st;
|
||||
|
||||
switch(action.type) {
|
||||
case MARKERS_FETCH_SUCCESS:
|
||||
return action.markers.notifications ? recountUnread(state, action.markers.notifications.last_read_id) : state;
|
||||
case fetchMarkers.fulfilled.type:
|
||||
return action.payload.markers.notifications ? recountUnread(state, action.payload.markers.notifications.last_read_id) : state;
|
||||
case NOTIFICATIONS_MOUNT:
|
||||
return updateMounted(state);
|
||||
case NOTIFICATIONS_UNMOUNT:
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
import { PICTURE_IN_PICTURE_DEPLOY, PICTURE_IN_PICTURE_REMOVE } from 'flavours/glitch/actions/picture_in_picture';
|
||||
|
||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||
|
||||
const initialState = {
|
||||
statusId: null,
|
||||
accountId: null,
|
||||
type: null,
|
||||
src: null,
|
||||
muted: false,
|
||||
volume: 0,
|
||||
currentTime: 0,
|
||||
};
|
||||
|
||||
export default function pictureInPicture(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case PICTURE_IN_PICTURE_DEPLOY:
|
||||
return { statusId: action.statusId, accountId: action.accountId, type: action.playerType, ...action.props };
|
||||
case PICTURE_IN_PICTURE_REMOVE:
|
||||
return { ...initialState };
|
||||
case TIMELINE_DELETE:
|
||||
return (state.statusId === action.id) ? { ...initialState } : state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import type { Reducer } from '@reduxjs/toolkit';
|
||||
|
||||
import {
|
||||
deployPictureInPictureAction,
|
||||
removePictureInPicture,
|
||||
} from 'flavours/glitch/actions/picture_in_picture';
|
||||
|
||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||
|
||||
export interface PIPMediaProps {
|
||||
src: string;
|
||||
muted: boolean;
|
||||
volume: number;
|
||||
currentTime: number;
|
||||
poster: string;
|
||||
backgroundColor: string;
|
||||
foregroundColor: string;
|
||||
accentColor: string;
|
||||
}
|
||||
|
||||
interface PIPStateWithValue extends Partial<PIPMediaProps> {
|
||||
statusId: string;
|
||||
accountId: string;
|
||||
type: 'audio' | 'video';
|
||||
}
|
||||
|
||||
interface PIPStateEmpty extends Partial<PIPMediaProps> {
|
||||
type: null;
|
||||
}
|
||||
|
||||
type PIPState = PIPStateWithValue | PIPStateEmpty;
|
||||
|
||||
const initialState = {
|
||||
type: null,
|
||||
muted: false,
|
||||
volume: 0,
|
||||
currentTime: 0,
|
||||
};
|
||||
|
||||
export const pictureInPictureReducer: Reducer<PIPState> = (
|
||||
state = initialState,
|
||||
action,
|
||||
) => {
|
||||
if (deployPictureInPictureAction.match(action))
|
||||
return {
|
||||
statusId: action.payload.statusId,
|
||||
accountId: action.payload.accountId,
|
||||
type: action.payload.playerType,
|
||||
...action.payload.props,
|
||||
};
|
||||
else if (removePictureInPicture.match(action)) return initialState;
|
||||
else if (action.type === TIMELINE_DELETE)
|
||||
if (state.type && state.statusId === action.id) return initialState;
|
||||
|
||||
return state;
|
||||
};
|
||||
|
|
@ -61,7 +61,7 @@ export const makeGetStatus = () => {
|
|||
|
||||
export const makeGetPictureInPicture = () => {
|
||||
return createSelector([
|
||||
(state, { id }) => state.get('picture_in_picture').statusId === id,
|
||||
(state, { id }) => state.picture_in_picture.statusId === id,
|
||||
(state) => state.getIn(['meta', 'layout']) !== 'mobile',
|
||||
], (inUse, available) => ImmutableMap({
|
||||
inUse: inUse && available,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,27 @@
|
|||
import { isAction } from '@reduxjs/toolkit';
|
||||
import {
|
||||
isAction,
|
||||
isAsyncThunkAction,
|
||||
isRejectedWithValue,
|
||||
} from '@reduxjs/toolkit';
|
||||
import type { Action, Middleware } from '@reduxjs/toolkit';
|
||||
|
||||
import type { RootState } from '..';
|
||||
import { showAlertForError } from '../../actions/alerts';
|
||||
import type { AsyncThunkRejectValue } from '../typed_functions';
|
||||
|
||||
const defaultFailSuffix = 'FAIL';
|
||||
const isFailedAction = new RegExp(`${defaultFailSuffix}$`, 'g');
|
||||
|
||||
interface ActionWithMaybeAlertParams extends Action {
|
||||
skipAlert?: boolean;
|
||||
skipNotFound?: boolean;
|
||||
error?: unknown;
|
||||
interface ActionWithMaybeAlertParams extends Action, AsyncThunkRejectValue {}
|
||||
|
||||
interface RejectedAction extends Action {
|
||||
payload: AsyncThunkRejectValue;
|
||||
}
|
||||
|
||||
function isRejectedActionWithPayload(
|
||||
action: unknown,
|
||||
): action is RejectedAction {
|
||||
return isAsyncThunkAction(action) && isRejectedWithValue(action);
|
||||
}
|
||||
|
||||
function isActionWithmaybeAlertParams(
|
||||
|
|
@ -19,11 +30,16 @@ function isActionWithmaybeAlertParams(
|
|||
return isAction(action);
|
||||
}
|
||||
|
||||
export const errorsMiddleware: Middleware<Record<string, never>, RootState> =
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- we need to use `{}` here to ensure the dispatch types can be merged
|
||||
export const errorsMiddleware: Middleware<{}, RootState> =
|
||||
({ dispatch }) =>
|
||||
(next) =>
|
||||
(action) => {
|
||||
if (
|
||||
if (isRejectedActionWithPayload(action) && !action.payload.skipAlert) {
|
||||
dispatch(
|
||||
showAlertForError(action.payload.error, action.payload.skipNotFound),
|
||||
);
|
||||
} else if (
|
||||
isActionWithmaybeAlertParams(action) &&
|
||||
!action.skipAlert &&
|
||||
action.type.match(isFailedAction)
|
||||
|
|
|
|||
|
|
@ -51,7 +51,8 @@ const play = (audio: HTMLAudioElement) => {
|
|||
};
|
||||
|
||||
export const soundsMiddleware = (): Middleware<
|
||||
Record<string, never>,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- we need to use `{}` here to ensure the dispatch types can be merged
|
||||
{},
|
||||
RootState
|
||||
> => {
|
||||
const soundCache: Record<string, HTMLAudioElement> = {};
|
||||
|
|
|
|||
|
|
@ -7,8 +7,14 @@ import type { AppDispatch, RootState } from './store';
|
|||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
||||
export const useAppSelector = useSelector.withTypes<RootState>();
|
||||
|
||||
export interface AsyncThunkRejectValue {
|
||||
skipAlert?: boolean;
|
||||
skipNotFound?: boolean;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
|
||||
state: RootState;
|
||||
dispatch: AppDispatch;
|
||||
rejectValue: string;
|
||||
rejectValue: AsyncThunkRejectValue;
|
||||
}>();
|
||||
|
|
|
|||
27
app/javascript/flavours/glitch/styles/application.scss
Normal file
27
app/javascript/flavours/glitch/styles/application.scss
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
@import 'mixins';
|
||||
@import 'variables';
|
||||
@import 'styles/fonts/roboto';
|
||||
@import 'styles/fonts/roboto-mono';
|
||||
|
||||
@import 'reset';
|
||||
@import 'basics';
|
||||
@import 'branding';
|
||||
@import 'containers';
|
||||
@import 'lists';
|
||||
@import 'widgets';
|
||||
@import 'forms';
|
||||
@import 'accounts';
|
||||
@import 'statuses';
|
||||
@import 'components';
|
||||
@import 'polls';
|
||||
@import 'modal';
|
||||
@import 'emoji_picker';
|
||||
@import 'about';
|
||||
@import 'tables';
|
||||
@import 'admin';
|
||||
@import 'dashboard';
|
||||
@import 'rtl';
|
||||
@import 'accessibility';
|
||||
@import 'rich_text';
|
||||
@import 'glitch_local_settings';
|
||||
@import 'glitch_doodle';
|
||||
|
|
@ -2471,14 +2471,14 @@ a.account__display-name {
|
|||
&.activate {
|
||||
& > .icon {
|
||||
animation: spring-rotate-in 1s linear;
|
||||
transform-origin: 50% 55%;
|
||||
transform-origin: 50% 52%;
|
||||
}
|
||||
}
|
||||
|
||||
&.deactivate {
|
||||
& > .icon {
|
||||
animation: spring-rotate-out 1s linear;
|
||||
transform-origin: 50% 55%;
|
||||
transform-origin: 50% 52%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2839,6 +2839,7 @@ a.account__display-name {
|
|||
}
|
||||
|
||||
$ui-header-height: 55px;
|
||||
$ui-header-logo-wordmark-width: 99px;
|
||||
|
||||
.ui__header {
|
||||
display: none;
|
||||
|
|
@ -2854,6 +2855,10 @@ $ui-header-height: 55px;
|
|||
&__logo {
|
||||
display: inline-flex;
|
||||
padding: 15px;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
overflow: hidden;
|
||||
container: header-logo / inline-size;
|
||||
|
||||
.logo {
|
||||
height: $ui-header-height - 30px;
|
||||
|
|
@ -2864,7 +2869,7 @@ $ui-header-height: 55px;
|
|||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (width >= 320px) {
|
||||
@container header-logo (min-width: #{$ui-header-logo-wordmark-width}) {
|
||||
.logo--wordmark {
|
||||
display: block;
|
||||
}
|
||||
|
|
@ -2881,6 +2886,7 @@ $ui-header-height: 55px;
|
|||
gap: 10px;
|
||||
padding: 0 10px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
.button {
|
||||
flex: 0 0 auto;
|
||||
|
|
@ -2898,7 +2904,7 @@ $ui-header-height: 55px;
|
|||
}
|
||||
|
||||
.tabs-bar__wrapper {
|
||||
background: var(--background-color-tint);
|
||||
background: var(--background-color);
|
||||
backdrop-filter: var(--background-filter);
|
||||
position: sticky;
|
||||
top: $ui-header-height;
|
||||
|
|
@ -3117,24 +3123,31 @@ $ui-header-height: 55px;
|
|||
}
|
||||
}
|
||||
|
||||
.layout-single-column .ui__header {
|
||||
display: flex;
|
||||
background: var(--background-color-tint);
|
||||
border-bottom: 1px solid var(--background-border-color);
|
||||
}
|
||||
.layout-single-column {
|
||||
.ui__header {
|
||||
display: flex;
|
||||
background: var(--background-color);
|
||||
border-bottom: 1px solid var(--background-border-color);
|
||||
}
|
||||
|
||||
.column > .scrollable,
|
||||
.tabs-bar__wrapper .column-header,
|
||||
.tabs-bar__wrapper .column-back-button {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
.column > .scrollable,
|
||||
.tabs-bar__wrapper .column-header,
|
||||
.tabs-bar__wrapper .column-back-button {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.column-header,
|
||||
.column-back-button,
|
||||
.scrollable,
|
||||
.error-column {
|
||||
border-radius: 0 !important;
|
||||
.column-header,
|
||||
.column-back-button,
|
||||
.scrollable,
|
||||
.error-column {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.column-header,
|
||||
.column-back-button {
|
||||
border-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3693,10 +3706,6 @@ $ui-header-height: 55px;
|
|||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
border-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.column-header__back-button {
|
||||
|
|
@ -4483,10 +4492,6 @@ a.status-card {
|
|||
padding-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
border-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.column-header__buttons {
|
||||
|
|
@ -5488,6 +5493,7 @@ a.status-card {
|
|||
.language-dropdown__dropdown {
|
||||
box-shadow: var(--dropdown-shadow);
|
||||
background: var(--dropdown-background-color);
|
||||
backdrop-filter: var(--background-filter);
|
||||
border: 1px solid var(--dropdown-border-color);
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
|
|
@ -8457,8 +8463,13 @@ noscript {
|
|||
}
|
||||
}
|
||||
|
||||
@container account-header (max-width: 372px) {
|
||||
.optional {
|
||||
.optional {
|
||||
@container account-header (max-width: 372px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Fallback for older browsers with no container queries support
|
||||
@media screen and (max-width: 372px + 55px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -10706,9 +10717,10 @@ noscript {
|
|||
}
|
||||
|
||||
.filtered-notifications-banner__badge {
|
||||
background-color: $highlight-text-color;
|
||||
background: $ui-button-background-color;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
@import 'contrast/variables';
|
||||
@import 'index';
|
||||
@import 'application';
|
||||
@import 'contrast/diff';
|
||||
|
|
|
|||
|
|
@ -112,10 +112,11 @@
|
|||
border: 0;
|
||||
}
|
||||
|
||||
&::-moz-focus-inner,
|
||||
&:focus,
|
||||
&:active {
|
||||
outline: 0 !important;
|
||||
&:active,
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
border-width: 1px !important;
|
||||
border-color: $ui-button-background-color;
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
|
|
|
|||
|
|
@ -1,27 +1,5 @@
|
|||
@import 'mixins';
|
||||
@import 'variables';
|
||||
@import 'styles/fonts/roboto';
|
||||
@import 'styles/fonts/roboto-mono';
|
||||
/* This is a compat file so that third-party themes do not have to immediately
|
||||
update their implementation, as the previous `index.scss` has been renamed
|
||||
to `application.scss`. */
|
||||
|
||||
@import 'reset';
|
||||
@import 'basics';
|
||||
@import 'branding';
|
||||
@import 'containers';
|
||||
@import 'lists';
|
||||
@import 'modal';
|
||||
@import 'widgets';
|
||||
@import 'forms';
|
||||
@import 'accounts';
|
||||
@import 'statuses';
|
||||
@import 'components';
|
||||
@import 'polls';
|
||||
@import 'emoji_picker';
|
||||
@import 'about';
|
||||
@import 'tables';
|
||||
@import 'admin';
|
||||
@import 'accessibility';
|
||||
@import 'rtl';
|
||||
@import 'dashboard';
|
||||
@import 'rich_text';
|
||||
@import 'glitch_local_settings';
|
||||
@import 'glitch_doodle';
|
||||
@import 'application';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
@import 'mastodon-light/variables';
|
||||
@import 'index';
|
||||
@import 'application';
|
||||
@import 'mastodon-light/diff';
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ html {
|
|||
}
|
||||
|
||||
.search__input,
|
||||
.search__popout,
|
||||
.setting-text,
|
||||
.report-dialog-modal__textarea,
|
||||
.audio-player {
|
||||
|
|
@ -439,7 +440,8 @@ html {
|
|||
.directory__tag > div,
|
||||
.card > a,
|
||||
.page-header,
|
||||
.compose-form .compose-form__warning {
|
||||
.compose-form,
|
||||
.compose-form__warning {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
|
@ -551,6 +553,10 @@ html {
|
|||
|
||||
.compose-form .autosuggest-textarea__textarea,
|
||||
.compose-form__highlightable,
|
||||
.search__input,
|
||||
.search__popout,
|
||||
.emoji-mart-search input,
|
||||
.language-dropdown__dropdown .emoji-mart-search input,
|
||||
.poll__option input[type='text'] {
|
||||
background: darken($ui-base-color, 10%);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,12 @@
|
|||
# (REQUIRED) The location of the pack files.
|
||||
pack:
|
||||
admin:
|
||||
- packs/admin.tsx
|
||||
- packs/public.tsx
|
||||
auth: packs/public.tsx
|
||||
common:
|
||||
filename: packs/common.js
|
||||
stylesheet: true
|
||||
embed: packs/public.tsx
|
||||
error: packs/error.js
|
||||
home:
|
||||
filename: packs/home.js
|
||||
preload:
|
||||
- flavours/glitch/async/compose
|
||||
- flavours/glitch/async/home_timeline
|
||||
- flavours/glitch/async/notifications
|
||||
mailer:
|
||||
modal:
|
||||
public: packs/public.tsx
|
||||
settings: packs/public.tsx
|
||||
sign_up: packs/sign_up.js
|
||||
share: packs/share.jsx
|
||||
# (REQUIRED) The directory which contains the entry point files.
|
||||
pack_directory: app/javascript/flavours/glitch/packs
|
||||
|
||||
# (OPTIONAL) Define files to be preloaded when a logged-in user is
|
||||
# visiting the main web app.
|
||||
signed_in_preload:
|
||||
- flavours/glitch/async/compose.js
|
||||
- flavours/glitch/async/home_timeline.js
|
||||
- flavours/glitch/async/notifications.js
|
||||
|
||||
# (OPTIONAL) The directory which contains localization files for
|
||||
# the flavour, relative to this directory. The contents of this
|
||||
|
|
@ -34,15 +20,3 @@ inherit_locales: vanilla
|
|||
# (OPTIONAL) A file to use as the preview screenshot for the flavour,
|
||||
# or an array thereof. These are the full path from `app/javascript/`.
|
||||
screenshot: flavours/glitch/images/glitch-preview.png
|
||||
|
||||
# (OPTIONAL) The directory which contains the pack files.
|
||||
# Defaults to the theme directory (`app/javascript/themes/[theme]`),
|
||||
# which should be sufficient for like 99% of use-cases lol.
|
||||
|
||||
# pack_directory: app/javascript/packs
|
||||
|
||||
# (OPTIONAL) By default the theme will fallback to the default theme
|
||||
# if a particular pack is not provided. You can specify different
|
||||
# fallbacks here, or disable fallback behaviours altogether by
|
||||
# specifying a `null` value.
|
||||
fallback:
|
||||
|
|
|
|||
|
|
@ -70,10 +70,10 @@ export function roundTo10(num: number): number {
|
|||
return Math.round(num * 0.1) / 0.1;
|
||||
}
|
||||
|
||||
export function toCappedNumber(num: string): string {
|
||||
if (parseInt(num) > 99) {
|
||||
return '99+';
|
||||
export function toCappedNumber(num: number, max = 99): string {
|
||||
if (num > max) {
|
||||
return `${max}+`;
|
||||
} else {
|
||||
return num;
|
||||
return num.toString();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,5 +4,6 @@ export function uuid(a?: string): string {
|
|||
(a as unknown as number) ^
|
||||
((Math.random() * 16) >> ((a as unknown as number) / 4))
|
||||
).toString(16)
|
||||
: ('' + 1e7 + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid);
|
||||
: // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
||||
('' + 1e7 + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,12 @@
|
|||
# (REQUIRED) The location of the pack files inside `pack_directory`.
|
||||
pack:
|
||||
admin:
|
||||
- admin.tsx
|
||||
- public.tsx
|
||||
auth: public.tsx
|
||||
common:
|
||||
filename: common.js
|
||||
stylesheet: true
|
||||
embed: public.tsx
|
||||
error: error.js
|
||||
home:
|
||||
filename: application.js
|
||||
preload:
|
||||
- features/compose
|
||||
- features/home_timeline
|
||||
- features/notifications
|
||||
mailer:
|
||||
modal:
|
||||
public: public.tsx
|
||||
settings: public.tsx
|
||||
sign_up: sign_up.js
|
||||
share: share.jsx
|
||||
# (REQUIRED) The directory which contains the pack files.
|
||||
pack_directory: app/javascript/packs
|
||||
|
||||
# (OPTIONAL) Define files to be preloaded when a logged-in user is
|
||||
# visiting the main web app.
|
||||
signed_in_preload:
|
||||
- features/compose.js
|
||||
- features/home_timeline.js
|
||||
- features/notifications.js
|
||||
|
||||
# (OPTIONAL) The directory which contains localization files for
|
||||
# the flavour, relative to this directory.
|
||||
|
|
@ -29,15 +15,3 @@ locales: ../../mastodon/locales
|
|||
# (OPTIONAL) A file to use as the preview screenshot for the flavour,
|
||||
# or an array thereof. These are the full path from `app/javascript/`.
|
||||
screenshot: images/screenshot.png
|
||||
|
||||
# (OPTIONAL) The directory which contains the pack files.
|
||||
# Defaults to this directory (`app/javascript/flavour/[flavour]`),
|
||||
# but in the case of the vanilla Mastodon flavour the pack files are
|
||||
# somewhere else.
|
||||
pack_directory: app/javascript/packs
|
||||
|
||||
# (OPTIONAL) By default the theme will fallback to the default flavour
|
||||
# if a particular pack is not provided. You can specify different
|
||||
# fallbacks here, or disable fallback behaviours altogether by
|
||||
# specifying a `null` value.
|
||||
fallback:
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
import { openModal } from './modal';
|
||||
|
||||
export const BOOSTS_INIT_MODAL = 'BOOSTS_INIT_MODAL';
|
||||
export const BOOSTS_CHANGE_PRIVACY = 'BOOSTS_CHANGE_PRIVACY';
|
||||
|
||||
export function initBoostModal(props) {
|
||||
return (dispatch, getState) => {
|
||||
const default_privacy = getState().getIn(['compose', 'default_privacy']);
|
||||
|
||||
const privacy = props.status.get('visibility') === 'private' ? 'private' : default_privacy;
|
||||
|
||||
dispatch({
|
||||
type: BOOSTS_INIT_MODAL,
|
||||
privacy,
|
||||
});
|
||||
|
||||
dispatch(openModal({
|
||||
modalType: 'BOOST',
|
||||
modalProps: props,
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function changeBoostPrivacy(privacy) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: BOOSTS_CHANGE_PRIVACY,
|
||||
privacy,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import api from '../api';
|
||||
import { compareId } from '../compare_id';
|
||||
|
||||
export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST';
|
||||
export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS';
|
||||
export const MARKERS_FETCH_FAIL = 'MARKERS_FETCH_FAIL';
|
||||
export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS';
|
||||
|
||||
export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
|
||||
const accessToken = getState().getIn(['meta', 'access_token'], '');
|
||||
const params = _buildParams(getState());
|
||||
|
||||
if (Object.keys(params).length === 0 || accessToken === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// The Fetch API allows us to perform requests that will be carried out
|
||||
// after the page closes. But that only works if the `keepalive` attribute
|
||||
// is supported.
|
||||
if (window.fetch && 'keepalive' in new Request('')) {
|
||||
fetch('/api/v1/markers', {
|
||||
keepalive: true,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
return;
|
||||
} else if (navigator && navigator.sendBeacon) {
|
||||
// Failing that, we can use sendBeacon, but we have to encode the data as
|
||||
// FormData for DoorKeeper to recognize the token.
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('bearer_token', accessToken);
|
||||
|
||||
for (const [id, value] of Object.entries(params)) {
|
||||
formData.append(`${id}[last_read_id]`, value.last_read_id);
|
||||
}
|
||||
|
||||
if (navigator.sendBeacon('/api/v1/markers', formData)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If neither Fetch nor sendBeacon worked, try to perform a synchronous
|
||||
// request.
|
||||
try {
|
||||
const client = new XMLHttpRequest();
|
||||
|
||||
client.open('POST', '/api/v1/markers', false);
|
||||
client.setRequestHeader('Content-Type', 'application/json');
|
||||
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
|
||||
client.send(JSON.stringify(params));
|
||||
} catch (e) {
|
||||
// Do not make the BeforeUnload handler error out
|
||||
}
|
||||
};
|
||||
|
||||
const _buildParams = (state) => {
|
||||
const params = {};
|
||||
|
||||
const lastHomeId = state.getIn(['timelines', 'home', 'items'], ImmutableList()).find(item => item !== null);
|
||||
const lastNotificationId = state.getIn(['notifications', 'lastReadId']);
|
||||
|
||||
if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) {
|
||||
params.home = {
|
||||
last_read_id: lastHomeId,
|
||||
};
|
||||
}
|
||||
|
||||
if (lastNotificationId && compareId(lastNotificationId, state.getIn(['markers', 'notifications'])) > 0) {
|
||||
params.notifications = {
|
||||
last_read_id: lastNotificationId,
|
||||
};
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
const debouncedSubmitMarkers = debounce((dispatch, getState) => {
|
||||
const accessToken = getState().getIn(['meta', 'access_token'], '');
|
||||
const params = _buildParams(getState());
|
||||
|
||||
if (Object.keys(params).length === 0 || accessToken === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
api(getState).post('/api/v1/markers', params).then(() => {
|
||||
dispatch(submitMarkersSuccess(params));
|
||||
}).catch(() => {});
|
||||
}, 300000, { leading: true, trailing: true });
|
||||
|
||||
export function submitMarkersSuccess({ home, notifications }) {
|
||||
return {
|
||||
type: MARKERS_SUBMIT_SUCCESS,
|
||||
home: (home || {}).last_read_id,
|
||||
notifications: (notifications || {}).last_read_id,
|
||||
};
|
||||
}
|
||||
|
||||
export function submitMarkers(params = {}) {
|
||||
const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
|
||||
|
||||
if (params.immediate === true) {
|
||||
debouncedSubmitMarkers.flush();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export const fetchMarkers = () => (dispatch, getState) => {
|
||||
const params = { timeline: ['notifications'] };
|
||||
|
||||
dispatch(fetchMarkersRequest());
|
||||
|
||||
api(getState).get('/api/v1/markers', { params }).then(response => {
|
||||
dispatch(fetchMarkersSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchMarkersFail(error));
|
||||
});
|
||||
};
|
||||
|
||||
export function fetchMarkersRequest() {
|
||||
return {
|
||||
type: MARKERS_FETCH_REQUEST,
|
||||
skipLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchMarkersSuccess(markers) {
|
||||
return {
|
||||
type: MARKERS_FETCH_SUCCESS,
|
||||
markers,
|
||||
skipLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchMarkersFail(error) {
|
||||
return {
|
||||
type: MARKERS_FETCH_FAIL,
|
||||
error,
|
||||
skipLoading: true,
|
||||
skipAlert: true,
|
||||
};
|
||||
}
|
||||
165
app/javascript/mastodon/actions/markers.ts
Normal file
165
app/javascript/mastodon/actions/markers.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import type { MarkerJSON } from 'mastodon/api_types/markers';
|
||||
import type { RootState } from 'mastodon/store';
|
||||
import { createAppAsyncThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
import api, { authorizationTokenFromState } from '../api';
|
||||
import { compareId } from '../compare_id';
|
||||
|
||||
export const synchronouslySubmitMarkers = createAppAsyncThunk(
|
||||
'markers/submit',
|
||||
async (_args, { getState }) => {
|
||||
const accessToken = authorizationTokenFromState(getState);
|
||||
const params = buildPostMarkersParams(getState());
|
||||
|
||||
if (Object.keys(params).length === 0 || !accessToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The Fetch API allows us to perform requests that will be carried out
|
||||
// after the page closes. But that only works if the `keepalive` attribute
|
||||
// is supported.
|
||||
if ('fetch' in window && 'keepalive' in new Request('')) {
|
||||
await fetch('/api/v1/markers', {
|
||||
keepalive: true,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
} else if ('navigator' && 'sendBeacon' in navigator) {
|
||||
// Failing that, we can use sendBeacon, but we have to encode the data as
|
||||
// FormData for DoorKeeper to recognize the token.
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('bearer_token', accessToken);
|
||||
|
||||
for (const [id, value] of Object.entries(params)) {
|
||||
if (value.last_read_id)
|
||||
formData.append(`${id}[last_read_id]`, value.last_read_id);
|
||||
}
|
||||
|
||||
if (navigator.sendBeacon('/api/v1/markers', formData)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If neither Fetch nor sendBeacon worked, try to perform a synchronous
|
||||
// request.
|
||||
try {
|
||||
const client = new XMLHttpRequest();
|
||||
|
||||
client.open('POST', '/api/v1/markers', false);
|
||||
client.setRequestHeader('Content-Type', 'application/json');
|
||||
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
|
||||
client.send(JSON.stringify(params));
|
||||
} catch (e) {
|
||||
// Do not make the BeforeUnload handler error out
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
interface MarkerParam {
|
||||
last_read_id?: string;
|
||||
}
|
||||
|
||||
function getLastHomeId(state: RootState): string | undefined {
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
||||
return (
|
||||
state
|
||||
// @ts-expect-error state.timelines is not yet typed
|
||||
.getIn(['timelines', 'home', 'items'], ImmutableList())
|
||||
// @ts-expect-error state.timelines is not yet typed
|
||||
.find((item) => item !== null)
|
||||
);
|
||||
}
|
||||
|
||||
function getLastNotificationId(state: RootState): string | undefined {
|
||||
// @ts-expect-error state.notifications is not yet typed
|
||||
return state.getIn(['notifications', 'lastReadId']);
|
||||
}
|
||||
|
||||
const buildPostMarkersParams = (state: RootState) => {
|
||||
const params = {} as { home?: MarkerParam; notifications?: MarkerParam };
|
||||
|
||||
const lastHomeId = getLastHomeId(state);
|
||||
const lastNotificationId = getLastNotificationId(state);
|
||||
|
||||
if (lastHomeId && compareId(lastHomeId, state.markers.home) > 0) {
|
||||
params.home = {
|
||||
last_read_id: lastHomeId,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lastNotificationId &&
|
||||
compareId(lastNotificationId, state.markers.notifications) > 0
|
||||
) {
|
||||
params.notifications = {
|
||||
last_read_id: lastNotificationId,
|
||||
};
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
export const submitMarkersAction = createAppAsyncThunk<{
|
||||
home: string | undefined;
|
||||
notifications: string | undefined;
|
||||
}>('markers/submitAction', async (_args, { getState }) => {
|
||||
const accessToken = authorizationTokenFromState(getState);
|
||||
const params = buildPostMarkersParams(getState());
|
||||
|
||||
if (Object.keys(params).length === 0 || accessToken === '') {
|
||||
return { home: undefined, notifications: undefined };
|
||||
}
|
||||
|
||||
await api(getState).post<MarkerJSON>('/api/v1/markers', params);
|
||||
|
||||
return {
|
||||
home: params.home?.last_read_id,
|
||||
notifications: params.notifications?.last_read_id,
|
||||
};
|
||||
});
|
||||
|
||||
const debouncedSubmitMarkers = debounce(
|
||||
(dispatch) => {
|
||||
dispatch(submitMarkersAction());
|
||||
},
|
||||
300000,
|
||||
{
|
||||
leading: true,
|
||||
trailing: true,
|
||||
},
|
||||
);
|
||||
|
||||
export const submitMarkers = createAppAsyncThunk(
|
||||
'markers/submit',
|
||||
(params: { immediate?: boolean }, { dispatch }) => {
|
||||
debouncedSubmitMarkers(dispatch);
|
||||
|
||||
if (params.immediate) {
|
||||
debouncedSubmitMarkers.flush();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchMarkers = createAppAsyncThunk(
|
||||
'markers/fetch',
|
||||
async (_args, { getState }) => {
|
||||
const response = await api(getState).get<Record<string, MarkerJSON>>(
|
||||
`/api/v1/markers`,
|
||||
{ params: { timeline: ['notifications'] } },
|
||||
);
|
||||
|
||||
return { markers: response.data };
|
||||
},
|
||||
);
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY';
|
||||
export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE';
|
||||
|
||||
/**
|
||||
* @typedef MediaProps
|
||||
* @property {string} src
|
||||
* @property {boolean} muted
|
||||
* @property {number} volume
|
||||
* @property {number} currentTime
|
||||
* @property {string} poster
|
||||
* @property {string} backgroundColor
|
||||
* @property {string} foregroundColor
|
||||
* @property {string} accentColor
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} statusId
|
||||
* @param {string} accountId
|
||||
* @param {string} playerType
|
||||
* @param {MediaProps} props
|
||||
* @returns {object}
|
||||
*/
|
||||
export const deployPictureInPicture = (statusId, accountId, playerType, props) => {
|
||||
// @ts-expect-error
|
||||
return (dispatch, getState) => {
|
||||
// Do not open a player for a toot that does not exist
|
||||
if (getState().hasIn(['statuses', statusId])) {
|
||||
dispatch({
|
||||
type: PICTURE_IN_PICTURE_DEPLOY,
|
||||
statusId,
|
||||
accountId,
|
||||
playerType,
|
||||
props,
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
* @return {object}
|
||||
*/
|
||||
export const removePictureInPicture = () => ({
|
||||
type: PICTURE_IN_PICTURE_REMOVE,
|
||||
});
|
||||
31
app/javascript/mastodon/actions/picture_in_picture.ts
Normal file
31
app/javascript/mastodon/actions/picture_in_picture.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type { PIPMediaProps } from 'mastodon/reducers/picture_in_picture';
|
||||
import { createAppAsyncThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
interface DeployParams {
|
||||
statusId: string;
|
||||
accountId: string;
|
||||
playerType: 'audio' | 'video';
|
||||
props: PIPMediaProps;
|
||||
}
|
||||
|
||||
export const removePictureInPicture = createAction('pip/remove');
|
||||
|
||||
export const deployPictureInPictureAction =
|
||||
createAction<DeployParams>('pip/deploy');
|
||||
|
||||
export const deployPictureInPicture = createAppAsyncThunk(
|
||||
'pip/deploy',
|
||||
(args: DeployParams, { dispatch, getState }) => {
|
||||
const { statusId } = args;
|
||||
|
||||
// Do not open a player for a toot that does not exist
|
||||
|
||||
// @ts-expect-error state.statuses is not yet typed
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
if (getState().hasIn(['statuses', statusId])) {
|
||||
dispatch(deployPictureInPictureAction(args));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -29,9 +29,14 @@ const setCSRFHeader = () => {
|
|||
|
||||
void ready(setCSRFHeader);
|
||||
|
||||
export const authorizationTokenFromState = (getState?: GetState) => {
|
||||
return (
|
||||
getState && (getState().meta.get('access_token', '') as string | false)
|
||||
);
|
||||
};
|
||||
|
||||
const authorizationHeaderFromState = (getState?: GetState) => {
|
||||
const accessToken =
|
||||
getState && (getState().meta.get('access_token', '') as string);
|
||||
const accessToken = authorizationTokenFromState(getState);
|
||||
|
||||
if (!accessToken) {
|
||||
return {};
|
||||
|
|
|
|||
7
app/javascript/mastodon/api_types/markers.ts
Normal file
7
app/javascript/mastodon/api_types/markers.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// See app/serializers/rest/account_serializer.rb
|
||||
|
||||
export interface MarkerJSON {
|
||||
last_read_id: string;
|
||||
version: string;
|
||||
updated_at: string;
|
||||
}
|
||||
22
app/javascript/mastodon/api_types/media_attachments.ts
Normal file
22
app/javascript/mastodon/api_types/media_attachments.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// See app/serializers/rest/media_attachment_serializer.rb
|
||||
|
||||
export type MediaAttachmentType =
|
||||
| 'image'
|
||||
| 'gifv'
|
||||
| 'video'
|
||||
| 'unknown'
|
||||
| 'audio';
|
||||
|
||||
export interface ApiMediaAttachmentJSON {
|
||||
id: string;
|
||||
type: MediaAttachmentType;
|
||||
url: string;
|
||||
preview_url: string;
|
||||
remoteUrl: string;
|
||||
preview_remote_url: string;
|
||||
text_url: string;
|
||||
// TODO: how to define this?
|
||||
meta: unknown;
|
||||
description?: string;
|
||||
blurhash: string;
|
||||
}
|
||||
23
app/javascript/mastodon/api_types/polls.ts
Normal file
23
app/javascript/mastodon/api_types/polls.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { ApiCustomEmojiJSON } from './custom_emoji';
|
||||
|
||||
// See app/serializers/rest/poll_serializer.rb
|
||||
|
||||
export interface ApiPollOptionJSON {
|
||||
title: string;
|
||||
votes_count: number;
|
||||
}
|
||||
|
||||
export interface ApiPollJSON {
|
||||
id: string;
|
||||
expires_at: string;
|
||||
expired: boolean;
|
||||
multiple: boolean;
|
||||
votes_count: number;
|
||||
voters_count: number;
|
||||
|
||||
options: ApiPollOptionJSON[];
|
||||
emojis: ApiCustomEmojiJSON[];
|
||||
|
||||
voted: boolean;
|
||||
own_votes: number[];
|
||||
}
|
||||
91
app/javascript/mastodon/api_types/statuses.ts
Normal file
91
app/javascript/mastodon/api_types/statuses.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// See app/serializers/rest/status_serializer.rb
|
||||
|
||||
import type { ApiAccountJSON } from './accounts';
|
||||
import type { ApiCustomEmojiJSON } from './custom_emoji';
|
||||
import type { ApiMediaAttachmentJSON } from './media_attachments';
|
||||
import type { ApiPollJSON } from './polls';
|
||||
|
||||
// See app/modals/status.rb
|
||||
export type StatusVisibility =
|
||||
| 'public'
|
||||
| 'unlisted'
|
||||
| 'private'
|
||||
// | 'limited' // This is never exposed to the API (they become `private`)
|
||||
| 'direct';
|
||||
|
||||
export interface ApiStatusApplicationJSON {
|
||||
name: string;
|
||||
website: string;
|
||||
}
|
||||
|
||||
export interface ApiTagJSON {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ApiMentionJSON {
|
||||
id: string;
|
||||
username: string;
|
||||
url: string;
|
||||
acct: string;
|
||||
}
|
||||
|
||||
export interface ApiPreviewCardJSON {
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
language: string;
|
||||
type: string;
|
||||
author_name: string;
|
||||
author_url: string;
|
||||
provider_name: string;
|
||||
provider_url: string;
|
||||
html: string;
|
||||
width: number;
|
||||
height: number;
|
||||
image: string;
|
||||
image_description: string;
|
||||
embed_url: string;
|
||||
blurhash: string;
|
||||
published_at: string;
|
||||
}
|
||||
|
||||
export interface ApiStatusJSON {
|
||||
id: string;
|
||||
created_at: string;
|
||||
in_reply_to_id?: string;
|
||||
in_reply_to_account_id?: string;
|
||||
sensitive: boolean;
|
||||
spoiler_text?: string;
|
||||
visibility: StatusVisibility;
|
||||
language: string;
|
||||
uri: string;
|
||||
url: string;
|
||||
replies_count: number;
|
||||
reblogs_count: number;
|
||||
favorites_count: number;
|
||||
edited_at?: string;
|
||||
|
||||
favorited?: boolean;
|
||||
reblogged?: boolean;
|
||||
muted?: boolean;
|
||||
bookmarked?: boolean;
|
||||
pinned?: boolean;
|
||||
|
||||
// filtered: FilterResult[]
|
||||
filtered: unknown; // TODO
|
||||
content?: string;
|
||||
text?: string;
|
||||
|
||||
reblog?: ApiStatusJSON;
|
||||
application?: ApiStatusApplicationJSON;
|
||||
account: ApiAccountJSON;
|
||||
media_attachments: ApiMediaAttachmentJSON[];
|
||||
mentions: ApiMentionJSON[];
|
||||
|
||||
tags: ApiTagJSON[];
|
||||
emojis: ApiCustomEmojiJSON[];
|
||||
|
||||
card?: ApiPreviewCardJSON;
|
||||
poll?: ApiPollJSON;
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import Rails from '@rails/ujs';
|
|||
import 'font-awesome/css/font-awesome.css';
|
||||
|
||||
export function start() {
|
||||
require.context('../images/', true);
|
||||
require.context('../images/', true, /\.(jpg|png|svg)$/);
|
||||
|
||||
try {
|
||||
Rails.start();
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
import type { PropsWithChildren } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface BaseProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
interface BaseProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||
block?: boolean;
|
||||
secondary?: boolean;
|
||||
text?: JSX.Element;
|
||||
}
|
||||
|
||||
interface PropsWithChildren extends BaseProps {
|
||||
text?: never;
|
||||
interface PropsChildren extends PropsWithChildren<BaseProps> {
|
||||
text?: undefined;
|
||||
}
|
||||
|
||||
interface PropsWithText extends BaseProps {
|
||||
text: JSX.Element;
|
||||
children: never;
|
||||
text: JSX.Element | string;
|
||||
children?: undefined;
|
||||
}
|
||||
|
||||
type Props = PropsWithText | PropsWithChildren;
|
||||
type Props = PropsWithText | PropsChildren;
|
||||
|
||||
export const Button: React.FC<Props> = ({
|
||||
text,
|
||||
type = 'button',
|
||||
onClick,
|
||||
disabled,
|
||||
|
|
@ -28,6 +28,7 @@ export const Button: React.FC<Props> = ({
|
|||
secondary,
|
||||
className,
|
||||
title,
|
||||
text,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export type StatusLike = Record<{
|
|||
|
||||
function normalizeHashtag(hashtag: string) {
|
||||
return (
|
||||
hashtag && hashtag.startsWith('#') ? hashtag.slice(1) : hashtag
|
||||
!!hashtag && hashtag.startsWith('#') ? hashtag.slice(1) : hashtag
|
||||
).normalize('NFKC');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ const timeRemainingString = (
|
|||
interface Props {
|
||||
intl: IntlShape;
|
||||
timestamp: string;
|
||||
year: number;
|
||||
year?: number;
|
||||
futureDate?: boolean;
|
||||
short?: boolean;
|
||||
}
|
||||
|
|
@ -203,11 +203,6 @@ class RelativeTimestamp extends Component<Props, States> {
|
|||
now: Date.now(),
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
year: new Date().getFullYear(),
|
||||
short: true,
|
||||
};
|
||||
|
||||
_timer: number | undefined;
|
||||
|
||||
shouldComponentUpdate(nextProps: Props, nextState: States) {
|
||||
|
|
@ -257,7 +252,13 @@ class RelativeTimestamp extends Component<Props, States> {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { timestamp, intl, year, futureDate, short } = this.props;
|
||||
const {
|
||||
timestamp,
|
||||
intl,
|
||||
futureDate,
|
||||
year = new Date().getFullYear(),
|
||||
short = true,
|
||||
} = this.props;
|
||||
|
||||
const timeGiven = timestamp.includes('T');
|
||||
const date = new Date(timestamp);
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
relationship: ImmutablePropTypes.map,
|
||||
relationship: ImmutablePropTypes.record,
|
||||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
|
|
|
|||
|
|
@ -4,11 +4,10 @@ import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?re
|
|||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
||||
import type { StatusVisibility } from 'mastodon/models/status';
|
||||
|
||||
import { Icon } from './icon';
|
||||
|
||||
type Visibility = 'public' | 'unlisted' | 'private' | 'direct';
|
||||
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
unlisted_short: {
|
||||
|
|
@ -25,7 +24,7 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
export const VisibilityIcon: React.FC<{ visibility: Visibility }> = ({
|
||||
export const VisibilityIcon: React.FC<{ visibility: StatusVisibility }> = ({
|
||||
visibility,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
} from '../actions/accounts';
|
||||
import { showAlertForError } from '../actions/alerts';
|
||||
import { initBlockModal } from '../actions/blocks';
|
||||
import { initBoostModal } from '../actions/boosts';
|
||||
import {
|
||||
replyCompose,
|
||||
mentionCompose,
|
||||
|
|
@ -107,7 +106,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
|
|||
if ((e && e.shiftKey) || !boostModal) {
|
||||
this.onModalReblog(status);
|
||||
} else {
|
||||
dispatch(initBoostModal({ status, onReblog: this.onModalReblog }));
|
||||
dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: this.onModalReblog } }));
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -262,7 +261,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
|
|||
},
|
||||
|
||||
deployPictureInPicture (status, type, mediaProps) {
|
||||
dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
|
||||
dispatch(deployPictureInPicture({statusId: status.get('id'), accountId: status.getIn(['account', 'id']), playerType: type, props: mediaProps}));
|
||||
},
|
||||
|
||||
onInteractionModal (type, status) {
|
||||
|
|
|
|||
|
|
@ -22,23 +22,23 @@ describe('emoji', () => {
|
|||
|
||||
it('does unicode', () => {
|
||||
expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual(
|
||||
'<img draggable="false" class="emojione" alt="👩👩👦👦" title=":woman-woman-boy-boy:" src="/emoji/1f469-200d-1f469-200d-1f466-200d-1f466.svg">');
|
||||
'<picture><img draggable="false" class="emojione" alt="👩👩👦👦" title=":woman-woman-boy-boy:" src="/emoji/1f469-200d-1f469-200d-1f466-200d-1f466.svg"></picture>');
|
||||
expect(emojify('👨👩👧👧')).toEqual(
|
||||
'<img draggable="false" class="emojione" alt="👨👩👧👧" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg">');
|
||||
expect(emojify('👩👩👦')).toEqual('<img draggable="false" class="emojione" alt="👩👩👦" title=":woman-woman-boy:" src="/emoji/1f469-200d-1f469-200d-1f466.svg">');
|
||||
'<picture><img draggable="false" class="emojione" alt="👨👩👧👧" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg"></picture>');
|
||||
expect(emojify('👩👩👦')).toEqual('<picture><img draggable="false" class="emojione" alt="👩👩👦" title=":woman-woman-boy:" src="/emoji/1f469-200d-1f469-200d-1f466.svg"></picture>');
|
||||
expect(emojify('\u2757')).toEqual(
|
||||
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg">');
|
||||
'<picture><img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"></picture>');
|
||||
});
|
||||
|
||||
it('does multiple unicode', () => {
|
||||
expect(emojify('\u2757 #\uFE0F\u20E3')).toEqual(
|
||||
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg">');
|
||||
'<picture><img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"></picture> <picture><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg"></picture>');
|
||||
expect(emojify('\u2757#\uFE0F\u20E3')).toEqual(
|
||||
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg">');
|
||||
'<picture><img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"></picture><picture><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg"></picture>');
|
||||
expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).toEqual(
|
||||
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg"> <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg">');
|
||||
'<picture><img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"></picture> <picture><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg"></picture> <picture><img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"></picture>');
|
||||
expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).toEqual(
|
||||
'foo <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg"> bar');
|
||||
'foo <picture><img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"></picture> <picture><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg"></picture> bar');
|
||||
});
|
||||
|
||||
it('ignores unicode inside of tags', () => {
|
||||
|
|
@ -46,16 +46,16 @@ describe('emoji', () => {
|
|||
});
|
||||
|
||||
it('does multiple emoji properly (issue 5188)', () => {
|
||||
expect(emojify('👌🌈💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg"><img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg"><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg">');
|
||||
expect(emojify('👌 🌈 💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg"> <img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg"> <img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg">');
|
||||
expect(emojify('👌🌈💕')).toEqual('<picture><img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg"></picture><picture><img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg"></picture><picture><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg"></picture>');
|
||||
expect(emojify('👌 🌈 💕')).toEqual('<picture><img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg"></picture> <picture><img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg"></picture> <picture><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg"></picture>');
|
||||
});
|
||||
|
||||
it('does an emoji that has no shortcode', () => {
|
||||
expect(emojify('👁🗨')).toEqual('<img draggable="false" class="emojione" alt="👁🗨" title="" src="/emoji/1f441-200d-1f5e8.svg">');
|
||||
expect(emojify('👁🗨')).toEqual('<picture><img draggable="false" class="emojione" alt="👁🗨" title="" src="/emoji/1f441-200d-1f5e8.svg"></picture>');
|
||||
});
|
||||
|
||||
it('does an emoji whose filename is irregular', () => {
|
||||
expect(emojify('↙️')).toEqual('<img draggable="false" class="emojione" alt="↙️" title=":arrow_lower_left:" src="/emoji/2199.svg">');
|
||||
expect(emojify('↙️')).toEqual('<picture><img draggable="false" class="emojione" alt="↙️" title=":arrow_lower_left:" src="/emoji/2199.svg"></picture>');
|
||||
});
|
||||
|
||||
it('avoid emojifying on invisible text', () => {
|
||||
|
|
@ -67,11 +67,11 @@ describe('emoji', () => {
|
|||
|
||||
it('avoid emojifying on invisible text with nested tags', () => {
|
||||
expect(emojify('<span class="invisible">😄<span class="foo">bar</span>😴</span>😇'))
|
||||
.toEqual('<span class="invisible">😄<span class="foo">bar</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
|
||||
.toEqual('<span class="invisible">😄<span class="foo">bar</span>😴</span><picture><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg"></picture>');
|
||||
expect(emojify('<span class="invisible">😄<span class="invisible">😕</span>😴</span>😇'))
|
||||
.toEqual('<span class="invisible">😄<span class="invisible">😕</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
|
||||
.toEqual('<span class="invisible">😄<span class="invisible">😕</span>😴</span><picture><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg"></picture>');
|
||||
expect(emojify('<span class="invisible">😄<br>😴</span>😇'))
|
||||
.toEqual('<span class="invisible">😄<br>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
|
||||
.toEqual('<span class="invisible">😄<br>😴</span><picture><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg"></picture>');
|
||||
});
|
||||
|
||||
it('does not emojify emojis with textual presentation VS15 character', () => {
|
||||
|
|
@ -79,19 +79,19 @@ describe('emoji', () => {
|
|||
.toEqual('✴︎');
|
||||
});
|
||||
|
||||
it('does an simple emoji properly', () => {
|
||||
it('does a simple emoji properly', () => {
|
||||
expect(emojify('♀♂'))
|
||||
.toEqual('<img draggable="false" class="emojione" alt="♀" title=":female_sign:" src="/emoji/2640.svg"><img draggable="false" class="emojione" alt="♂" title=":male_sign:" src="/emoji/2642.svg">');
|
||||
.toEqual('<picture><img draggable="false" class="emojione" alt="♀" title=":female_sign:" src="/emoji/2640.svg"></picture><picture><img draggable="false" class="emojione" alt="♂" title=":male_sign:" src="/emoji/2642.svg"></picture>');
|
||||
});
|
||||
|
||||
it('does an emoji containing ZWJ properly', () => {
|
||||
expect(emojify('💂♀️💂♂️'))
|
||||
.toEqual('<img draggable="false" class="emojione" alt="💂\u200D♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f_border.svg"><img draggable="false" class="emojione" alt="💂\u200D♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f_border.svg">');
|
||||
.toEqual('<picture><img draggable="false" class="emojione" alt="💂\u200D♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f_border.svg"></picture><picture><img draggable="false" class="emojione" alt="💂\u200D♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f_border.svg"></picture>');
|
||||
});
|
||||
|
||||
it('keeps ordering as expected (issue fixed by PR 20677)', () => {
|
||||
expect(emojify('<p>💕 <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener noreferrer" target="_blank">#<span>foo</span></a> test: foo.</p>'))
|
||||
.toEqual('<p><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg"> <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener noreferrer" target="_blank">#<span>foo</span></a> test: foo.</p>');
|
||||
.toEqual('<p><picture><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg"></picture> <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener noreferrer" target="_blank">#<span>foo</span></a> test: foo.</p>');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,8 +17,13 @@ const emojiFilenames = (emojis) => {
|
|||
const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺', '📱', '📲', '🚲', '🪮', '🐦⬛']);
|
||||
const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️', '🪽', '🪿']);
|
||||
|
||||
const emojiFilename = (filename) => {
|
||||
const borderedEmoji = (document.body && document.body.classList.contains('theme-mastodon-light')) ? lightEmoji : darkEmoji;
|
||||
/**
|
||||
* @param {string} filename
|
||||
* @param {"light" | "dark" } colorScheme
|
||||
* @returns {string}
|
||||
*/
|
||||
const emojiFilename = (filename, colorScheme) => {
|
||||
const borderedEmoji = colorScheme === "light" ? lightEmoji : darkEmoji;
|
||||
return borderedEmoji.includes(filename) ? (filename + '_border') : filename;
|
||||
};
|
||||
|
||||
|
|
@ -92,12 +97,30 @@ const emojifyTextNode = (node, customEmojis) => {
|
|||
const { filename, shortCode } = unicodeMapping[unicode_emoji];
|
||||
const title = shortCode ? `:${shortCode}:` : '';
|
||||
|
||||
replacement = document.createElement('img');
|
||||
replacement.setAttribute('draggable', 'false');
|
||||
replacement.setAttribute('class', 'emojione');
|
||||
replacement.setAttribute('alt', unicode_emoji);
|
||||
replacement.setAttribute('title', title);
|
||||
replacement.setAttribute('src', `${assetHost}/emoji/${emojiFilename(filename)}.svg`);
|
||||
replacement = document.createElement('picture');
|
||||
|
||||
const isSystemTheme = !!document.body?.classList.contains('theme-system');
|
||||
|
||||
if(isSystemTheme) {
|
||||
let source = document.createElement('source');
|
||||
source.setAttribute('media', '(prefers-color-scheme: dark)');
|
||||
source.setAttribute('srcset', `${assetHost}/emoji/${emojiFilename(filename, "dark")}.svg`);
|
||||
replacement.appendChild(source);
|
||||
}
|
||||
|
||||
let img = document.createElement('img');
|
||||
img.setAttribute('draggable', 'false');
|
||||
img.setAttribute('class', 'emojione');
|
||||
img.setAttribute('alt', unicode_emoji);
|
||||
img.setAttribute('title', title);
|
||||
|
||||
let theme = "light";
|
||||
|
||||
if(!isSystemTheme && !document.body?.classList.contains('theme-mastodon-light'))
|
||||
theme = "dark";
|
||||
|
||||
img.setAttribute('src', `${assetHost}/emoji/${emojiFilename(filename, theme)}.svg`);
|
||||
replacement.appendChild(img);
|
||||
}
|
||||
|
||||
// Add the processed-up-to-now string and the emoji replacement
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ class GettingStarted extends ImmutablePureComponent {
|
|||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
myAccount: ImmutablePropTypes.map,
|
||||
myAccount: ImmutablePropTypes.record,
|
||||
multiColumn: PropTypes.bool,
|
||||
fetchFollowRequests: PropTypes.func.isRequired,
|
||||
unreadFollowRequests: PropTypes.number,
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
|||
<td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>s</kbd></td>
|
||||
<td><kbd>s</kbd>, <kbd>/</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.search' defaultMessage='to focus search' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export const FilteredNotificationsBanner = () => {
|
|||
};
|
||||
}, [dispatch]);
|
||||
|
||||
if (policy === null || policy.getIn(['summary', 'pending_notifications_count']) * 1 === 0) {
|
||||
if (policy === null || policy.getIn(['summary', 'pending_notifications_count']) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ export const FilteredNotificationsBanner = () => {
|
|||
|
||||
<div className='filtered-notifications-banner__badge'>
|
||||
<div className='filtered-notifications-banner__badge__badge'>{toCappedNumber(policy.getIn(['summary', 'pending_notifications_count']))}</div>
|
||||
<FormattedMessage id='filtered_notifications_banner.private_mentions' defaultMessage='{count, plural, one {private mention} other {private mentions}}' values={{ count: policy.getIn(['summary', 'pending_notifications_count']) }} />
|
||||
<FormattedMessage id='filtered_notifications_banner.mentions' defaultMessage='{count, plural, one {mention} other {mentions}}' values={{ count: policy.getIn(['summary', 'pending_notifications_count']) }} />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { initBoostModal } from '../../../actions/boosts';
|
||||
import { mentionCompose } from '../../../actions/compose';
|
||||
import {
|
||||
reblog,
|
||||
|
|
@ -8,6 +7,7 @@ import {
|
|||
unreblog,
|
||||
unfavourite,
|
||||
} from '../../../actions/interactions';
|
||||
import { openModal } from '../../../actions/modal';
|
||||
import {
|
||||
hideStatus,
|
||||
revealStatus,
|
||||
|
|
@ -49,7 +49,7 @@ const mapDispatchToProps = dispatch => ({
|
|||
if (e.shiftKey || !boostModal) {
|
||||
this.onModalReblog(status);
|
||||
} else {
|
||||
dispatch(initBoostModal({ status, onReblog: this.onModalReblog }));
|
||||
dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: this.onModalReblog } }));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import EditNoteIcon from '@/material-icons/400-24px/edit_note.svg?react';
|
|||
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
|
||||
import { focusCompose } from 'mastodon/actions/compose';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
|
@ -42,42 +43,44 @@ const Onboarding = () => {
|
|||
|
||||
return (
|
||||
<Column>
|
||||
<Switch>
|
||||
<Route path='/start' exact>
|
||||
<div className='scrollable privacy-policy'>
|
||||
<div className='column-title'>
|
||||
<img src={illustration} alt='' className='onboarding__illustration' />
|
||||
<h3><FormattedMessage id='onboarding.start.title' defaultMessage="You've made it!" /></h3>
|
||||
<p><FormattedMessage id='onboarding.start.lead' defaultMessage="Your new Mastodon account is ready to go. Here's how you can make the most of it:" /></p>
|
||||
{account ? (
|
||||
<Switch>
|
||||
<Route path='/start' exact>
|
||||
<div className='scrollable privacy-policy'>
|
||||
<div className='column-title'>
|
||||
<img src={illustration} alt='' className='onboarding__illustration' />
|
||||
<h3><FormattedMessage id='onboarding.start.title' defaultMessage="You've made it!" /></h3>
|
||||
<p><FormattedMessage id='onboarding.start.lead' defaultMessage="Your new Mastodon account is ready to go. Here's how you can make the most of it:" /></p>
|
||||
</div>
|
||||
|
||||
<div className='onboarding__steps'>
|
||||
<Step to='/start/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' iconComponent={AccountCircleIcon} label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} />
|
||||
<Step to='/start/follows' completed={(account.get('following_count') * 1) >= 1} icon='user-plus' iconComponent={PersonAddIcon} label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Find at least {count, plural, one {one person} other {# people}} to follow' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own home feed. Let's fill it with interesting people." />} />
|
||||
<Step onClick={handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' iconComponent={EditNoteIcon} label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' values={{ emoji: <img className='emojione' alt='🐘' src={`${assetHost}/emoji/1f418.svg`} /> }} />} />
|
||||
<Step to='/start/share' icon='copy' iconComponent={ContentCopyIcon} label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} />
|
||||
</div>
|
||||
|
||||
<p className='onboarding__lead'><FormattedMessage id='onboarding.start.skip' defaultMessage="Don't need help getting started?" /></p>
|
||||
|
||||
<div className='onboarding__links'>
|
||||
<Link to='/explore' className='onboarding__link'>
|
||||
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
|
||||
<Icon icon={ArrowRightAltIcon} />
|
||||
</Link>
|
||||
|
||||
<Link to='/home' className='onboarding__link'>
|
||||
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
|
||||
<Icon icon={ArrowRightAltIcon} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
<div className='onboarding__steps'>
|
||||
<Step to='/start/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' iconComponent={AccountCircleIcon} label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} />
|
||||
<Step to='/start/follows' completed={(account.get('following_count') * 1) >= 1} icon='user-plus' iconComponent={PersonAddIcon} label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Find at least {count, plural, one {one person} other {# people}} to follow' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own home feed. Let's fill it with interesting people." />} />
|
||||
<Step onClick={handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' iconComponent={EditNoteIcon} label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' values={{ emoji: <img className='emojione' alt='🐘' src={`${assetHost}/emoji/1f418.svg`} /> }} />} />
|
||||
<Step to='/start/share' icon='copy' iconComponent={ContentCopyIcon} label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} />
|
||||
</div>
|
||||
|
||||
<p className='onboarding__lead'><FormattedMessage id='onboarding.start.skip' defaultMessage="Don't need help getting started?" /></p>
|
||||
|
||||
<div className='onboarding__links'>
|
||||
<Link to='/explore' className='onboarding__link'>
|
||||
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
|
||||
<Icon icon={ArrowRightAltIcon} />
|
||||
</Link>
|
||||
|
||||
<Link to='/home' className='onboarding__link'>
|
||||
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
|
||||
<Icon icon={ArrowRightAltIcon} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
<Route path='/start/profile' component={Profile} />
|
||||
<Route path='/start/follows' component={Follows} />
|
||||
<Route path='/start/share' component={Share} />
|
||||
</Switch>
|
||||
<Route path='/start/profile' component={Profile} />
|
||||
<Route path='/start/follows' component={Follows} />
|
||||
<Route path='/start/share' component={Share} />
|
||||
</Switch>
|
||||
) : <NotSignedInIndicator />}
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
|||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import { initBoostModal } from 'mastodon/actions/boosts';
|
||||
import { replyCompose } from 'mastodon/actions/compose';
|
||||
import { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
|
|
@ -140,7 +139,7 @@ class Footer extends ImmutablePureComponent {
|
|||
} else if ((e && e.shiftKey) || !boostModal) {
|
||||
this._performReblog(status);
|
||||
} else {
|
||||
dispatch(initBoostModal({ status, onReblog: this._performReblog }));
|
||||
dispatch(openModal({ modalType: 'BOOST', modalProps: { status, onReblog: this._performReblog } }));
|
||||
}
|
||||
} else {
|
||||
dispatch(openModal({
|
||||
|
|
@ -210,4 +209,4 @@ class Footer extends ImmutablePureComponent {
|
|||
|
||||
}
|
||||
|
||||
export default withRouter(connect(makeMapStateToProps)(injectIntl(Footer)));
|
||||
export default connect(makeMapStateToProps)(withRouter(injectIntl(Footer)));
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, { accountId }) => ({
|
||||
account: state.getIn(['accounts', accountId]),
|
||||
});
|
||||
|
||||
class Header extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
accountId: PropTypes.string.isRequired,
|
||||
statusId: PropTypes.string.isRequired,
|
||||
account: ImmutablePropTypes.record.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { account, statusId, onClose, intl } = this.props;
|
||||
|
||||
return (
|
||||
<div className='picture-in-picture__header'>
|
||||
<Link to={`/@${account.get('acct')}/${statusId}`} className='picture-in-picture__header__account'>
|
||||
<Avatar account={account} size={36} />
|
||||
<DisplayName account={account} />
|
||||
</Link>
|
||||
|
||||
<IconButton icon='times' iconComponent={CloseIcon} onClick={onClose} title={intl.formatMessage(messages.close)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(Header));
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
interface Props {
|
||||
accountId: string;
|
||||
statusId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<Props> = ({ accountId, statusId, onClose }) => {
|
||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
return (
|
||||
<div className='picture-in-picture__header'>
|
||||
<Link
|
||||
to={`/@${account.get('acct')}/${statusId}`}
|
||||
className='picture-in-picture__header__account'
|
||||
>
|
||||
<Avatar account={account} size={36} />
|
||||
<DisplayName account={account} />
|
||||
</Link>
|
||||
|
||||
<IconButton
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={onClose}
|
||||
title={intl.formatMessage(messages.close)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { Component } from 'react';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
|
||||
import Audio from 'mastodon/features/audio';
|
||||
import Video from 'mastodon/features/video';
|
||||
|
||||
import Footer from './components/footer';
|
||||
import Header from './components/header';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
...state.get('picture_in_picture'),
|
||||
});
|
||||
|
||||
class PictureInPicture extends Component {
|
||||
|
||||
static propTypes = {
|
||||
statusId: PropTypes.string,
|
||||
accountId: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
src: PropTypes.string,
|
||||
muted: PropTypes.bool,
|
||||
volume: PropTypes.number,
|
||||
currentTime: PropTypes.number,
|
||||
poster: PropTypes.string,
|
||||
backgroundColor: PropTypes.string,
|
||||
foregroundColor: PropTypes.string,
|
||||
accentColor: PropTypes.string,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(removePictureInPicture());
|
||||
};
|
||||
|
||||
render () {
|
||||
const { type, src, currentTime, accountId, statusId } = this.props;
|
||||
|
||||
if (!currentTime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let player;
|
||||
|
||||
if (type === 'video') {
|
||||
player = (
|
||||
<Video
|
||||
src={src}
|
||||
currentTime={this.props.currentTime}
|
||||
volume={this.props.volume}
|
||||
muted={this.props.muted}
|
||||
autoPlay
|
||||
inline
|
||||
alwaysVisible
|
||||
/>
|
||||
);
|
||||
} else if (type === 'audio') {
|
||||
player = (
|
||||
<Audio
|
||||
src={src}
|
||||
currentTime={this.props.currentTime}
|
||||
volume={this.props.volume}
|
||||
muted={this.props.muted}
|
||||
poster={this.props.poster}
|
||||
backgroundColor={this.props.backgroundColor}
|
||||
foregroundColor={this.props.foregroundColor}
|
||||
accentColor={this.props.accentColor}
|
||||
autoPlay
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='picture-in-picture'>
|
||||
<Header accountId={accountId} statusId={statusId} onClose={this.handleClose} />
|
||||
|
||||
{player}
|
||||
|
||||
<Footer statusId={statusId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(PictureInPicture);
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
|
||||
import Audio from 'mastodon/features/audio';
|
||||
import Video from 'mastodon/features/video';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store/typed_functions';
|
||||
|
||||
import Footer from './components/footer';
|
||||
import { Header } from './components/header';
|
||||
|
||||
export const PictureInPicture: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
dispatch(removePictureInPicture());
|
||||
}, [dispatch]);
|
||||
|
||||
const pipState = useAppSelector((s) => s.picture_in_picture);
|
||||
|
||||
if (pipState.type === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
type,
|
||||
src,
|
||||
currentTime,
|
||||
accountId,
|
||||
statusId,
|
||||
volume,
|
||||
muted,
|
||||
poster,
|
||||
backgroundColor,
|
||||
foregroundColor,
|
||||
accentColor,
|
||||
} = pipState;
|
||||
|
||||
let player;
|
||||
|
||||
switch (type) {
|
||||
case 'video':
|
||||
player = (
|
||||
<Video
|
||||
src={src}
|
||||
currentTime={currentTime}
|
||||
volume={volume}
|
||||
muted={muted}
|
||||
autoPlay
|
||||
inline
|
||||
alwaysVisible
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'audio':
|
||||
player = (
|
||||
<Audio
|
||||
src={src}
|
||||
currentTime={currentTime}
|
||||
volume={volume}
|
||||
muted={muted}
|
||||
poster={poster}
|
||||
backgroundColor={backgroundColor}
|
||||
foregroundColor={foregroundColor}
|
||||
accentColor={accentColor}
|
||||
autoPlay
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='picture-in-picture'>
|
||||
<Header accountId={accountId} statusId={statusId} onClose={handleClose} />
|
||||
|
||||
{player}
|
||||
|
||||
<Footer statusId={statusId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -74,7 +74,7 @@ class ActionBar extends PureComponent {
|
|||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
relationship: ImmutablePropTypes.map,
|
||||
relationship: ImmutablePropTypes.record,
|
||||
onReply: PropTypes.func.isRequired,
|
||||
onReblog: PropTypes.func.isRequired,
|
||||
onFavourite: PropTypes.func.isRequired,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue